diff --git a/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomEndpointAppService.cs b/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomEndpointAppService.cs index 4be694ae..91a45f2c 100644 --- a/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomEndpointAppService.cs +++ b/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomEndpointAppService.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Sozsoft.Platform.DynamicData; using Sozsoft.Platform.Entities; @@ -55,6 +57,20 @@ public class CustomEndpointAppService : PlatformAppService return await Execute("POST"); } + [HttpPut("{**path}")] + [Authorize(PlatformConsts.AppCodes.DeveloperKits.Put)] + public async Task PutAsync() + { + return await Execute("PUT"); + } + + [HttpDelete("{**path}")] + [Authorize(PlatformConsts.AppCodes.DeveloperKits.Remove)] + public async Task DeleteAsync() + { + return await Execute("DELETE"); + } + private async Task Execute(string method) { using var uow = UnitOfWorkManager.Begin(new AbpUnitOfWorkOptions(false), true); @@ -70,7 +86,7 @@ public class CustomEndpointAppService : PlatformAppService .EnsureStartsWith('/') .EnsureEndsWith('/'); - Logger.LogInformation("Custom Endpoint called. User: {user} Path: [{method}]{path}", CurrentUser.UserName, "GET", path); + Logger.LogInformation("Custom Endpoint called. User: {user} Path: [{method}]{path}", CurrentUser.UserName, method, path); var api = await repo.FirstOrDefaultAsync(a => path.StartsWith(a.Url) && a.Method == method); if (api is null) { @@ -135,9 +151,9 @@ public class CustomEndpointAppService : PlatformAppService } // 4- Body - if (method == "POST") + if (method == "POST" || method == "PUT") { - var body = await httpContextAccessor.HttpContext.Request.ReadFormAsync(); + var body = await ReadBodyValuesAsync(); foreach (var item in api.Parameters.Where(a => a.Type == PlatformConsts.CustomEndpointConsts.ParameterTypes.Body)) { if (body.TryGetValue(item.Name, out var value)) @@ -196,6 +212,71 @@ public class CustomEndpointAppService : PlatformAppService }; } } + + /// + /// Body parameters of a POST/PUT call. Both form encoded and JSON payloads are + /// accepted: the visual designer's SqlDataSource sends the edited record as JSON, + /// while older callers keep posting forms. + /// + private async Task> ReadBodyValuesAsync() + { + var request = httpContextAccessor.HttpContext.Request; + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (request.HasFormContentType) + { + var form = await request.ReadFormAsync(); + foreach (var entry in form) + { + values[entry.Key] = entry.Value.ToString(); + } + + return values; + } + + request.EnableBuffering(); + using var reader = new StreamReader(request.Body, leaveOpen: true); + var raw = await reader.ReadToEndAsync(); + request.Body.Position = 0; + + if (raw.IsNullOrWhiteSpace()) + { + return values; + } + + try + { + using var json = JsonDocument.Parse(raw); + if (json.RootElement.ValueKind != JsonValueKind.Object) + { + return values; + } + + foreach (var property in json.RootElement.EnumerateObject()) + { + values[property.Name] = property.Value.ValueKind switch + { + JsonValueKind.Null or JsonValueKind.Undefined => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => property.Value.TryGetInt64(out var number) + ? number + : property.Value.GetDouble(), + JsonValueKind.String => property.Value.GetString(), + // Nested objects/arrays are passed through as raw JSON text. + _ => property.Value.GetRawText(), + }; + } + } + catch (JsonException ex) + { + // A malformed body is reported through the missing parameter checks, + // which produce a far clearer message than a parser error. + Logger.LogWarning(ex, "Custom Endpoint body could not be parsed as JSON."); + } + + return values; + } } //TODO: Custom Endpoint rol, permission seed diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index dc53d793..9d94269e 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -20754,6 +20754,12 @@ "en": "Default", "tr": "Varsayılan" }, + { + "resourceName": "Platform", + "key": "App.StaticLookup.Delete", + "en": "DELETE", + "tr": "DELETE" + }, { "resourceName": "Platform", "key": "App.StaticLookup.Desktop", @@ -20982,6 +20988,12 @@ "en": "Published", "tr": "Yayımlandı" }, + { + "resourceName": "Platform", + "key": "App.StaticLookup.Put", + "en": "PUT", + "tr": "PUT" + }, { "resourceName": "Platform", "key": "App.StaticLookup.Query", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs index a84d999e..b4a87ba4 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs @@ -6921,7 +6921,9 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency ValueExpr = "key", LookupQuery = JsonSerializer.Serialize(new LookupDataDto[] { new () { Key="GET", Name="App.StaticLookup.Get" }, - new () { Key="POSt", Name="App.StaticLookup.Post" }, + new () { Key="POST", Name="App.StaticLookup.Post" }, + new () { Key="PUT", Name="App.StaticLookup.Put" }, + new () { Key="DELETE", Name="App.StaticLookup.Delete" }, }), }), ValidationRuleJson = DefaultValidationRuleRequiredJson, diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json index b765ebb3..3d62bc45 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json @@ -2395,6 +2395,24 @@ "MultiTenancySide": 2, "MenuGroup": "Erp|Kurs" }, + { + "GroupName": "App.Saas", + "Name": "App.DeveloperKit.CustomEndpoints.Put", + "ParentName": "App.DeveloperKit.CustomEndpoints", + "DisplayName": "Put", + "IsEnabled": true, + "MultiTenancySide": 2, + "MenuGroup": "Erp|Kurs" + }, + { + "GroupName": "App.Saas", + "Name": "App.DeveloperKit.CustomEndpoints.Remove", + "ParentName": "App.DeveloperKit.CustomEndpoints", + "DisplayName": "Remove", + "IsEnabled": true, + "MultiTenancySide": 2, + "MenuGroup": "Erp|Kurs" + }, { "GroupName": "App.Saas", "Name": "App.DeveloperKit.CustomEndpoints.Update", diff --git a/api/src/Sozsoft.Platform.Domain.Shared/PlatformConsts.cs b/api/src/Sozsoft.Platform.Domain.Shared/PlatformConsts.cs index da5a6c9b..2628b3f2 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/PlatformConsts.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/PlatformConsts.cs @@ -477,8 +477,14 @@ public static class PlatformConsts public const string CustomEndpoints = Default + ".CustomEndpoints"; + // Coarse gate for calling a dynamic endpoint through the dispatcher. + // The per endpoint User/Role/Global rules are checked on top of this. + // Named Put/Remove because Update/Delete already gate editing the + // endpoint definitions from the ListForm screen. public const string Get = CustomEndpoints + ".Get"; public const string Post = CustomEndpoints + ".Post"; + public const string Put = CustomEndpoints + ".Put"; + public const string Remove = CustomEndpoints + ".Remove"; public const string CrudEndpoints = Default + ".CrudEndpoints"; diff --git a/ui/src/components/layouts/AuthLayout/Simple.tsx b/ui/src/components/layouts/AuthLayout/Simple.tsx index 8a5caf89..c36a9bb9 100644 --- a/ui/src/components/layouts/AuthLayout/Simple.tsx +++ b/ui/src/components/layouts/AuthLayout/Simple.tsx @@ -8,11 +8,9 @@ import { FaArrowLeft, FaCheck } from 'react-icons/fa' import { Avatar, Select } from '@/components/ui' import { useStoreActions, useStoreState } from '@/store' import appConfig from '@/proxy/configs/app.config' -import dayjs from 'dayjs' import { components } from 'react-select' import { ROUTES_ENUM } from '@/routes/route.constant' import { hasSubdomain } from '@/utils/subdomain' -import { dateLocales } from '@/constants/dateLocales.constant' import useDarkMode from '@/utils/hooks/useDarkmode' interface SimpleProps extends CommonProps { @@ -38,23 +36,10 @@ const Simple = ({ children, content, ...rest }: SimpleProps) => { ) }, [languageList]) + // The store is the single entry point: `useLocale` runs above every layout and + // owns the dayjs locale, the timezone and ``. const onLanguageSelect = (cultureName = appConfig.locale) => { - const dispatchLang = () => { - setLang(cultureName) - } - - if (dateLocales[cultureName]) { - dateLocales[cultureName]() - .then(() => { - dayjs.locale(cultureName) - dispatchLang() - }) - .catch(() => { - dispatchLang() - }) - } else { - dispatchLang() - } + setLang(cultureName) } const CustomSelectOption = ({ innerProps, data, isSelected }: any) => { @@ -133,7 +118,12 @@ const Simple = ({ children, content, ...rest }: SimpleProps) => {
- +
diff --git a/ui/src/components/template/LanguageSelector.tsx b/ui/src/components/template/LanguageSelector.tsx index ec464ecf..e2f7b0b0 100644 --- a/ui/src/components/template/LanguageSelector.tsx +++ b/ui/src/components/template/LanguageSelector.tsx @@ -5,11 +5,9 @@ import Spinner from '@/components/ui/Spinner' import classNames from 'classnames' import withHeaderItem from '@/utils/hoc/withHeaderItem' import { useStoreState, useStoreActions } from '@/store' -import dayjs from 'dayjs' import { FaCheck } from 'react-icons/fa' import type { CommonProps } from '@/proxy/common' import appConfig from '@/proxy/configs/app.config' -import { dateLocales } from '@/constants/dateLocales.constant' import { useLocalization } from '@/utils/hooks/useLocalization' const _LanguageSelector = ({ className }: CommonProps) => { @@ -38,26 +36,13 @@ const _LanguageSelector = ({ className }: CommonProps) => {
) + // Only the store is touched here: `useLocale` watches it and owns loading the + // dayjs locale, the timezone and `` for every layout. Duplicating + // that here is what let the two paths drift apart. const onLanguageSelect = (cultureName = appConfig.locale) => { setLoading(true) - - const dispatchLang = () => { - setLang(cultureName) - setLoading(false) - } - - if (dateLocales[cultureName]) { - dateLocales[cultureName]() - .then(() => { - dayjs.locale(cultureName) - dispatchLang() - }) - .catch(() => { - dispatchLang() - }) - } else { - dispatchLang() - } + setLang(cultureName) + setLoading(false) } return ( diff --git a/ui/src/components/ui/DatePicker/DatePicker.tsx b/ui/src/components/ui/DatePicker/DatePicker.tsx index 95a8e8c2..7358fd5f 100644 --- a/ui/src/components/ui/DatePicker/DatePicker.tsx +++ b/ui/src/components/ui/DatePicker/DatePicker.tsx @@ -6,318 +6,280 @@ import Calendar from './Calendar' import BasePicker from './BasePicker' import { useConfig } from '../ConfigProvider' import capitalize from '../utils/capitalize' +import { LOCALE_DATE_FORMAT, toTwoLetterCulture } from '@/utils/localeFormat' import type { CommonProps } from '../@types/common' import type { CalendarSharedProps } from './CalendarBase' import type { BasePickerSharedProps } from './BasePicker' import type { FocusEvent, KeyboardEvent, ChangeEvent } from 'react' -const DEFAULT_INPUT_FORMAT = 'YYYY-MM-DD' +// The native `type="date"` input only accepts an ISO value; every other case +// follows the language the user selected. +const NATIVE_INPUT_FORMAT = 'YYYY-MM-DD' export interface DatePickerProps - extends CommonProps, - Omit< - CalendarSharedProps, - | 'onMonthChange' - | 'onChange' - | 'isDateInRange' - | 'isDateFirstInRange' - | 'isDateLastInRange' - | 'month' - >, - BasePickerSharedProps { - closePickerOnChange?: boolean - defaultOpen?: boolean - defaultValue?: Date | null - value?: Date | null - inputFormat?: string - inputtableBlurClose?: boolean - openPickerOnClear?: boolean - onChange?: (value: Date | null) => void + extends + CommonProps, + Omit< + CalendarSharedProps, + | 'onMonthChange' + | 'onChange' + | 'isDateInRange' + | 'isDateFirstInRange' + | 'isDateLastInRange' + | 'month' + >, + BasePickerSharedProps { + closePickerOnChange?: boolean + defaultOpen?: boolean + defaultValue?: Date | null + value?: Date | null + inputFormat?: string + inputtableBlurClose?: boolean + openPickerOnClear?: boolean + onChange?: (value: Date | null) => void } -const DatePicker = forwardRef( - (props, ref) => { - const { - className, - clearable = true, - clearButton, - closePickerOnChange = true, - dateViewCount, - dayClassName, - dayStyle, - defaultMonth, - defaultOpen = false, - defaultValue, - defaultView, - disabled = false, - disableDate, - enableHeaderLabel, - disableOutOfMonth, - firstDayOfWeek = 'monday', - hideOutOfMonthDates, - hideWeekdays, - inputFormat, - inputPrefix, - inputSuffix, - inputtable, - labelFormat = { - month: 'MMM', - year: 'YYYY', - }, - locale, - maxDate, - minDate, - name = 'date', - onBlur, - onChange, - onFocus, - onDropdownClose, - onDropdownOpen, - openPickerOnClear = false, - renderDay, - size, - style, - type, - value, - weekendDays, - yearLabelFormat, - ...rest - } = props +const DatePicker = forwardRef((props, ref) => { + const { + className, + clearable = true, + clearButton, + closePickerOnChange = true, + dateViewCount, + dayClassName, + dayStyle, + defaultMonth, + defaultOpen = false, + defaultValue, + defaultView, + disabled = false, + disableDate, + enableHeaderLabel, + disableOutOfMonth, + firstDayOfWeek = 'monday', + hideOutOfMonthDates, + hideWeekdays, + inputFormat, + inputPrefix, + inputSuffix, + inputtable, + labelFormat = { + month: 'MMM', + year: 'YYYY', + }, + locale, + maxDate, + minDate, + name = 'date', + onBlur, + onChange, + onFocus, + onDropdownClose, + onDropdownOpen, + openPickerOnClear = false, + renderDay, + size, + style, + type, + value, + weekendDays, + yearLabelFormat, + ...rest + } = props - const { locale: themeLocale } = useConfig() + const { locale: themeLocale } = useConfig() - const finalLocale = locale || themeLocale + const finalLocale = toTwoLetterCulture(locale || themeLocale) - const dateFormat = - type === 'date' - ? DEFAULT_INPUT_FORMAT - : inputFormat || DEFAULT_INPUT_FORMAT + const dateFormat = type === 'date' ? NATIVE_INPUT_FORMAT : inputFormat || LOCALE_DATE_FORMAT - const [dropdownOpened, setDropdownOpened] = useState(defaultOpen) + const [dropdownOpened, setDropdownOpened] = useState(defaultOpen) - const inputRef = useRef(null) + const inputRef = useRef(null) - const [lastValidValue, setLastValidValue] = useState( - defaultValue ?? null - ) + const [lastValidValue, setLastValidValue] = useState(defaultValue ?? null) - const [_value, setValue] = useControllableState({ - prop: value, - defaultProp: defaultValue, - onChange, - }) + const [_value, setValue] = useControllableState({ + prop: value, + defaultProp: defaultValue, + onChange, + }) - const [calendarMonth, setCalendarMonth] = useState( - _value || defaultMonth || new Date() - ) + const [calendarMonth, setCalendarMonth] = useState(_value || defaultMonth || new Date()) - const [focused, setFocused] = useState(false) + const [focused, setFocused] = useState(false) - const [inputState, setInputState] = useState( - _value instanceof Date - ? capitalize( - dayjs(_value).locale(finalLocale).format(dateFormat) - ) - : '' - ) + const [inputState, setInputState] = useState( + _value instanceof Date ? capitalize(dayjs(_value).locale(finalLocale).format(dateFormat)) : '', + ) - const closeDropdown = () => { - setDropdownOpened(false) - onDropdownClose?.() - } + const closeDropdown = () => { + setDropdownOpened(false) + onDropdownClose?.() + } - const openDropdown = () => { - setDropdownOpened(true) - onDropdownOpen?.() - } + const openDropdown = () => { + setDropdownOpened(true) + onDropdownOpen?.() + } - useEffect(() => { - if (!_value) { - if (maxDate && dayjs(calendarMonth).isAfter(maxDate)) { - setCalendarMonth(maxDate) - } + useEffect(() => { + if (!_value) { + if (maxDate && dayjs(calendarMonth).isAfter(maxDate)) { + setCalendarMonth(maxDate) + } - if (minDate && dayjs(calendarMonth).isBefore(minDate)) { - - setCalendarMonth(minDate) - } - } - }, [minDate, maxDate]) - - useEffect(() => { - if (value === null && !focused) { - setInputState('') - } - - if (value instanceof Date && !focused) { - setInputState( - capitalize( - dayjs(value).locale(finalLocale).format(dateFormat) - ) - ) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, focused, themeLocale]) - - useEffect(() => { - if (defaultValue instanceof Date && inputState && !focused) { - setInputState( - capitalize( - dayjs(_value).locale(finalLocale).format(dateFormat) - ) - ) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [themeLocale]) - - const handleValueChange = (date: Date | null) => { - setValue(date) - setInputState( - capitalize(dayjs(date).locale(finalLocale).format(dateFormat)) - ) - closePickerOnChange && closeDropdown() - window.setTimeout(() => inputRef.current?.focus(), 0) - } - - const handleClear = () => { - setValue(null) - setLastValidValue(null) - setInputState('') - openPickerOnClear && openDropdown() - inputRef.current?.focus() - } - - const parseDate = (date: string) => - dayjs(date, dateFormat, finalLocale).toDate() - - const setDateFromInput = () => { - let date = typeof _value === 'string' ? parseDate(_value) : _value - - if (maxDate && dayjs(date).isAfter(maxDate)) { - date = maxDate - } - - if (minDate && dayjs(date).isBefore(minDate)) { - date = minDate - } - - if (dayjs(date).isValid()) { - setValue(date) - setLastValidValue(date as Date) - setInputState( - capitalize( - dayjs(date).locale(finalLocale).format(dateFormat) - ) - ) - setCalendarMonth(date as Date) - } else { - setValue(lastValidValue) - } - } - - const handleInputBlur = ( - event: FocusEvent - ) => { - typeof onBlur === 'function' && onBlur(event) - setFocused(false) - - if (inputtable) { - setDateFromInput() - } - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter' && inputtable) { - closeDropdown() - setDateFromInput() - } - } - - const handleInputFocus = ( - event: FocusEvent - ) => { - typeof onFocus === 'function' && onFocus(event) - setFocused(true) - } - - const handleChange = (event: ChangeEvent) => { - openDropdown() - - const date = parseDate(event.target.value) - if (dayjs(date).isValid()) { - setValue(date) - setLastValidValue(date) - setInputState(event.target.value) - setCalendarMonth(date) - } else { - setInputState(event.target.value) - } - } - - return ( - - - - ) + if (minDate && dayjs(calendarMonth).isBefore(minDate)) { + setCalendarMonth(minDate) + } } -) + }, [minDate, maxDate]) + + useEffect(() => { + if (value === null && !focused) { + setInputState('') + } + + if (value instanceof Date && !focused) { + setInputState(capitalize(dayjs(value).locale(finalLocale).format(dateFormat))) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, focused, themeLocale]) + + useEffect(() => { + if (defaultValue instanceof Date && inputState && !focused) { + setInputState(capitalize(dayjs(_value).locale(finalLocale).format(dateFormat))) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [themeLocale]) + + const handleValueChange = (date: Date | null) => { + setValue(date) + setInputState(capitalize(dayjs(date).locale(finalLocale).format(dateFormat))) + closePickerOnChange && closeDropdown() + window.setTimeout(() => inputRef.current?.focus(), 0) + } + + const handleClear = () => { + setValue(null) + setLastValidValue(null) + setInputState('') + openPickerOnClear && openDropdown() + inputRef.current?.focus() + } + + const parseDate = (date: string) => dayjs(date, dateFormat, finalLocale).toDate() + + const setDateFromInput = () => { + let date = typeof _value === 'string' ? parseDate(_value) : _value + + if (maxDate && dayjs(date).isAfter(maxDate)) { + date = maxDate + } + + if (minDate && dayjs(date).isBefore(minDate)) { + date = minDate + } + + if (dayjs(date).isValid()) { + setValue(date) + setLastValidValue(date as Date) + setInputState(capitalize(dayjs(date).locale(finalLocale).format(dateFormat))) + setCalendarMonth(date as Date) + } else { + setValue(lastValidValue) + } + } + + const handleInputBlur = (event: FocusEvent) => { + typeof onBlur === 'function' && onBlur(event) + setFocused(false) + + if (inputtable) { + setDateFromInput() + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && inputtable) { + closeDropdown() + setDateFromInput() + } + } + + const handleInputFocus = (event: FocusEvent) => { + typeof onFocus === 'function' && onFocus(event) + setFocused(true) + } + + const handleChange = (event: ChangeEvent) => { + openDropdown() + + const date = parseDate(event.target.value) + if (dayjs(date).isValid()) { + setValue(date) + setLastValidValue(date) + setInputState(event.target.value) + setCalendarMonth(date) + } else { + setInputState(event.target.value) + } + } + + return ( + + + + ) +}) DatePicker.displayName = 'DatePicker' diff --git a/ui/src/components/ui/DatePicker/DatePickerRange.tsx b/ui/src/components/ui/DatePicker/DatePickerRange.tsx index 505359d3..a0bb2d54 100644 --- a/ui/src/components/ui/DatePicker/DatePickerRange.tsx +++ b/ui/src/components/ui/DatePicker/DatePickerRange.tsx @@ -7,6 +7,7 @@ import capitalize from '../utils/capitalize' import RangeCalendar from './RangeCalendar' import BasePicker from './BasePicker' import { useConfig } from '../ConfigProvider' +import { LOCALE_DATE_FORMAT, toTwoLetterCulture } from '@/utils/localeFormat' import type { CommonProps } from '../@types/common' import type { CalendarSharedProps } from './CalendarBase' import type { BasePickerSharedProps } from './BasePicker' @@ -14,191 +15,175 @@ import type { BasePickerSharedProps } from './BasePicker' export type DatePickerRangeValue = [Date | null, Date | null] export interface DatePickerRangeProps - extends CommonProps, - Omit< - CalendarSharedProps, - | 'onMonthChange' - | 'onChange' - | 'isDateInRange' - | 'isDateFirstInRange' - | 'isDateLastInRange' - | 'month' - >, - BasePickerSharedProps { - closePickerOnChange?: boolean - defaultOpen?: boolean - defaultValue?: DatePickerRangeValue - inputFormat?: string - separator?: string - onChange?: (value: DatePickerRangeValue) => void - openPickerOnClear?: boolean - singleDate?: boolean - value?: DatePickerRangeValue + extends + CommonProps, + Omit< + CalendarSharedProps, + | 'onMonthChange' + | 'onChange' + | 'isDateInRange' + | 'isDateFirstInRange' + | 'isDateLastInRange' + | 'month' + >, + BasePickerSharedProps { + closePickerOnChange?: boolean + defaultOpen?: boolean + defaultValue?: DatePickerRangeValue + inputFormat?: string + separator?: string + onChange?: (value: DatePickerRangeValue) => void + openPickerOnClear?: boolean + singleDate?: boolean + value?: DatePickerRangeValue } const validationRule = (val: any) => - Array.isArray(val) && - val.length === 2 && - val.every((v) => v instanceof Date) + Array.isArray(val) && val.length === 2 && val.every((v) => v instanceof Date) const isFirstDateSet = (val: any) => - Array.isArray(val) && val.length === 2 && val[0] instanceof Date + Array.isArray(val) && val.length === 2 && val[0] instanceof Date -const DatePickerRange = forwardRef( - (props, ref) => { - const { - className, - clearable = true, - clearButton, - closePickerOnChange = true, - dateViewCount = 1, - dayClassName, - dayStyle, - defaultMonth, - defaultOpen = false, - defaultValue, - defaultView, - disabled, - disableDate, - enableHeaderLabel, - disableOutOfMonth, - firstDayOfWeek = 'monday', - hideOutOfMonthDates, - hideWeekdays, - inputFormat, - inputPrefix, - inputSuffix, - labelFormat = { - month: 'MMM', - year: 'YYYY', - }, - separator = '~', - locale, - maxDate, - minDate, - onChange, - onDropdownClose, - onDropdownOpen, - openPickerOnClear = false, - renderDay, - singleDate = false, - size, - style, - value, - weekendDays, - yearLabelFormat, - ...rest - } = props +const DatePickerRange = forwardRef((props, ref) => { + const { + className, + clearable = true, + clearButton, + closePickerOnChange = true, + dateViewCount = 1, + dayClassName, + dayStyle, + defaultMonth, + defaultOpen = false, + defaultValue, + defaultView, + disabled, + disableDate, + enableHeaderLabel, + disableOutOfMonth, + firstDayOfWeek = 'monday', + hideOutOfMonthDates, + hideWeekdays, + inputFormat, + inputPrefix, + inputSuffix, + labelFormat = { + month: 'MMM', + year: 'YYYY', + }, + separator = '~', + locale, + maxDate, + minDate, + onChange, + onDropdownClose, + onDropdownOpen, + openPickerOnClear = false, + renderDay, + singleDate = false, + size, + style, + value, + weekendDays, + yearLabelFormat, + ...rest + } = props - const { locale: themeLocale } = useConfig() + const { locale: themeLocale } = useConfig() - const finalLocale = locale || themeLocale + const finalLocale = toTwoLetterCulture(locale || themeLocale) - const dateFormat = inputFormat || 'YYYY-MM-DD' + const dateFormat = inputFormat || LOCALE_DATE_FORMAT - const [dropdownOpened, setDropdownOpened] = useState(defaultOpen) + const [dropdownOpened, setDropdownOpened] = useState(defaultOpen) - const inputRef = useRef(null) + const inputRef = useRef(null) - const [_value, setValue] = useControllableState< - [Date | null, Date | null] - >({ - prop: value, - defaultProp: - defaultValue !== undefined ? defaultValue : [null, null], - onChange, - }) + const [_value, setValue] = useControllableState<[Date | null, Date | null]>({ + prop: value, + defaultProp: defaultValue !== undefined ? defaultValue : [null, null], + onChange, + }) - const handleValueChange = (range: [Date, Date]) => { - setValue(range) - if (closePickerOnChange && validationRule(range)) { - setDropdownOpened(false) - onDropdownClose?.() - window.setTimeout(() => inputRef.current?.focus(), 0) - } - } - - const valueValid = validationRule(_value) - const firstValueValid = isFirstDateSet(_value) - - const firstDateLabel = _value?.[0] - ? capitalize( - dayjs(_value[0]).locale(finalLocale).format(dateFormat) - ) - : '' - - const secondDateLabel = _value?.[1] - ? capitalize( - dayjs(_value[1]).locale(finalLocale).format(dateFormat) - ) - : '' - - const handleClear = () => { - setValue([null, null]) - setDropdownOpened(true) - openPickerOnClear && onDropdownOpen?.() - inputRef.current?.focus() - } - - const handleDropdownToggle = (isOpened: boolean) => { - if (!isOpened && firstValueValid && _value?.[1] === null) { - handleClear() - } - setDropdownOpened(isOpened) - } - - return ( - - handleValueChange(date as [Date, Date])} - /> - - ) + const handleValueChange = (range: [Date, Date]) => { + setValue(range) + if (closePickerOnChange && validationRule(range)) { + setDropdownOpened(false) + onDropdownClose?.() + window.setTimeout(() => inputRef.current?.focus(), 0) } -) + } + + const valueValid = validationRule(_value) + const firstValueValid = isFirstDateSet(_value) + + const firstDateLabel = _value?.[0] + ? capitalize(dayjs(_value[0]).locale(finalLocale).format(dateFormat)) + : '' + + const secondDateLabel = _value?.[1] + ? capitalize(dayjs(_value[1]).locale(finalLocale).format(dateFormat)) + : '' + + const handleClear = () => { + setValue([null, null]) + setDropdownOpened(true) + openPickerOnClear && onDropdownOpen?.() + inputRef.current?.focus() + } + + const handleDropdownToggle = (isOpened: boolean) => { + if (!isOpened && firstValueValid && _value?.[1] === null) { + handleClear() + } + setDropdownOpened(isOpened) + } + + return ( + + handleValueChange(date as [Date, Date])} + /> + + ) +}) DatePickerRange.displayName = 'DatePickerRange' diff --git a/ui/src/components/ui/DatePicker/DateTimepicker.tsx b/ui/src/components/ui/DatePicker/DateTimepicker.tsx index 0fb296fa..3c9c9e33 100644 --- a/ui/src/components/ui/DatePicker/DateTimepicker.tsx +++ b/ui/src/components/ui/DatePicker/DateTimepicker.tsx @@ -8,317 +8,275 @@ import Calendar from './Calendar' import BasePicker from './BasePicker' import Button from '../Button/Button' import { useConfig } from '../ConfigProvider' +import { LOCALE_DATE_TIME_FORMAT, toTwoLetterCulture } from '@/utils/localeFormat' import type { CommonProps } from '../@types/common' import type { CalendarSharedProps } from './CalendarBase' import type { BasePickerSharedProps } from './BasePicker' import type { FocusEvent, ChangeEvent } from 'react' export interface DateTimepickerProps - extends CommonProps, - Omit< - CalendarSharedProps, - | 'onMonthChange' - | 'onChange' - | 'isDateInRange' - | 'isDateFirstInRange' - | 'isDateLastInRange' - | 'month' - >, - BasePickerSharedProps { - closePickerOnChange?: boolean - defaultOpen?: boolean - defaultValue?: Date | null - value?: Date | null - inputFormat?: string - openPickerOnClear?: boolean - onChange?: (value: Date | null) => void - amPm?: boolean - okButtonContent?: boolean + extends + CommonProps, + Omit< + CalendarSharedProps, + | 'onMonthChange' + | 'onChange' + | 'isDateInRange' + | 'isDateFirstInRange' + | 'isDateLastInRange' + | 'month' + >, + BasePickerSharedProps { + closePickerOnChange?: boolean + defaultOpen?: boolean + defaultValue?: Date | null + value?: Date | null + inputFormat?: string + openPickerOnClear?: boolean + onChange?: (value: Date | null) => void + amPm?: boolean + okButtonContent?: boolean } -const DEFAULT_INPUT_FORMAT = 'DD-MMM-YYYY hh:mm a' +const DateTimepicker = forwardRef((props, ref) => { + const { + amPm = true, + className, + clearable = true, + closePickerOnChange = false, + dateViewCount, + dayClassName, + dayStyle, + defaultMonth, + defaultOpen = false, + defaultValue, + defaultView, + disabled = false, + disableDate, + enableHeaderLabel, + disableOutOfMonth, + firstDayOfWeek = 'monday', + hideOutOfMonthDates, + hideWeekdays, + inputFormat, + inputPrefix, + inputSuffix, + inputtable, + labelFormat = { + month: 'MMM', + year: 'YYYY', + }, + locale, + maxDate, + minDate, + name = 'dateTime', + okButtonContent = 'OK', + onBlur, + onChange, + onFocus, + onDropdownClose, + onDropdownOpen, + openPickerOnClear, + renderDay, + size, + value, + weekendDays, + yearLabelFormat, + ...rest + } = props -const DateTimepicker = forwardRef( - (props, ref) => { - const { - amPm = true, - className, - clearable = true, - closePickerOnChange = false, - dateViewCount, - dayClassName, - dayStyle, - defaultMonth, - defaultOpen = false, - defaultValue, - defaultView, - disabled = false, - disableDate, - enableHeaderLabel, - disableOutOfMonth, - firstDayOfWeek = 'monday', - hideOutOfMonthDates, - hideWeekdays, - inputFormat, - inputPrefix, - inputSuffix, - inputtable, - labelFormat = { - month: 'MMM', - year: 'YYYY', - }, - locale, - maxDate, - minDate, - name = 'dateTime', - okButtonContent = 'OK', - onBlur, - onChange, - onFocus, - onDropdownClose, - onDropdownOpen, - openPickerOnClear, - renderDay, - size, - value, - weekendDays, - yearLabelFormat, - ...rest - } = props + const { locale: themeLocale } = useConfig() - const { locale: themeLocale } = useConfig() + const finalLocale = toTwoLetterCulture(locale || themeLocale) - const finalLocale = locale || themeLocale + const dateFormat = inputFormat || LOCALE_DATE_TIME_FORMAT - const dateFormat = inputFormat || DEFAULT_INPUT_FORMAT + const [dropdownOpened, setDropdownOpened] = useState(defaultOpen) - const [dropdownOpened, setDropdownOpened] = useState(defaultOpen) + const inputRef = useRef(null) - const inputRef = useRef(null) + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + const [_, setLastValidValue] = useState(defaultValue ?? null) + const [_value, setValue] = useControllableState({ + prop: value, + defaultProp: defaultValue, + onChange, + }) - // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - const [_, setLastValidValue] = useState(defaultValue ?? null) - const [_value, setValue] = useControllableState({ - prop: value, - defaultProp: defaultValue, - onChange, - }) + const [calendarMonth, setCalendarMonth] = useState(_value || defaultMonth || new Date()) - const [calendarMonth, setCalendarMonth] = useState( - _value || defaultMonth || new Date() - ) + const [focused, setFocused] = useState(false) + const [inputState, setInputState] = useState( + _value instanceof Date ? capitalize(dayjs(_value).locale(finalLocale).format(dateFormat)) : '', + ) - const [focused, setFocused] = useState(false) - const [inputState, setInputState] = useState( - _value instanceof Date - ? capitalize( - dayjs(_value).locale(finalLocale).format(dateFormat) - ) - : '' - ) + const closeDropdown = () => { + setDropdownOpened(false) + onDropdownClose?.() + } - const closeDropdown = () => { - setDropdownOpened(false) - onDropdownClose?.() - } + const openDropdown = () => { + setDropdownOpened(true) + onDropdownOpen?.() + } - const openDropdown = () => { - setDropdownOpened(true) - onDropdownOpen?.() - } - - useEffect(() => { - if (value === null && !focused) { - setInputState('') - } - - if (value instanceof Date && !focused) { - setInputState( - dayjs(value).locale(finalLocale).format(dateFormat) - ) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, focused]) - - const handleValueChange = (date: Date) => { - if (_value) { - date.setHours(_value.getHours()) - date.setMinutes(_value.getMinutes()) - } else { - const now = new Date(Date.now()) - date.setHours(now.getHours()) - date.setMinutes(now.getMinutes()) - } - setValue(date) - if (!value && !closePickerOnChange) { - setInputState( - dayjs(date).locale(finalLocale).format(dateFormat) - ) - } - closePickerOnChange && - setInputState( - capitalize( - dayjs(date).locale(finalLocale).format(dateFormat) - ) - ) - closePickerOnChange && closeDropdown() - window.setTimeout(() => inputRef.current?.focus(), 0) - } - - const handleClear = () => { - setValue(null) - setLastValidValue(null) - setInputState('') - openPickerOnClear && openDropdown() - inputRef.current?.focus() - onChange?.(null) - } - - const parseDate = (date: string) => - dayjs(date, dateFormat, finalLocale).toDate() - - const handleInputBlur = ( - event: FocusEvent - ) => { - typeof onBlur === 'function' && onBlur(event) - setFocused(false) - } - - const handleInputFocus = ( - event: FocusEvent - ) => { - typeof onFocus === 'function' && onFocus(event) - setFocused(true) - } - - const handleChange = (event: ChangeEvent) => { - openDropdown() - - const date = parseDate(event.target.value) - if (dayjs(date).isValid()) { - setValue(date) - setLastValidValue(date) - closePickerOnChange && setInputState(event.target.value) - setCalendarMonth(date) - } else { - closePickerOnChange && setInputState(event.target.value) - } - } - - const handleTimeChange = (time: Date | null) => { - if (_value instanceof Date && time instanceof Date) { - const newDateTime = new Date( - _value.getFullYear(), - _value.getMonth(), - _value.getDate(), - time.getHours(), - time.getMinutes(), - time.getSeconds(), - time.getMilliseconds() - ) - setValue(newDateTime) - - if (!value && !closePickerOnChange) { - setInputState( - capitalize( - dayjs(newDateTime) - .locale(finalLocale) - .format(dateFormat) - ) - ) - } - - closePickerOnChange && - setInputState( - capitalize( - dayjs(newDateTime) - .locale(finalLocale) - .format(dateFormat) - ) - ) - } - closePickerOnChange && closeDropdown() - } - - const handleOk = () => { - setInputState( - capitalize(dayjs(_value).locale(finalLocale).format(dateFormat)) - ) - closeDropdown() - window.setTimeout(() => inputRef.current?.focus(), 0) - onChange?.(_value as Date | null) - } - - return ( - - -
- - -
-
- ) + useEffect(() => { + if (value === null && !focused) { + setInputState('') } -) + + if (value instanceof Date && !focused) { + setInputState(dayjs(value).locale(finalLocale).format(dateFormat)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, focused]) + + const handleValueChange = (date: Date) => { + if (_value) { + date.setHours(_value.getHours()) + date.setMinutes(_value.getMinutes()) + } else { + const now = new Date(Date.now()) + date.setHours(now.getHours()) + date.setMinutes(now.getMinutes()) + } + setValue(date) + if (!value && !closePickerOnChange) { + setInputState(dayjs(date).locale(finalLocale).format(dateFormat)) + } + closePickerOnChange && + setInputState(capitalize(dayjs(date).locale(finalLocale).format(dateFormat))) + closePickerOnChange && closeDropdown() + window.setTimeout(() => inputRef.current?.focus(), 0) + } + + const handleClear = () => { + setValue(null) + setLastValidValue(null) + setInputState('') + openPickerOnClear && openDropdown() + inputRef.current?.focus() + onChange?.(null) + } + + const parseDate = (date: string) => dayjs(date, dateFormat, finalLocale).toDate() + + const handleInputBlur = (event: FocusEvent) => { + typeof onBlur === 'function' && onBlur(event) + setFocused(false) + } + + const handleInputFocus = (event: FocusEvent) => { + typeof onFocus === 'function' && onFocus(event) + setFocused(true) + } + + const handleChange = (event: ChangeEvent) => { + openDropdown() + + const date = parseDate(event.target.value) + if (dayjs(date).isValid()) { + setValue(date) + setLastValidValue(date) + closePickerOnChange && setInputState(event.target.value) + setCalendarMonth(date) + } else { + closePickerOnChange && setInputState(event.target.value) + } + } + + const handleTimeChange = (time: Date | null) => { + if (_value instanceof Date && time instanceof Date) { + const newDateTime = new Date( + _value.getFullYear(), + _value.getMonth(), + _value.getDate(), + time.getHours(), + time.getMinutes(), + time.getSeconds(), + time.getMilliseconds(), + ) + setValue(newDateTime) + + if (!value && !closePickerOnChange) { + setInputState(capitalize(dayjs(newDateTime).locale(finalLocale).format(dateFormat))) + } + + closePickerOnChange && + setInputState(capitalize(dayjs(newDateTime).locale(finalLocale).format(dateFormat))) + } + closePickerOnChange && closeDropdown() + } + + const handleOk = () => { + setInputState(capitalize(dayjs(_value).locale(finalLocale).format(dateFormat))) + closeDropdown() + window.setTimeout(() => inputRef.current?.focus(), 0) + onChange?.(_value as Date | null) + } + + return ( + + +
+ + +
+
+ ) +}) DateTimepicker.displayName = 'DateTimepicker' diff --git a/ui/src/components/visualDesigner/VisualCanvas.tsx b/ui/src/components/visualDesigner/VisualCanvas.tsx index 2fdd844d..42be7055 100644 --- a/ui/src/components/visualDesigner/VisualCanvas.tsx +++ b/ui/src/components/visualDesigner/VisualCanvas.tsx @@ -4,24 +4,69 @@ import PlatformViewHost, { type PlatformViewName, } from '@/components/componentEditor/PlatformViewHost' import { useLocalization } from '@/utils/hooks/useLocalization' +import { formatLocaleValue } from '@/utils/localeFormat' import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa' import { fromDesignerDate, getDesignerCollectionProperty, getDesignerTabSlot, getDesignerValueByPath, + getSqlDataSourceEndpointId, + getSqlDataSourceKeyField, isDesignerDateComponent, isDesignerDateProperty, isDesignerOptionComponent, + isSqlDataSourceNode, normalizeDesignerKeyList, + readSqlDataSourceField, resolveDesignerDropdownTitle, resolveDesignerTabValue, + resolveSqlDataSourceRows, toDesignerDate, type DesignerBinding, type DesignerNode, + type SqlDataSourceMode, } from './types' +/** + * Editing scope opened by a SqlDataSource: a descendant whose `value`/`checked` + * is bound to `sourceId` writes back into the record instead of into its own + * static prop, which is what makes the container behave like an ASP.NET FormView. + */ +export interface DesignerFormScope { + sourceId: string + onFieldChange: (path: string, value: unknown) => void +} + export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer' + +interface DesignerDragPayload { + source?: 'library' | 'canvas' + name?: string + nodeId?: string +} + +/** Drag payload of the designer; anything else dropped on the canvas is ignored. */ +const readDesignerDragPayload = ( + event: React.DragEvent, +): DesignerDragPayload | null => { + const raw = + event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain') + if (!raw) return null + try { + const payload = JSON.parse(raw) as DesignerDragPayload + return payload?.source === 'library' || payload?.source === 'canvas' ? payload : null + } catch { + return null + } +} + +/** `dropEffect` must match the source's `effectAllowed` or the drop never fires. */ +const acceptDesignerDrag = (event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move' +} const getTableDimension = (value: unknown, fallback: number) => Math.min(20, Math.max(1, Math.floor(Number(value) || fallback))) const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => { @@ -43,6 +88,12 @@ interface VisualCanvasProps { interactive?: boolean onSelect?: (id: string) => void onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void + /** Adds a toolbox component next to an existing node instead of inside it. */ + onDropComponentBeside?: ( + definitionName: string, + targetId: string, + placement: 'before' | 'after', + ) => void /** Moves an existing node into a container/table cell, or to the root. */ onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void onMove?: (id: string, direction: -1 | 1) => void @@ -180,12 +231,21 @@ const getSelectMenuProps = (props: Record) => { const getTabOptions = (props: Record) => Array.isArray(props.items) ? (props.items as Array>) : [] +/** Prop of a node that a SqlDataSource scope is allowed to write back to. */ +const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) => + formScope + ? (['value', 'checked'] as const).find( + (propertyName) => node.bindings?.[propertyName]?.sourceId === formScope.sourceId, + ) + : undefined + const getPreviewProps = ( node: DesignerNode, dataValues: Record, currentItem?: unknown, onNodePropChange?: (id: string, propertyName: string, value: unknown) => void, translate: (key: string) => string = (key) => key, + formScope?: DesignerFormScope, ) => { const props: Record = {} Object.entries(node.props).forEach(([key, value]) => { @@ -219,7 +279,7 @@ const getPreviewProps = ( if (isDesignerDateComponent(node.type)) { Object.keys(props).forEach((propertyName) => { if (isDesignerDateProperty(node.type, propertyName)) { - props[propertyName] = toDesignerDate(props[propertyName]) + props[propertyName] = toDesignerDate(props[propertyName], node.type) } }) } @@ -279,21 +339,50 @@ const getPreviewProps = ( ? (value as { target?: { value?: unknown } }).target?.value : value - if (node.type === 'Select') { - chainHandler('onChange', (selected) => - updateProp( - 'value', - Array.isArray(selected) - ? selected.map((option) => - option && typeof option === 'object' && 'value' in option - ? (option as { value: unknown }).value - : option, - ) - : selected && typeof selected === 'object' && 'value' in selected - ? (selected as { value: unknown }).value - : null, - ), - ) + const selectedOptionValue = (selected: unknown) => + Array.isArray(selected) + ? selected.map((option) => + option && typeof option === 'object' && 'value' in option + ? (option as { value: unknown }).value + : option, + ) + : selected && typeof selected === 'object' && 'value' in selected + ? (selected as { value: unknown }).value + : null + const checkedValue = (value: unknown, originalEvent: unknown) => + originalEvent && typeof originalEvent === 'object' && 'target' in originalEvent + ? Boolean((originalEvent as { target?: { checked?: unknown } }).target?.checked) + : value && typeof value === 'object' && 'target' in value + ? Boolean((value as { target?: { checked?: unknown } }).target?.checked) + : Boolean(value) + // Inside a SqlDataSource the edited value belongs to the record, not to the + // node's static prop — otherwise typing into a bound Input would be discarded + // on the next render because the binding always wins. + const formScopeProperty = getFormScopeProperty(node, formScope) + + if (formScope && formScopeProperty) { + const bindingPath = node.bindings[formScopeProperty].path + // An empty record (New mode) yields `undefined`, which React reads as + // "uncontrolled" and leaves the field stuck on its previous DOM value. + if (props[formScopeProperty] === undefined && !isDesignerDateComponent(node.type)) { + props[formScopeProperty] = formScopeProperty === 'checked' ? false : '' + } + const writeField = (value: unknown) => formScope.onFieldChange(bindingPath, value) + if (node.type === 'Select') { + chainHandler('onChange', (selected) => writeField(selectedOptionValue(selected))) + } else if (formScopeProperty === 'checked') { + chainHandler('onChange', (value, originalEvent) => + writeField(checkedValue(value, originalEvent)), + ) + } else if (isDesignerDateComponent(node.type)) { + chainHandler('onChange', (value) => writeField(fromDesignerDate(value, node.type))) + } else if (node.type === 'AutoComplete') { + chainHandler('onInputChange', (value) => writeField(value ?? '')) + } else { + chainHandler('onChange', (value) => writeField(eventValue(value))) + } + } else if (node.type === 'Select') { + chainHandler('onChange', (selected) => updateProp('value', selectedOptionValue(selected))) } else if (node.type === 'AutoComplete') { chainHandler('onInputChange', (value) => updateProp('value', value ?? '')) } else if (node.type === 'Menu') { @@ -319,17 +408,11 @@ const getPreviewProps = ( } else if (isDesignerDateComponent(node.type)) { // A Date is not JSON serialisable, so the picked value is kept as an ISO // string — otherwise it is lost on the next render/save. - chainHandler('onChange', (value) => updateProp('value', fromDesignerDate(value))) + chainHandler('onChange', (value) => updateProp('value', fromDesignerDate(value, node.type))) } else if ('checked' in props) { - chainHandler('onChange', (value, originalEvent) => { - const checked = - originalEvent && typeof originalEvent === 'object' && 'target' in originalEvent - ? Boolean((originalEvent as { target?: { checked?: unknown } }).target?.checked) - : value && typeof value === 'object' && 'target' in value - ? Boolean((value as { target?: { checked?: unknown } }).target?.checked) - : Boolean(value) - updateProp('checked', checked) - }) + chainHandler('onChange', (value, originalEvent) => + updateProp('checked', checkedValue(value, originalEvent)), + ) } else if ('value' in props) { chainHandler('onChange', (value) => updateProp('value', eventValue(value))) } @@ -392,8 +475,11 @@ const GridColumnHeaders = ({ columns }: { columns: string[] }) => ( ) +// Dates and decimals follow the selected language, like the DevExtreme grids do. const getGridCellText = (value: unknown) => - typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? '') || '—' + typeof value === 'object' && value !== null && !(value instanceof Date) + ? JSON.stringify(value) + : formatLocaleValue(value) || '—' const GridDataTablePreview = ({ borderlessRow = false, @@ -468,6 +554,212 @@ const GridDataTablePreview = ({ ) } +/** Deep set on a dot path, used by both the canvas and the generated runtime. */ +const setRecordField = ( + record: Record, + path: string, + value: unknown, +): Record => { + const keys = path.split('.').filter(Boolean) + if (!keys.length) return record + const next = { ...record } + let target = next + for (const key of keys.slice(0, -1)) { + const child = target[key] + const branch = child && typeof child === 'object' && !Array.isArray(child) ? { ...child } : {} + target[key] = branch + target = branch as Record + } + target[keys[keys.length - 1]] = value + return next +} + +const SqlDataSourceView = ({ + node, + dataValues, + interactive, + renderChildren, +}: { + node: DesignerNode + dataValues: Record + interactive: boolean + renderChildren: ( + childDataValues: Record, + formScope: DesignerFormScope, + ) => React.ReactNode +}) => { + const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint') + const keyField = getSqlDataSourceKeyField(node) + const collectionPath = String(node.props.collectionPath ?? '') + const rows = React.useMemo( + () => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []), + [collectionPath, dataValues, selectId], + ) + const [rowIndex, setRowIndex] = React.useState(0) + const [mode, setMode] = React.useState('edit') + // Holds the whole record while editing, so New mode can show an empty form + // instead of falling back to the loaded row. + const [draft, setDraft] = React.useState | null>(null) + const activeRow = React.useMemo(() => rows[rowIndex] ?? {}, [rowIndex, rows]) + // A fresh Select result invalidates the local edits, otherwise the canvas would + // keep showing values that no longer exist in the response. + const rowsFingerprint = React.useMemo(() => JSON.stringify(rows), [rows]) + React.useEffect(() => { + setRowIndex(0) + setDraft(null) + setMode('edit') + }, [rowsFingerprint]) + + const record = draft ?? activeRow + const formScope = React.useMemo( + () => ({ + sourceId: node.id, + onFieldChange: (path, value) => + setDraft((current) => setRecordField(current ?? activeRow, path, value)), + }), + [activeRow, node.id], + ) + const childDataValues = React.useMemo( + () => ({ ...dataValues, [node.id]: record }), + [dataValues, node.id, record], + ) + + const goToRow = (index: number) => { + setRowIndex(index) + setDraft(null) + setMode('edit') + } + const keyValue = readSqlDataSourceField(record, keyField) + const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== '' + const canInsert = Boolean(getSqlDataSourceEndpointId(node, 'insertEndpoint')) + const canUpdate = Boolean(getSqlDataSourceEndpointId(node, 'updateEndpoint')) + const canDelete = Boolean(getSqlDataSourceEndpointId(node, 'deleteEndpoint')) + // Save follows the explicit mode, exactly like the generated runtime does. + const canSave = mode === 'new' ? canInsert : canUpdate + const designTimeTitle = 'Tasarım modunda endpoint çağrısı yapılmaz.' + + const toolbarButton = ( + label: string, + enabled: boolean, + tone: 'primary' | 'danger' | 'plain', + disabledTitle: string, + onClick?: () => void, + ) => ( + + ) + + return ( +
+ {interactive && ( +
+ SqlDataSource + + key: {keyField} + + {selectId ? `${rows.length} kayıt` : 'Select endpointi seçilmedi'} + + {mode === 'new' ? 'Yeni kayıt' : 'Düzenleme'} + + {rows.length > 1 && ( + + )} +
+ )} + {renderChildren(childDataValues, formScope)} + {/* The drop zone belongs with the content, above the command toolbar. */} + {interactive && !node.children.length && ( +
+ Bileşeni buraya bırakın; Data sekmesinden sütununa bağlayın. +
+ )} + {node.props.showToolbar !== false && ( +
+ {/* Navigation appears on its own once there is more than one record. */} + {rows.length > 1 && ( + <> + {toolbarButton('Önceki', rowIndex > 0, 'plain', 'İlk kayıttasınız.', () => + goToRow(Math.max(0, rowIndex - 1)), + )} + + {rows.length ? `${rowIndex + 1} / ${rows.length}` : '0 / 0'} + + {toolbarButton( + 'Sonraki', + rowIndex < rows.length - 1, + 'plain', + 'Son kayıttasınız.', + () => goToRow(Math.min(rows.length - 1, rowIndex + 1)), + )} + + + )} + {/* New and Reload only touch local state, so they work at design time. */} + {toolbarButton('Yeni', canInsert, 'plain', 'Insert için POST endpointi seçin.', () => { + setDraft({}) + setMode('new') + })} + {toolbarButton( + 'Kaydet', + canSave, + 'primary', + mode === 'new' + ? 'Insert için POST endpointi seçin.' + : 'Update için PUT endpointi seçin.', + )} + {toolbarButton( + 'Sil', + canDelete && hasKey && mode === 'edit', + 'danger', + canDelete ? `Silmek için ${keyField} alanı dolu olmalıdır.` : 'DELETE endpointi seçin.', + )} + {toolbarButton( + 'Yenile', + Boolean(selectId), + 'plain', + 'Select için GET endpointi seçin.', + () => goToRow(rowIndex), + )} +
+ )} +
+ ) +} + const renderElement = ( node: DesignerNode, children: React.ReactNode, @@ -477,7 +769,11 @@ const renderElement = ( translate: (key: string) => string, onNodePropChange?: (id: string, propertyName: string, value: unknown) => void, renderCustomComponent?: (name: string, props?: Record) => React.ReactNode, + formScope?: DesignerFormScope, ) => { + // Built in NodeView so the container can own the record state and expose it to + // its children through an augmented `dataValues` map. + if (isSqlDataSourceNode(node.type)) return <>{children} if (node.type === 'Spacer') { return (
void onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void + onDropComponentBeside?: ( + definitionName: string, + targetId: string, + placement: 'before' | 'after', + ) => void onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void onMove?: (id: string, direction: -1 | 1) => void onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void @@ -720,6 +1030,7 @@ const NodeView = ({ renderCustomComponent?: (name: string, props?: Record) => React.ReactNode dataValues: Record currentItem?: unknown + formScope?: DesignerFormScope }) => { const { translate } = useLocalization() const selected = interactive && selectedId === node.id @@ -736,8 +1047,10 @@ const NodeView = ({ 'div', 'Card', 'FormContainer', + 'Timeline', // Dropped components land in whichever tab is open. 'Tabs', + 'SqlDataSource', ].includes(node.type) const staticChildren = node.props.children const hasStaticChildren = @@ -763,32 +1076,54 @@ const NodeView = ({ : effectiveBoundItems : [] const childContexts = repeatedItems.length ? repeatedItems : [currentItem] - const children = childContexts.flatMap((childItem, itemIndex) => - node.children.map((child, childIndex) => ( - - )), - ) + const renderChildNodes = ( + childDataValues: Record, + childFormScope?: DesignerFormScope, + ) => + childContexts.flatMap((childItem, itemIndex) => + node.children.map((child, childIndex) => ( + + )), + ) + const children = renderChildNodes(dataValues, formScope) + const sqlDataSourceContent = isSqlDataSourceNode(node.type) ? ( + + ) : null const tabsContent = (() => { if (node.type !== 'Tabs') return null - const tabsProps = getPreviewProps(node, dataValues, currentItem, onNodePropChange, translate) + const tabsProps = getPreviewProps( + node, + dataValues, + currentItem, + onNodePropChange, + translate, + formScope, + ) const options = getTabOptions(tabsProps) delete tabsProps.items const activeValue = resolveDesignerTabValue(options, tabsProps.value) @@ -856,8 +1191,10 @@ const NodeView = ({ renderCustomComponent={renderCustomComponent} dataValues={dataValues} currentItem={currentItem} + formScope={formScope} onSelect={onSelect} onDropComponent={onDropComponent} + onDropComponentBeside={onDropComponentBeside} onMoveIntoContainer={onMoveIntoContainer} onMove={onMove} onReorder={onReorder} @@ -958,8 +1295,10 @@ const NodeView = ({ renderCustomComponent={renderCustomComponent} dataValues={dataValues} currentItem={currentItem} + formScope={formScope} onSelect={onSelect} onDropComponent={onDropComponent} + onDropComponentBeside={onDropComponentBeside} onMoveIntoContainer={onMoveIntoContainer} onMove={onMove} onReorder={onReorder} @@ -983,8 +1322,9 @@ const NodeView = ({ ) : null - const renderedChildren = - node.type === 'Tabs' + const renderedChildren = sqlDataSourceContent + ? sqlDataSourceContent + : node.type === 'Tabs' ? tabsContent : node.type === 'Table' ? tableContent @@ -1063,12 +1403,31 @@ const NodeView = ({ />, ] : children + // An empty container's placeholder is an explicit "inside" target, so it never + // depends on where the pointer happens to sit within the node. + const containerDropZoneProps = { + onDragOver: (event: React.DragEvent) => { + if (interactive) acceptDesignerDrag(event) + }, + onDrop: (event: React.DragEvent) => { + if (!interactive) return + event.preventDefault() + event.stopPropagation() + const payload = readDesignerDragPayload(event) + if (!payload) return + if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id) + else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) { + onMoveIntoContainer?.(payload.nodeId, node.id) + } + }, + } const contentChildren = interactive && node.type === 'Card' && node.children.length === 0 ? [
Bileşeni buraya bırakın
, @@ -1115,16 +1474,24 @@ const NodeView = ({ if (!interactive) return event.preventDefault() event.stopPropagation() - const raw = - event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain') - if (!raw) return - const payload = JSON.parse(raw) - if (payload.source === 'canvas' && payload.nodeId !== node.id) { - const bounds = event.currentTarget.getBoundingClientRect() - const placement = event.clientY < bounds.top + bounds.height / 2 ? 'before' : 'after' - onReorder?.(payload.nodeId, node.id, placement) - } else if (payload.source === 'library' && acceptsDroppedChildren) { - onDropComponent?.(payload.name, node.id) + const payload = readDesignerDragPayload(event) + if (!payload) return + + const bounds = event.currentTarget.getBoundingClientRect() + const offset = event.clientY - bounds.top + // A container claims its middle band as "drop inside"; the outer quarters + // stay reserved for placing the node next to it, which is the only + // meaningful option on a component that cannot host children. + const inside = + acceptsDroppedChildren && offset > bounds.height * 0.25 && offset < bounds.height * 0.75 + const placement = offset < bounds.height / 2 ? 'before' : 'after' + + if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) { + if (inside) onMoveIntoContainer?.(payload.nodeId, node.id) + else onReorder?.(payload.nodeId, node.id, placement) + } else if (payload.source === 'library' && payload.name) { + if (inside) onDropComponent?.(payload.name, node.id) + else onDropComponentBeside?.(payload.name, node.id, placement) } }} > @@ -1196,6 +1563,7 @@ const NodeView = ({ translate, onNodePropChange, renderCustomComponent, + formScope, )} {/* Tabs has a drop zone inside every tab, so it needs no outer placeholder. */} @@ -1203,8 +1571,13 @@ const NodeView = ({ !hasVisibleChildren && acceptsDroppedChildren && node.type !== 'Card' && - node.type !== 'Tabs' && ( -
+ node.type !== 'Tabs' && + // SqlDataSource renders its own placeholder below the toolbar. + !isSqlDataSourceNode(node.type) && ( +
Bileşeni buraya bırakın
)} @@ -1219,6 +1592,7 @@ const VisualCanvas = ({ interactive = true, onSelect, onDropComponent, + onDropComponentBeside, onMoveIntoContainer, onMove, onReorder, @@ -1265,6 +1639,7 @@ const VisualCanvas = ({ dataValues={previewDataValues} onSelect={onSelect} onDropComponent={onDropComponent} + onDropComponentBeside={onDropComponentBeside} onMoveIntoContainer={onMoveIntoContainer} onMove={onMove} onReorder={onReorder} diff --git a/ui/src/components/visualDesigner/catalog.ts b/ui/src/components/visualDesigner/catalog.ts index f6e70907..29bc65f4 100644 --- a/ui/src/components/visualDesigner/catalog.ts +++ b/ui/src/components/visualDesigner/catalog.ts @@ -2,6 +2,7 @@ import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/c import generatedComponentProps from './generated/componentProps.json' import { DESIGNER_DATA_COMPONENT_NAMES, + SQL_DATA_SOURCE_TYPE, getDesignerCollectionProperty, isDesignerOptionComponent, isDesignerTabularComponent, @@ -471,6 +472,112 @@ export const PLATFORM_COMPONENTS: DesignerComponentDefinition[] = [ platformDefinition('ChartView', 'Chart', 'Sozsoft dinamik grafik görünümü', '420px'), ] +/** + * ASP.NET's SqlDataSource + FormView in one component: it owns the four CRUD + * endpoints and acts as a container, so every component dropped inside it can + * bind to a column of the Select result and write back through Save/Delete. + * The endpoints are configured from the inspector's Data tab, not here. + */ +export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = { + name: SQL_DATA_SOURCE_TYPE, + icon: 'Database', + category: 'data', + kind: 'layout', + toolboxGroup: 'data', + description: + 'GET/POST/PUT/DELETE endpointlerini tek kayıt üzerinde yöneten veri kabı; içine bırakılan komponentler sütunlara bağlanır', + acceptsChildren: true, + properties: [ + { + name: 'selectEndpoint', + type: 'string', + value: '', + category: 'properties', + description: 'Kaydı okuyan GET endpointinin data source id’si (Data sekmesinden seçilir)', + }, + { + name: 'insertEndpoint', + type: 'string', + value: '', + category: 'properties', + description: 'Yeni kayıt için POST endpointi', + }, + { + name: 'updateEndpoint', + type: 'string', + value: '', + category: 'properties', + description: 'Mevcut kaydı güncelleyen PUT endpointi', + }, + { + name: 'deleteEndpoint', + type: 'string', + value: '', + category: 'properties', + description: 'Kaydı silen DELETE endpointi', + }, + { + name: 'keyFieldName', + type: 'string', + value: 'id', + category: 'properties', + description: 'Insert/Update ayrımı ve endpoint parametreleri bu sütundan doldurulur', + }, + { + name: 'collectionPath', + type: 'string', + value: '', + category: 'properties', + description: 'GET cevabı içinde satırların bulunduğu path (boşsa cevabın kendisi kullanılır)', + }, + { + name: 'keySource', + type: 'select', + value: 'query', + options: ['query', 'route'], + category: 'properties', + description: 'Key değerinin sayfa URL’sinden okunma şekli', + }, + { + name: 'keyParamName', + type: 'string', + value: '', + category: 'properties', + description: 'URL’den okunacak parametre adı (boşsa key field kullanılır)', + }, + { + name: 'previewKeyValue', + type: 'string', + value: '', + category: 'properties', + description: + 'Sadece tasarım ekranı: GetById endpointinin sütunlarını okuyabilmek için örnek key değeri', + }, + { + name: 'autoLoad', + type: 'boolean', + value: true, + category: 'properties', + description: 'Sayfa açıldığında Select endpointini otomatik çağırır', + }, + { + name: 'showToolbar', + type: 'boolean', + value: true, + category: 'properties', + description: 'New / Save / Delete / Reload butonlarını gösterir', + }, + { + name: 'gap', + type: 'number', + value: 16, + category: 'styling', + }, + { name: 'className', type: 'string', value: '', category: 'styling' }, + ], + hooks: [], +} + export const DESIGNER_EXTRAS: DesignerComponentDefinition[] = [ { name: 'PageContainer', @@ -610,5 +717,13 @@ export const getDesignerCatalog = (customNames: string[] = []): DesignerComponen hooks: [], })) - return [...DESIGNER_EXTRAS, ...PLATFORM_COMPONENTS, ...html, ...ui, ...generatedOnlyUi, ...custom] + return [ + ...DESIGNER_EXTRAS, + SQL_DATA_SOURCE_DEFINITION, + ...PLATFORM_COMPONENTS, + ...html, + ...ui, + ...generatedOnlyUi, + ...custom, + ] } diff --git a/ui/src/components/visualDesigner/codeGenerator.ts b/ui/src/components/visualDesigner/codeGenerator.ts index 64169ea7..2da0b470 100644 --- a/ui/src/components/visualDesigner/codeGenerator.ts +++ b/ui/src/components/visualDesigner/codeGenerator.ts @@ -1,15 +1,35 @@ import { getDesignerCollectionProperty, getDesignerTabSlotValue, + getSqlDataSourceEndpointId, + getSqlDataSourceKeyField, + getSqlDataSourceKeyParam, + getSqlDataSourceKeySource, + hasSqlDataSourceUrlParams, isDesignerDateComponent, + isDesignerDateOnlyComponent, isDesignerDateProperty, isDesignerOptionComponent, + isSqlDataSourceNode, normalizeDesignerKeyList, DESIGNER_DROPDOWN_PLACEHOLDER, + SQL_DATA_SOURCE_SLOTS, + type DesignerDataSource, type DesignerDocument, type DesignerNode, } from './types' +/** + * Record scope opened by a SqlDataSource. The record state is named + * `data_`, which is exactly what `bindingExpression` emits for a binding + * whose `sourceId` is the container's node id — so children bind to columns + * through the regular binding machinery and write back through `setterName`. + */ +interface FormScope { + sourceId: string + setterName: string +} + const safeIdentifier = (value: string) => { const cleaned = value.replace(/[^A-Za-z0-9_$]/g, '_') return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `Component_${cleaned}` @@ -110,6 +130,21 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => { } } +/** Prop of a node that the enclosing SqlDataSource writes the record back from. */ +const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => { + if (!formScope) return null + const propertyName = (['value', 'checked'] as const).find( + (name) => node.bindings?.[name]?.sourceId === formScope.sourceId, + ) + if (!propertyName) return null + return { + propertyName, + eventName: node.type === 'AutoComplete' ? 'onInputChange' : 'onChange', + path: node.bindings[propertyName].path, + setterName: formScope.setterName, + } +} + const isOptionCollectionProperty = (node: DesignerNode, propertyName: string) => isDesignerOptionComponent(node.type) && propertyName === getDesignerCollectionProperty(node.type) @@ -141,11 +176,19 @@ const withoutCollection = (node: DesignerNode) => { return { node: { ...node, props, bindings }, props, bindings } } -const propsToCode = (node: DesignerNode, itemVariable?: string, omitProperties: string[] = []) => { +const propsToCode = ( + node: DesignerNode, + itemVariable?: string, + omitProperties: string[] = [], + formScope?: FormScope, +) => { const omitted = new Set(omitProperties) const runtimeState = getRuntimeStateSpec(node) + const formField = getFormScopeField(node, formScope) const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId) - const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) + // Inside a SqlDataSource the bound value is editable: the change is written to + // the record, so the input must not be forced read-only. + const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) || Boolean(formField) const props = Object.entries(node.props) .filter( ([key, value]) => @@ -154,6 +197,9 @@ const propsToCode = (node: DesignerNode, itemVariable?: string, omitProperties: !omitted.has(key) && !(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) && !(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') && + // A field the SqlDataSource writes back must stay editable, whatever the + // node happens to carry from the toolbox defaults. + !(formField && (key === 'readOnly' || key === 'disabled')) && !node.bindings?.[key]?.sourceId && value !== '' && value !== undefined, @@ -161,7 +207,7 @@ const propsToCode = (node: DesignerNode, itemVariable?: string, omitProperties: .map(([key, value]) => // Dates live in the document as ISO strings; pickers need Date instances. isDesignerDateProperty(node.type, key) - ? `${key}={toDesignerDate(${staticValueExpression(value)})}` + ? `${key}={toDesignerDate(${staticValueExpression(value)}, ${isDesignerDateOnlyComponent(node.type)})}` : `${key}=${serializeValue(value)}`, ) @@ -174,7 +220,17 @@ const propsToCode = (node: DesignerNode, itemVariable?: string, omitProperties: const binding = node.bindings?.[propertyName] const propertyExpression = isOptionCollectionProperty(node, propertyName) ? `toSelectOptions(${expression}, ${JSON.stringify(binding?.labelPath || '')}, ${JSON.stringify(binding?.valuePath || '')})` - : expression + : // Pickers need a Date instance; an endpoint column is an ISO string. + isDesignerDateProperty(node.type, propertyName) + ? `toDesignerDate(${expression}, ${isDesignerDateOnlyComponent(node.type)})` + : // Select is controlled by the option object, not by the raw column value. + node.type === 'Select' && propertyName === 'value' + ? `toSelectValue(${optionCollectionExpression(node, itemVariable)}, ${expression}, ${Boolean(node.props.isMulti)})` + : // An empty record (New mode) would otherwise hand React `undefined`, + // which flips the field to uncontrolled and makes it unusable. + formField?.propertyName === propertyName + ? `(${expression}) ?? ${propertyName === 'checked' ? 'false' : '""'}` + : expression props.push(`${propertyName}={${propertyExpression}}`) } }) @@ -191,6 +247,7 @@ const propsToCode = (node: DesignerNode, itemVariable?: string, omitProperties: .map(([eventName]) => eventName), ) if (runtimeState) handlerNames.add(runtimeState.eventName) + if (formField) handlerNames.add(formField.eventName) handlerNames.forEach((eventName) => props.push(`${eventName}={handle_${safeIdentifier(node.id)}_${eventName}}`), ) @@ -207,7 +264,262 @@ const indent = (text: string, level: number) => .map((line) => `${' '.repeat(level)}${line}`) .join('\n') -const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): string => { +const sqlIdentifiers = (node: DesignerNode) => { + const identifier = safeIdentifier(node.id) + return { + identifier, + record: `data_${identifier}`, + setRecord: `setData_${identifier}`, + rows: `sqlRows_${identifier}`, + busy: `sqlBusy_${identifier}`, + setBusy: `setSqlBusy_${identifier}`, + error: `sqlError_${identifier}`, + setError: `setSqlError_${identifier}`, + key: `sqlKey_${identifier}`, + keyParam: `sqlKeyParam_${identifier}`, + hasKey: `sqlHasKey_${identifier}`, + original: `sqlOriginal_${identifier}`, + setOriginal: `setSqlOriginal_${identifier}`, + mode: `sqlMode_${identifier}`, + setMode: `setSqlMode_${identifier}`, + index: `sqlIndex_${identifier}`, + setIndex: `setSqlIndex_${identifier}`, + selectUrl: `sqlSelectUrl_${identifier}`, + urlKey: `sqlUrlKey_${identifier}`, + setField: `sqlSetField_${identifier}`, + reload: `sqlReload_${identifier}`, + refresh: `sqlRefresh_${identifier}`, + create: `sqlNew_${identifier}`, + save: `sqlSave_${identifier}`, + remove: `sqlDelete_${identifier}`, + previous: `sqlPrev_${identifier}`, + next: `sqlNext_${identifier}`, + slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`, + } +} + +/** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */ +const sqlSlotLiteral = ( + node: DesignerNode, + property: string, + method: string, + dataSources: DesignerDataSource[], +) => { + const source = dataSources.find( + (candidate) => candidate.id === getSqlDataSourceEndpointId(node, property), + ) + if (!source?.url.trim()) return 'null' + return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }` +} + +const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[]) => { + const names = sqlIdentifiers(node) + const keyField = getSqlDataSourceKeyField(node) + const selectSource = dataSources.find( + (candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'), + ) + // The Select endpoint already owns a fetch hook, so the container reuses that + // state instead of issuing a second request for the same URL. + const selectData = selectSource ? `data_${safeIdentifier(selectSource.id)}` : 'null' + // Must mirror the setter emitted by the data hooks, or the reload would call an + // identifier that was never declared. + const setSelectData = + selectSource?.method === 'GET' && selectSource.url.trim().startsWith('/api/') + ? `setData_${safeIdentifier(selectSource.id)}` + : '' + const keySource = getSqlDataSourceKeySource(node) + const rowsExpression = `toSqlRows(${selectData}, ${JSON.stringify(String(node.props.collectionPath ?? ''))})` + const slots = SQL_DATA_SOURCE_SLOTS.map( + (slot) => + ` const ${names.slot(slot.property)} = ${sqlSlotLiteral(node, slot.property, slot.method, dataSources)}`, + ).join('\n') + + return `${slots} + const ${names.key} = ${JSON.stringify(keyField)} + const ${names.keyParam} = ${JSON.stringify(getSqlDataSourceKeyParam(node))} + const [${names.record}, ${names.setRecord}] = React.useState({}) + const [${names.original}, ${names.setOriginal}] = React.useState({}) + const [${names.mode}, ${names.setMode}] = React.useState("edit") + const [${names.index}, ${names.setIndex}] = React.useState(0) + const [${names.busy}, ${names.setBusy}] = React.useState(false) + const [${names.error}, ${names.setError}] = React.useState("") + // The Select key can come from the page URL, which is how a detail page reads + // /api/app/orders/{id} or /api/app/orders?id=… for a single record. + const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam}) + const ${names.selectUrl} = React.useMemo(() => { + if (!${names.slot('selectEndpoint')}) return "" + if (!${names.urlKey}) return ${names.slot('selectEndpoint')}.url + const bound = bindSqlUrl(${names.slot('selectEndpoint')}.url, { [${names.keyParam}]: ${names.urlKey} }, ${names.keyParam}) + return bound.keyBound ? bound.url : appendQueryParam(bound.url, ${names.keyParam}, ${names.urlKey}) + }, [${names.urlKey}]) + // A key in the page URL always narrows the result: a list endpoint ignores the + // parameter server side, so the requested record is picked out here. Without a + // key every row is kept and the navigation below takes over. + const ${names.rows} = React.useMemo( + () => filterSqlRowsByKey(${rowsExpression}, ${names.key}, ${names.urlKey}), + [${selectData}, ${names.urlKey}], + ) + // A fresh result set resets the position; New mode is left untouched so an + // unsaved draft is not overwritten by a re-render. + React.useEffect(() => { + ${names.setIndex}((current) => (current < ${names.rows}.length ? current : 0)) + }, [${names.rows}]) + React.useEffect(() => { + const row = ${names.rows}[${names.index}] + if (!row) return + ${names.setRecord}(row) + ${names.setOriginal}(row) + ${names.setMode}("edit") + }, [${names.rows}, ${names.index}]) + const ${names.hasKey} = (() => { + const value = readSqlField(${names.record}, ${names.key}) + return value !== undefined && value !== null && value !== "" + })() + const ${names.setField} = React.useCallback((path, value) => ${names.setRecord}((current) => setSqlField(current, path, value)), []) + const ${names.reload} = React.useCallback(async () => {${ + setSelectData + ? ` + if (!${names.selectUrl}) return + // Calling a URL that still holds a placeholder is a guaranteed 400, so the + // missing key is reported in the component instead. + if (hasSqlUrlParams(${names.selectUrl})) { + throw new Error("Select endpointi " + ${names.keyParam} + " parametresini bekliyor; sayfa adresinde bulunamadı.") + } + const response = await apiService.fetchData({ url: ${names.selectUrl}, method: "GET" }) + ${setSelectData}(getByPath(response.data, ${names.slot('selectEndpoint')}.responsePath))` + : '' + } + }, [${setSelectData ? names.selectUrl : ''}]) + // Reload wrapped with the busy/error handling the toolbar and mount effect need, + // so a failing Select is reported in the component instead of the console. + const ${names.refresh} = React.useCallback(async () => { + ${names.setBusy}(true) + ${names.setError}("") + try { + await ${names.reload}() + } catch (error) { + ${names.setError}(toSqlErrorMessage(error)) + } finally { + ${names.setBusy}(false) + } + }, [${names.reload}]) + const ${names.create} = React.useCallback(() => { + ${names.setError}("") + ${names.setRecord}({}) + ${names.setOriginal}({}) + ${names.setMode}("new") + }, []) + const ${names.previous} = React.useCallback(() => ${names.setIndex}((current) => Math.max(0, current - 1)), []) + const ${names.next} = React.useCallback(() => ${names.setIndex}((current) => Math.min(${names.rows}.length - 1, current + 1)), [${names.rows}]) + const ${names.save} = async () => { + const isNew = ${names.mode} === "new" + const target = isNew ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')} + if (!target) { + ${names.setError}(isNew ? "Insert için POST endpointi tanımlı değil." : "Update için PUT endpointi tanımlı değil.") + return + } + // Update carries only the edited columns plus the key; Insert sends the record. + const payload = isNew ? ${names.record} : toSqlChanges(${names.original}, ${names.record}, ${names.key}) + if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) { + ${names.setError}("Kaydedilecek bir değişiklik yok.") + return + } + ${names.setBusy}(true) + ${names.setError}("") + try { + const response = await callSqlEndpoint(target, ${names.record}, ${names.key}, payload) + // With a Select endpoint the reload is the source of truth and its effect + // repopulates the form; without one, the response row is all there is. + if (${names.slot('selectEndpoint')}) { + await ${names.reload}() + } else { + const saved = toSqlRows(response?.data, "")[0] + if (saved) { + ${names.setRecord}(saved) + ${names.setOriginal}(saved) + } + } + ${names.setMode}("edit") + } catch (error) { + ${names.setError}(toSqlErrorMessage(error)) + } finally { + ${names.setBusy}(false) + } + } + const ${names.remove} = async () => { + if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return + // Deleting cannot be undone from the form, so it always asks first. + const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?" + if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return + ${names.setBusy}(true) + ${names.setError}("") + try { + await callSqlEndpoint(${names.slot('deleteEndpoint')}, ${names.record}, ${names.key}) + ${names.setRecord}({}) + ${names.setOriginal}({}) + ${names.setMode}("new") + await ${names.reload}() + } catch (error) { + ${names.setError}(toSqlErrorMessage(error)) + } finally { + ${names.setBusy}(false) + } + }${ + node.props.autoLoad === false + ? '' + : ` + React.useEffect(() => { void ${names.refresh}() }, [${names.refresh}])` + }` +} + +const SQL_TOOLBAR_BUTTON_CLASS = + 'rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40' + +const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: string) => { + const names = sqlIdentifiers(node) + const formScope: FormScope = { sourceId: node.id, setterName: names.setField } + const children = node.children + .map((child) => nodeToCode(child, level + 1, itemVariable, formScope)) + .join('\n') + const className = JSON.stringify(String(node.props.className || '')) + const style = `{ display: "flex", flexDirection: "column", gap: ${Number(node.props.gap) || 0} }` + const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300` + // Navigation appears on its own whenever there is more than one record to walk. + const navigation = ` +${indent(`{${names.rows}.length > 1 ? (`, level + 2)} +${indent('<>', level + 3)} +${indent(``, level + 4)} +${indent(`{\`\${${names.index} + 1} / \${${names.rows}.length}\`}`, level + 4)} +${indent(``, level + 4)} +${indent('', level + 4)} +${indent('', level + 3)} +${indent(') : null}', level + 2)}` + const toolbar = + node.props.showToolbar === false + ? '' + : ` +${indent('
', level + 1)}${navigation} +${indent(``, level + 2)} +${indent(``, level + 2)} +${indent(``, level + 2)} +${indent(``, level + 2)} +${indent(`{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}`, level + 2)} +${indent('
', level + 1)}` + const error = ` +${indent(`{${names.error} ?
{${names.error}}
: null}`, level + 1)}` + + return `${indent(`
`, level)} +${children}${toolbar}${error} +${indent('
', level)}` +} + +const nodeToCode = ( + node: DesignerNode, + level = 0, + itemVariable?: string, + formScope?: FormScope, +): string => { + if (isSqlDataSourceNode(node.type)) return sqlDataSourceToCode(node, level, itemVariable) if (node.type === 'Spacer') { return indent( `', level)}` } @@ -241,7 +553,7 @@ const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): strin ? `{ 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 - .map((child) => nodeToCode(child, level + 1, itemVariable)) + .map((child) => nodeToCode(child, level + 1, itemVariable, formScope)) .join('\n') return `${indent(`
`, level)}\n${children}\n${indent('
', level)}` } @@ -258,7 +570,9 @@ const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): strin `table:${Math.floor(childIndex / columnCount)}:${childIndex % columnCount}`) === slot, ) const content = cellChildren.length - ? cellChildren.map((child) => nodeToCode(child, level + 3, itemVariable)).join('\n') + ? cellChildren + .map((child) => nodeToCode(child, level + 3, itemVariable, formScope)) + .join('\n') : indent('', level + 3) return `${indent('', level + 2)}\n${content}\n${indent('', level + 2)}` }).join('\n') @@ -329,7 +643,7 @@ ${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)} ${indent(``, level + 7)} ${indent(`{${columnsVariable}.map((column) => {`, level + 8)} ${indent(`const ${valueVariable} = column === "value" ? ${itemVariable} : getByPath(${itemVariable}, column)`, level + 9)} -${indent(`const text = typeof ${valueVariable} === "object" && ${valueVariable} !== null ? JSON.stringify(${valueVariable}) : String(${valueVariable} ?? "") || "—"`, level + 9)} +${indent(`const text = typeof ${valueVariable} === "object" && ${valueVariable} !== null && !(${valueVariable} instanceof Date) ? JSON.stringify(${valueVariable}) : formatLocaleValue(${valueVariable}) || "—"`, level + 9)} ${indent(`return {text}`, level + 9)} ${indent('})}', level + 8)} ${indent('', level + 7)} @@ -344,10 +658,12 @@ ${indent('})()}', level + 1)}` const repeatedItemVariable = `item_${safeIdentifier(node.id)}` const staticChildren = node.children - .map((child) => nodeToCode(child, level + 1, itemVariable)) + .map((child) => nodeToCode(child, level + 1, itemVariable, formScope)) .join('\n') const repeatedChildren = node.children.length - ? node.children.map((child) => nodeToCode(child, level + 3, repeatedItemVariable)).join('\n') + ? node.children + .map((child) => nodeToCode(child, level + 3, repeatedItemVariable, formScope)) + .join('\n') : indent( `
{typeof ${repeatedItemVariable} === "string" ? ${repeatedItemVariable} : JSON.stringify(${repeatedItemVariable}, null, 2)}
`, level + 3, @@ -387,7 +703,7 @@ ${indent(')}', level + 1)}` delete menuProps.variant menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys) menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys) - const menuPropsCode = propsToCode(menuNode, itemVariable) + 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)}` } @@ -402,7 +718,7 @@ ${indent(')}', level + 1)}` const fallbackTitleExpression = boundTitle || staticValueExpression(dropdownProps.title ?? '') delete dropdownProps.title delete bindings.title - const dropdownPropsCode = propsToCode(dropdownNode, itemVariable) + const dropdownPropsCode = propsToCode(dropdownNode, itemVariable, [], formScope) const optionsExpression = optionCollectionExpression(node, itemVariable) const activeKeyExpression = getRuntimeStateSpec(node)?.stateName || @@ -414,7 +730,7 @@ ${indent(')}', level + 1)}` if (node.type === 'Pagination') { const { node: paginationNode } = withoutCollection(node) - const paginationPropsCode = propsToCode(paginationNode, itemVariable) + const paginationPropsCode = propsToCode(paginationNode, itemVariable, [], formScope) const optionsExpression = optionCollectionExpression(node, itemVariable) return indent( ``, @@ -425,7 +741,7 @@ ${indent(')}', level + 1)}` if (node.type === 'Tabs') { const { node: tabsNode } = withoutCollection(node) // `value` is emitted below, resolved against the (possibly async) tab list. - const tabsPropsCode = propsToCode(tabsNode, itemVariable, ['value']) + const tabsPropsCode = propsToCode(tabsNode, itemVariable, ['value'], formScope) const identifier = safeIdentifier(node.id) const optionsVariable = `tabItems_${identifier}` const activeVariable = `tabValue_${identifier}` @@ -449,7 +765,7 @@ ${indent(')}', level + 1)}` }) const fragmentFor = (children: DesignerNode[], fragmentLevel: number) => `${indent('<>', fragmentLevel)}\n${children - .map((child) => nodeToCode(child, fragmentLevel + 1, itemVariable)) + .map((child) => nodeToCode(child, fragmentLevel + 1, itemVariable, formScope)) .join('\n')}\n${indent('', fragmentLevel)}` const slottedEntries = [...slotGroups] @@ -493,7 +809,7 @@ ${indent('})()}', level)}` delete groupProps.checked delete groupProps.defaultChecked delete groupProps.readOnly - const groupPropsCode = propsToCode(groupNode, itemVariable) + const groupPropsCode = propsToCode(groupNode, itemVariable, [], formScope) const optionsExpression = optionCollectionExpression(node, itemVariable) return `${indent(``, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => {String(option.label ?? option.value ?? \`Seçenek \${optionIndex + 1}\`)})}`, level + 1)}\n${indent('', level)}` } @@ -509,7 +825,7 @@ ${indent('})()}', level)}` const props = { ...node.props } if (node.type === 'checkbox') props.type = 'checkbox' const normalizedNode = { ...node, props } - const propCode = propsToCode(normalizedNode, itemVariable) + const propCode = propsToCode(normalizedNode, itemVariable, [], formScope) const childrenText = String(node.props.children ?? '') const childrenBinding = bindingExpression(node, 'children', itemVariable) const hasChildren = node.children.length > 0 || childrenText.length > 0 || !!childrenBinding @@ -518,7 +834,9 @@ ${indent('})()}', level)}` if (voidElement) return indent(`<${tag}${propCode} />`, level) if (!hasChildren) return indent(`<${tag}${propCode} />`, level) - const nested = node.children.map((child) => nodeToCode(child, level + 1, itemVariable)).join('\n') + const nested = node.children + .map((child) => nodeToCode(child, level + 1, itemVariable, formScope)) + .join('\n') const text = childrenBinding ? indent(`{${childrenBinding}}`, level + 1) : childrenText @@ -538,8 +856,11 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = let hasSelectComponent = false let hasDatePicker = false let hasTabs = false + const sqlDataSourceNodes: DesignerNode[] = [] + /** Sources loaded by a SqlDataSource; their own mount fetch would duplicate it. */ + const sqlManagedSelectSourceIds = new Set() - const visit = (nodes: DesignerNode[]) => { + const visit = (nodes: DesignerNode[], formScope?: FormScope) => { nodes.forEach((node) => { if (isDesignerOptionComponent(node.type)) hasSelect = true if (node.type === 'Select') hasSelectComponent = true @@ -547,11 +868,17 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = if (node.type === 'Grid') hasDataTable = true if (node.type === 'Tabs') hasTabs = true if (isDesignerDateComponent(node.type)) hasDatePicker = true + if (isSqlDataSourceNode(node.type)) { + sqlDataSourceNodes.push(node) + const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint') + if (selectId) sqlManagedSelectSourceIds.add(selectId) + } + const formField = getFormScopeField(node, formScope) const runtimeState = getRuntimeStateSpec(node) if (runtimeState) { // Date pickers hold a Date in state but an ISO string in the document. const initialExpression = isDesignerDateProperty(node.type, runtimeState.propertyName) - ? `toDesignerDate(${JSON.stringify(runtimeState.initialValue ?? null)})` + ? `toDesignerDate(${JSON.stringify(runtimeState.initialValue ?? null)}, ${isDesignerDateOnlyComponent(node.type)})` : JSON.stringify(runtimeState.initialValue) runtimeStateHooks.push( ` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`, @@ -563,10 +890,28 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = .map(([eventName]) => eventName), ) if (runtimeState) eventNames.add(runtimeState.eventName) + if (formField) eventNames.add(formField.eventName) eventNames.forEach((eventName) => { const script = node.events[eventName]?.trim() || '' + // A form scoped field replaces the local state update: the edit belongs + // to the SqlDataSource record, which is what Save posts back. + const formUpdate = + formField?.eventName === eventName + ? `${formField.setterName}(${JSON.stringify(formField.path)}, ${ + node.type === 'Select' + ? 'Array.isArray(valueOrEvent) ? valueOrEvent.map((option) => option?.value ?? option) : (valueOrEvent?.value ?? null)' + : formField.propertyName === 'checked' + ? 'typeof originalEvent === "object" && originalEvent?.target ? Boolean(originalEvent.target.checked) : typeof valueOrEvent === "object" && valueOrEvent?.target ? Boolean(valueOrEvent.target.checked) : Boolean(valueOrEvent)' + : isDesignerDateComponent(node.type) + ? `fromDesignerDate(valueOrEvent ?? null, ${isDesignerDateOnlyComponent(node.type)})` + : node.type === 'AutoComplete' + ? 'valueOrEvent ?? ""' + : 'valueOrEvent?.target?.value ?? valueOrEvent' + })` + : '' const stateUpdate = - runtimeState?.eventName === eventName + formUpdate || + (runtimeState?.eventName === eventName ? node.type === 'Select' ? `${runtimeState.setterName}(valueOrEvent)` : node.type === 'AutoComplete' @@ -588,7 +933,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = : node.type === 'Input' || node.kind === 'html' ? `${runtimeState.setterName}(valueOrEvent?.target?.value ?? valueOrEvent)` : `${runtimeState.setterName}(valueOrEvent)` - : '' + : '') const eventDeclaration = node.type === 'Checkbox' && eventName === 'onChange' ? ' const event = { checked: Boolean(valueOrEvent), originalEvent, target: originalEvent?.target }' @@ -597,7 +942,12 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = ` const handle_${safeIdentifier(node.id)}_${eventName} = (valueOrEvent, originalEvent) => {\n${stateUpdate ? ` ${stateUpdate}\n` : ''}${eventDeclaration}${script ? `\n${indent(script, 2)}` : ''}\n }`, ) }) - visit(node.children) + visit( + node.children, + isSqlDataSourceNode(node.type) + ? { sourceId: node.id, setterName: sqlIdentifiers(node).setField } + : formScope, + ) }) } visit(document.nodes) @@ -617,6 +967,14 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = }) }` : '' + // A bound Select receives a raw column value, but react-select is controlled by + // the option object itself. + const selectValueHelpers = hasSelectComponent + ? ` const toSelectValue = (options, value, isMulti) => { + if (isMulti) return options.filter((option) => Array.isArray(value) && value.includes(option.value)) + return options.find((option) => option.value === value) ?? null + }` + : '' // Dropdown paints its toggle from `title`; `activeKey` only marks the item // inside the closed menu, so the label of the active option is resolved here. const dropdownHelpers = hasDropdown @@ -636,14 +994,63 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = return options.length ? String(options[0]?.value ?? "") : "" }` : '' + // Grid cells print raw endpoint columns, so dates and decimals are localised + // here — the culture is published on `` because a runtime compiled + // component has neither hooks nor imports to reach the store. + const localeHelpers = hasDataTable + ? ` const localeCulture = () => (typeof document !== "undefined" && document.documentElement.lang) || "en" + const localeDateOptions = { year: "numeric", month: "2-digit", day: "2-digit" } + const localeTimeOptions = { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" } + const localeIsoPattern = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/ + const formatLocaleValue = (value) => { + if (value === null || value === undefined) return "" + const culture = localeCulture() + if (typeof value === "number") { + if (!Number.isFinite(value)) return String(value) + return new Intl.NumberFormat(culture, { useGrouping: false, maximumFractionDigits: 20 }).format(value) + } + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? "" : new Intl.DateTimeFormat(culture, localeTimeOptions).format(value) + } + if (typeof value !== "string") return String(value) + const parts = localeIsoPattern.exec(value.trim()) + if (!parts) return value + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return value + if (parts[4] === undefined) { + const day = new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])) + return new Intl.DateTimeFormat(culture, localeDateOptions).format(day) + } + return new Intl.DateTimeFormat(culture, localeTimeOptions).format(parsed) + }` + : '' // Pickers check `instanceof Date`, but the document only stores ISO strings. + // Both directions stay on the local wall clock: a date column carries no + // timezone, so converting through UTC would move 29/07 to 28/07. const dateHelpers = hasDatePicker - ? ` const toDesignerDate = (value) => { - if (Array.isArray(value)) return value.map(toDesignerDate) + ? ` const sqlPad = (value) => String(value).padStart(2, "0") + const isoDateTime = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2}))?)?/ + const toDesignerDate = (value, dateOnly) => { + if (Array.isArray(value)) return value.map((entry) => toDesignerDate(entry, dateOnly)) if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value if (typeof value !== "string" || !value.trim()) return null + const parts = isoDateTime.exec(value.trim()) + const midnight = parts && dateOnly && !Number(parts[4]) && !Number(parts[5]) && !Number(parts[6]) + if (parts && (parts[4] === undefined || midnight)) { + return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])) + } const parsed = new Date(value) return Number.isNaN(parsed.getTime()) ? null : parsed + } + const fromDesignerDate = (value, dateOnly) => { + if (Array.isArray(value)) return value.map((entry) => fromDesignerDate(entry, dateOnly)) + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return null + const day = value.getFullYear() + "-" + sqlPad(value.getMonth() + 1) + "-" + sqlPad(value.getDate()) + if (dateOnly) return day + return day + "T" + sqlPad(value.getHours()) + ":" + sqlPad(value.getMinutes()) + ":" + sqlPad(value.getSeconds()) + } + return typeof value === "string" && value.trim() ? value : null }` : '' // Select renders its menu inline, so a scrollable/clipped ancestor (Grid, Table, @@ -655,8 +1062,95 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = styles: { menuPortal: (base) => ({ ...base, zIndex: 60 }) }, }` : '' + // Shared SqlDataSource runtime: row extraction, record writes, URL parameter + // binding and the actual Insert/Update/Delete calls. + const sqlHelpers = sqlDataSourceNodes.length + ? ` const readSqlField = (record, field) => { + if (!record || typeof record !== "object" || Array.isArray(record)) return undefined + if (field in record) return record[field] + const matched = Object.keys(record).find((key) => key.toLowerCase() === String(field).toLowerCase()) + return matched === undefined ? undefined : record[matched] + } + const toSqlRows = (value, collectionPath) => { + const source = collectionPath ? getByPath(value, collectionPath) : value + const rows = Array.isArray(source) + ? source + : source && typeof source === "object" + ? (Array.isArray(source.items) ? source.items : [source]) + : [] + return rows.filter((item) => item && typeof item === "object" && !Array.isArray(item)) + } + const setSqlField = (record, path, value) => { + const keys = String(path || "").split(".").filter(Boolean) + if (!keys.length) return record + const next = { ...(record || {}) } + let target = next + for (let index = 0; index < keys.length - 1; index += 1) { + const child = target[keys[index]] + target[keys[index]] = child && typeof child === "object" && !Array.isArray(child) ? { ...child } : {} + target = target[keys[index]] + } + target[keys[keys.length - 1]] = value + return next + } + const bindSqlUrl = (url, record, keyField) => { + let keyBound = false + const boundUrl = String(url).replace(/\\{([^}]+)\\}|(?<=\\/):([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, colon) => { + const name = (braced || colon || "").trim() + if (!name) return match + const value = readSqlField(record, name) ?? readSqlField(record, keyField) + if (value === undefined || value === null || value === "") return match + if (name.toLowerCase() === String(keyField).toLowerCase()) keyBound = true + return encodeURIComponent(String(value)) + }) + return { url: boundUrl, keyBound } + } + const appendQueryParam = (url, name, value) => + url + (url.includes("?") ? "&" : "?") + encodeURIComponent(name) + "=" + encodeURIComponent(value) + const hasSqlUrlParams = (url) => /\\{[^}]+\\}|(?<=\\/):[A-Za-z_][A-Za-z0-9_]*/.test(String(url)) + const readUrlKey = (source, name) => { + if (typeof window === "undefined") return "" + if (source === "query") return new URLSearchParams(window.location.search).get(name) ?? "" + const segments = window.location.pathname.split("/").filter(Boolean) + return segments.length ? decodeURIComponent(segments[segments.length - 1]) : "" + } + const filterSqlRowsByKey = (rows, keyField, keyValue) => { + if (!keyValue) return rows + const matched = rows.filter((row) => String(readSqlField(row, keyField) ?? "") === String(keyValue)) + // No match means the endpoint already filtered server side, or the key column + // is named differently — keeping the rows beats showing an empty form. + return matched.length ? matched : rows + } + // Update payload: only the columns the user actually edited, plus the key. + const toSqlChanges = (original, current, keyField) => { + const changes = {} + const isSame = (left, right) => JSON.stringify(left ?? null) === JSON.stringify(right ?? null) + Object.keys(current || {}).forEach((column) => { + if (!isSame(original ? original[column] : undefined, current[column])) changes[column] = current[column] + }) + const keyColumn = Object.keys(current || {}).find((column) => column.toLowerCase() === String(keyField).toLowerCase()) + if (keyColumn !== undefined && changes[keyColumn] === undefined) changes[keyColumn] = current[keyColumn] + return changes + } + const callSqlEndpoint = (target, record, keyField, payload) => { + // The URL is always bound from the full record: a placeholder may reference a + // column that the update payload does not carry. + const bound = bindSqlUrl(target.url, record, keyField) + const request = { url: bound.url, method: target.method } + if (target.method === "POST" || target.method === "PUT") request.data = payload === undefined ? record : payload + const keyValue = readSqlField(record, keyField) + const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== "" + // The key still has to reach the endpoint when the URL carries no placeholder. + if (!bound.keyBound && hasKey && target.method !== "POST") request.params = { [keyField]: keyValue } + return apiService.fetchData(request) + } + const toSqlErrorMessage = (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı."` + : '' + const sqlHooks = sqlDataSourceNodes + .map((node) => sqlDataSourceHooks(node, dataSources)) + .join('\n\n') const dataHelpers = - dataSources.length || hasDataTable + dataSources.length || hasDataTable || sqlDataSourceNodes.length ? ` const getByPath = (value, path) => { if (!path) return value const readPath = (target, targetPath) => targetPath.split('.').filter(Boolean).reduce((current, key) => current?.[key], target) @@ -669,9 +1163,17 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = const dataHooks = dataSources .map((source) => { const identifier = safeIdentifier(source.id) - if (!source.url.trim().startsWith('/api/')) { + // POST/PUT/DELETE sources are only ever invoked by a SqlDataSource command; + // they hold no readable state and must never be written to. + if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) { return ` const [data_${identifier}] = React.useState(null)` } + // A GetById style URL is not fetched on mount — requesting a literal `{id}` + // is a guaranteed 400 — and neither is a source the owning SqlDataSource + // loads itself. Both still need the setter that container writes through. + if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) { + return ` const [data_${identifier}, setData_${identifier}] = React.useState(null)` + } return ` const [data_${identifier}, setData_${identifier}] = React.useState(null) React.useEffect(() => { let active = true @@ -690,5 +1192,5 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) = const designerBackup = encodeURIComponent(JSON.stringify(document)) - return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, selectHelpers, dropdownHelpers, selectMenuHelpers, dateHelpers, tabHelpers, dataHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n \n )\n}\n\nexport default ${componentName}\n` + return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, selectHelpers, dropdownHelpers, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, dataHooks, sqlHooks, ...runtimeStateHooks, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n \n )\n}\n\nexport default ${componentName}\n` } diff --git a/ui/src/components/visualDesigner/types.ts b/ui/src/components/visualDesigner/types.ts index 6c6acfd0..d3357033 100644 --- a/ui/src/components/visualDesigner/types.ts +++ b/ui/src/components/visualDesigner/types.ts @@ -10,10 +10,28 @@ export interface DesignerBinding { valuePath?: string } +/** + * Data sources are no longer read-only: an endpoint can also be attached as the + * insert/update/delete command of a SqlDataSource, mirroring ASP.NET's + * SqlDataSource Select/Insert/Update/DeleteCommand pairs. + */ +export const DESIGNER_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const + +export type DesignerHttpMethod = (typeof DESIGNER_HTTP_METHODS)[number] + +export const toDesignerHttpMethod = (value: unknown): DesignerHttpMethod => { + const method = String(value ?? '') + .trim() + .toUpperCase() + return (DESIGNER_HTTP_METHODS as readonly string[]).includes(method) + ? (method as DesignerHttpMethod) + : 'GET' +} + export interface DesignerDataSource { id: string name: string - method: 'GET' + method: DesignerHttpMethod url: string responsePath: string } @@ -113,19 +131,59 @@ export const isDesignerDateComponent = (type?: string) => export const isDesignerDateProperty = (type?: string, propertyName?: string) => isDesignerDateComponent(type) && !!propertyName && DESIGNER_DATE_PROPERTIES.has(propertyName) -/** Stored value (ISO string) → `Date` for rendering. */ -export const toDesignerDate = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(toDesignerDate) +/** Pickers without a time part: their value is a calendar day, not an instant. */ +const DESIGNER_DATE_ONLY_COMPONENTS = new Set([ + 'Calendar', + 'DatePicker', + 'DatePickerRange', + 'RangeCalendar', +]) + +export const isDesignerDateOnlyComponent = (type?: string) => + Boolean(type && DESIGNER_DATE_ONLY_COMPONENTS.has(type)) + +const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/ + +const pad = (value: number) => String(value).padStart(2, '0') + +/** + * Stored value (ISO string) → `Date` for rendering, read as a *local* wall + * clock. `new Date("2026-07-29")` is UTC midnight, which every browser west of + * Greenwich shows as 28 July; a date column has no timezone to convert. + */ +export const toDesignerDate = (value: unknown, type?: string): unknown => { + if (Array.isArray(value)) return value.map((entry) => toDesignerDate(entry, type)) if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value if (typeof value !== 'string' || !value.trim()) return null + const parts = ISO_DATE_TIME.exec(value.trim()) + // Midnight on a date only picker is a plain day: "2026-07-29T00:00:00Z" means + // 29 July, never 28 July at 21:00 because the browser sits on +03:00. + const midnight = + parts && + isDesignerDateOnlyComponent(type) && + !Number(parts[4]) && + !Number(parts[5]) && + !Number(parts[6]) + if (parts && (parts[4] === undefined || midnight)) { + return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])) + } const parsed = new Date(value) return Number.isNaN(parsed.getTime()) ? null : parsed } -/** `Date` from a picker → ISO string for storage. */ -export const fromDesignerDate = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(fromDesignerDate) - if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString() +/** + * `Date` from a picker → string for storage, keeping the wall clock the user + * picked. `toISOString()` would shift 29/07 back to 28/07 on every positive + * offset, which is exactly what reached the database before. + */ +export const fromDesignerDate = (value: unknown, type?: string): unknown => { + if (Array.isArray(value)) return value.map((entry) => fromDesignerDate(entry, type)) + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return null + const day = `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}` + if (isDesignerDateOnlyComponent(type)) return day + return `${day}T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}` + } return typeof value === 'string' && value.trim() ? value : null } @@ -254,6 +312,167 @@ export const resolveDesignerResponse = (value: unknown, responsePath: string): u return undefined } +/** + * ASP.NET style data container: it owns the Select/Insert/Update/Delete endpoints + * and exposes the current record to every component dropped inside it. A child + * binds to a column by using the SqlDataSource node id as its binding `sourceId`, + * which is also the name of the record state emitted by the code generator — so + * the existing binding machinery keeps working unchanged. + */ +export const SQL_DATA_SOURCE_TYPE = 'SqlDataSource' + +export const isSqlDataSourceNode = (type?: string) => type === SQL_DATA_SOURCE_TYPE + +export interface SqlDataSourceSlot { + property: 'selectEndpoint' | 'insertEndpoint' | 'updateEndpoint' | 'deleteEndpoint' + method: DesignerHttpMethod + label: string + description: string +} + +export const SQL_DATA_SOURCE_SLOTS: readonly SqlDataSourceSlot[] = [ + { + property: 'selectEndpoint', + method: 'GET', + label: 'Select (GET)', + description: 'Kaydı okuyan endpoint. İçerideki komponentler bu sütunlara bağlanır.', + }, + { + property: 'insertEndpoint', + method: 'POST', + label: 'Insert (POST)', + description: 'Key alanı boşken Save butonu bu endpointi çağırır.', + }, + { + property: 'updateEndpoint', + method: 'PUT', + label: 'Update (PUT)', + description: 'Key alanı doluyken Save butonu bu endpointi çağırır.', + }, + { + property: 'deleteEndpoint', + method: 'DELETE', + label: 'Delete (DELETE)', + description: 'Delete butonunu aktifleştirir.', + }, +] as const + +export const getSqlDataSourceSlot = (property: string) => + SQL_DATA_SOURCE_SLOTS.find((slot) => slot.property === property) + +export const getSqlDataSourceEndpointId = (node: DesignerNode, property: string) => + String(node.props?.[property] ?? '').trim() + +export const getSqlDataSourceKeyField = (node: DesignerNode) => + String(node.props?.keyFieldName ?? '').trim() || 'id' + +/** + * Where the key in the page URL is read from. It fills `/api/app/orders/{id}`, + * is appended as `?id=…` when the endpoint has no placeholder, and narrows a list + * result to the requested record — all without any extra configuration, which is + * why the query string is the default rather than an opt in. + */ +export const SQL_DATA_SOURCE_KEY_SOURCES = [ + { value: 'query', label: 'URL query string (?id=…)' }, + { value: 'route', label: 'URL son segmenti (/…/5)' }, +] as const + +export type SqlDataSourceKeySource = (typeof SQL_DATA_SOURCE_KEY_SOURCES)[number]['value'] + +export const getSqlDataSourceKeySource = (node: DesignerNode): SqlDataSourceKeySource => + String(node.props?.keySource ?? '') === 'route' ? 'route' : 'query' + +/** Name of the URL parameter; defaults to the key column. */ +export const getSqlDataSourceKeyParam = (node: DesignerNode) => + String(node.props?.keyParamName ?? '').trim() || getSqlDataSourceKeyField(node) + +/** + * Design time only. A GetById endpoint cannot be sampled in the designer — there + * is no page URL to read the key from — so the columns stay unknown and children + * have nothing to bind to. This value stands in for the key while designing. + */ +export const getSqlDataSourcePreviewKey = (node: DesignerNode) => + String(node.props?.previewKeyValue ?? '').trim() + +/** Edit mode drives which command Save issues, independently of the key value. */ +export type SqlDataSourceMode = 'new' | 'edit' + +/** Case insensitive column read; SQL results rarely match the configured casing. */ +export const readSqlDataSourceField = (record: unknown, field: string): unknown => { + if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined + const source = record as Record + if (field in source) return source[field] + const matched = Object.keys(source).find( + (key) => key.toLocaleLowerCase('en') === field.toLocaleLowerCase('en'), + ) + return matched === undefined ? undefined : source[matched] +} + +/** + * Rows of the Select endpoint. A single object response is treated as a one row + * result set so a detail endpoint works without extra configuration; ABP list + * envelopes (`{ items: [] }`) are unwrapped. + */ +export const resolveSqlDataSourceRows = ( + sample: unknown, + collectionPath = '', +): Array> => { + const value = collectionPath.trim() ? getDesignerValueByPath(sample, collectionPath) : sample + const candidate = Array.isArray(value) + ? value + : value && typeof value === 'object' + ? Array.isArray((value as Record).items) + ? ((value as Record).items as unknown[]) + : [value] + : [] + return candidate.filter( + (item): item is Record => + Boolean(item) && typeof item === 'object' && !Array.isArray(item), + ) +} + +export const getSqlDataSourceRecord = ( + node: DesignerNode, + dataValues: Record, + rowIndex = 0, +): Record => { + const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint') + if (!selectId) return {} + const rows = resolveSqlDataSourceRows( + dataValues[selectId], + String(node.props?.collectionPath ?? ''), + ) + return rows[rowIndex] ?? {} +} + +/** + * Fills `{Id}` / `:Id` placeholders of an endpoint URL from the current record, + * falling back to the key field. Returns whether the key made it into the URL so + * the caller knows if it still has to be sent as a query parameter. + */ +/** `/api/app/orders/{id}` — a URL that cannot be called until a key fills it in. */ +export const hasSqlDataSourceUrlParams = (url: string) => + /\{[^}]+\}|(?<=\/):[A-Za-z_][A-Za-z0-9_]*/.test(url) + +export const appendSqlDataSourceQueryParam = (url: string, name: string, value: string) => + `${url}${url.includes('?') ? '&' : '?'}${encodeURIComponent(name)}=${encodeURIComponent(value)}` + +export const bindSqlDataSourceUrl = (url: string, record: unknown, keyField: string) => { + let keyBound = false + const boundUrl = url.replace( + /\{([^}]+)\}|(?<=\/):([A-Za-z_][A-Za-z0-9_]*)/g, + (match, braced?: string, colon?: string) => { + const name = (braced || colon || '').trim() + if (!name) return match + const value = readSqlDataSourceField(record, name) ?? readSqlDataSourceField(record, keyField) + if (value === undefined || value === null || value === '') return match + if (name.toLocaleLowerCase('en') === keyField.toLocaleLowerCase('en')) keyBound = true + return encodeURIComponent(String(value)) + }, + ) + return { url: boundUrl, keyBound } +} + export const normalizeDesignerKeyList = (value: unknown): string[] => { let candidate = value diff --git a/ui/src/routes/dynamicRouteLoader.tsx b/ui/src/routes/dynamicRouteLoader.tsx index c3c6a20b..2dfa66fe 100644 --- a/ui/src/routes/dynamicRouteLoader.tsx +++ b/ui/src/routes/dynamicRouteLoader.tsx @@ -104,6 +104,8 @@ export interface DynamicReactRoute { authority?: string[] componentType: string componentPath: string + /** Browser tab title; only runtime components carry a human readable label. */ + title?: string } // API'den gelen route objesini, React Router için uygun hale getirir @@ -128,7 +130,7 @@ export function mapDynamicRoutes(routes: RouteDto[]): DynamicReactRoute[] { // Custom components are the single source of truth for runtime routes. export function mapCustomComponentRoutes( - components: Pick[], + components: Pick[], ): DynamicReactRoute[] { return components .filter((component) => component.isActive && component.routePath?.trim()) @@ -147,5 +149,8 @@ export function mapCustomComponentRoutes( authority: [], componentType: 'dynamic', componentPath: component.name, + // A runtime component has no module of its own to set the page title, so + // the description entered in the designer names the tab. + title: component.description?.trim() || component.name, })) } diff --git a/ui/src/routes/dynamicRouter.tsx b/ui/src/routes/dynamicRouter.tsx index 9cb0dcb3..67ea6440 100644 --- a/ui/src/routes/dynamicRouter.tsx +++ b/ui/src/routes/dynamicRouter.tsx @@ -1,6 +1,8 @@ // DynamicRouter.tsx import React from 'react' +import { Helmet } from 'react-helmet' import { Routes, Route, Navigate, useLocation } from 'react-router-dom' +import { APP_NAME } from '@/constants/app.constant' import { mapCustomComponentRoutes, mapDynamicRoutes } from './dynamicRouteLoader' import { useDynamicRoutes } from './dynamicRoutesContext' import { useComponents } from '@/contexts/ComponentContext' @@ -15,6 +17,16 @@ const AccessDenied = React.lazy(() => import('@/views/AccessDenied')) const NotFound = React.lazy(() => import('@/views/NotFound')) const DatabaseSetup = React.lazy(() => import('@/views/setup/DatabaseSetup')) +/** + * Physical views set their own title; this only fills the gap for routes whose + * component is compiled at runtime. It renders before the page, so a component + * that declares its own Helmet still wins. + */ +const RouteTitle = ({ title }: { title?: string }) => + title ? ( + + ) : null + const RootRedirect = () => { const location = useLocation() const searchParams = new URLSearchParams(location.search) @@ -53,10 +65,7 @@ export const DynamicRouter: React.FC = () => { const location = useLocation() const dynamicRoutes = React.useMemo( - () => [ - ...mapDynamicRoutes(routes), - ...mapCustomComponentRoutes(components), - ], + () => [...mapDynamicRoutes(routes), ...mapCustomComponentRoutes(components)], [routes, components], ) @@ -83,6 +92,7 @@ export const DynamicRouter: React.FC = () => { element={ + Loading {route.path}...
}> @@ -137,9 +147,12 @@ export const DynamicRouter: React.FC = () => { key={route.key} path={route.path} element={ - Loading {route.path}...
}> - - + <> + + Loading {route.path}...}> + + + } /> ) diff --git a/ui/src/utils/hooks/useLocale.ts b/ui/src/utils/hooks/useLocale.ts index 4f56e339..6f629d45 100644 --- a/ui/src/utils/hooks/useLocale.ts +++ b/ui/src/utils/hooks/useLocale.ts @@ -4,6 +4,7 @@ import utc from 'dayjs/plugin/utc' import timezone from 'dayjs/plugin/timezone' import { useStoreState } from '@/store' import { dateLocales } from '@/constants/dateLocales.constant' +import { setActiveCulture, toTwoLetterCulture } from '@/utils/localeFormat' dayjs.extend(utc) dayjs.extend(timezone) @@ -12,21 +13,26 @@ function useLocale() { const cultureName = useStoreState((state) => state.locale.currentLang) const languageList = useStoreState((state) => state.abpConfig.config?.localization.languages) const abpConfig = useStoreState((state) => state.abpConfig.config) - const twoLetterISOLanguageName = languageList?.find( - (lang) => lang.cultureName === cultureName, - )?.twoLetterISOLanguageName + // A tenant may only publish the full culture ("tr-TR"), so fall back to the + // language part rather than silently leaving dayjs on English. + const twoLetterISOLanguageName = + languageList?.find((lang) => lang.cultureName === cultureName)?.twoLetterISOLanguageName || + toTwoLetterCulture(cultureName) const timeZone = abpConfig?.timing?.timeZone?.iana?.timeZoneName ?? 'UTC' useEffect(() => { - if (cultureName && twoLetterISOLanguageName && dateLocales[twoLetterISOLanguageName]) { - dateLocales[twoLetterISOLanguageName]().then(() => { - dayjs.locale(cultureName) - dayjs.tz.setDefault(timeZone) + if (!cultureName) return + setActiveCulture(cultureName) + dayjs.tz.setDefault(timeZone) - // console.info(`🌍 Locale: ${cultureName}, TZ: ${timeZone}`) - }) - } + const loadLocale = dateLocales[twoLetterISOLanguageName] + if (!loadLocale) return + loadLocale().then(() => { + // The locale is registered under its two letter name; passing "tr-TR" here + // is an unknown locale and dayjs quietly keeps the previous one. + dayjs.locale(twoLetterISOLanguageName) + }) }, [cultureName, twoLetterISOLanguageName, timeZone]) return cultureName diff --git a/ui/src/utils/localeFormat.ts b/ui/src/utils/localeFormat.ts new file mode 100644 index 00000000..9b707842 --- /dev/null +++ b/ui/src/utils/localeFormat.ts @@ -0,0 +1,89 @@ +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import localizedFormat from 'dayjs/plugin/localizedFormat' + +// `L`/`LT` are the locale defined date and time patterns; without these plugins +// dayjs neither prints nor parses them. +dayjs.extend(localizedFormat) +dayjs.extend(customParseFormat) + +export const DEFAULT_CULTURE = 'en' + +/** Locale aware date pattern (tr → 29.07.2026, en → 07/29/2026). */ +export const LOCALE_DATE_FORMAT = 'L' + +/** Locale aware date + time pattern. */ +export const LOCALE_DATE_TIME_FORMAT = 'L LT' + +/** + * The active culture is mirrored onto `` so that code running outside + * the store — most importantly components compiled at runtime by the visual + * designer, which have no access to hooks or imports — can still format values + * for the language the user picked. + */ +export const setActiveCulture = (culture: string) => { + if (typeof document === 'undefined' || !culture) return + document.documentElement.lang = culture +} + +export const getActiveCulture = (): string => { + if (typeof document !== 'undefined' && document.documentElement.lang) { + return document.documentElement.lang + } + if (typeof navigator !== 'undefined' && navigator.language) return navigator.language + return DEFAULT_CULTURE +} + +/** `tr-TR` → `tr`; dayjs locales are registered under the two letter name. */ +export const toTwoLetterCulture = (culture?: string) => + (culture || DEFAULT_CULTURE).split('-')[0].toLowerCase() + +const ISO_DATE_TIME = + /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/ + +/** + * Integers are left untouched on purpose: a year, an identifier or a quantity + * read worse grouped (`2.026`) than plain. Only the fraction — where the locale + * genuinely differs (`1234,5` vs `1234.5`) — is localised. + */ +export const formatLocaleNumber = (value: number, culture = getActiveCulture()): string => { + if (!Number.isFinite(value)) return String(value) + if (Number.isInteger(value)) + return new Intl.NumberFormat(culture, { useGrouping: false }).format(value) + return new Intl.NumberFormat(culture, { maximumFractionDigits: 20, useGrouping: false }).format( + value, + ) +} + +export const formatLocaleDate = (value: Date, culture = getActiveCulture()): string => + dayjs(value).locale(toTwoLetterCulture(culture)).format(LOCALE_DATE_FORMAT) + +export const formatLocaleDateTime = (value: Date, culture = getActiveCulture()): string => + dayjs(value).locale(toTwoLetterCulture(culture)).format(LOCALE_DATE_TIME_FORMAT) + +/** + * Formats a raw endpoint value for display. ISO date strings and numbers follow + * the active language; everything else is returned as written, so an identifier + * or a code is never reformatted into something unrecognisable. + */ +export const formatLocaleValue = (value: unknown, culture = getActiveCulture()): string => { + if (value === null || value === undefined) return '' + if (typeof value === 'number') return formatLocaleNumber(value, culture) + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? '' : formatLocaleDateTime(value, culture) + } + if (typeof value !== 'string') return String(value) + const parts = ISO_DATE_TIME.exec(value.trim()) + if (!parts) return value + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return value + const locale = toTwoLetterCulture(culture) + // A date without a time part is a calendar day, not an instant — printing it + // through the browser timezone would move it a day for negative offsets. + if (parts[4] === undefined) { + return dayjs(new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))) + .locale(locale) + .format(LOCALE_DATE_FORMAT) + } + return dayjs(parsed).locale(locale).format(LOCALE_DATE_TIME_FORMAT) +} diff --git a/ui/src/views/developerKit/VisualComponentDesigner.tsx b/ui/src/views/developerKit/VisualComponentDesigner.tsx index f351ac9f..c8175bcf 100644 --- a/ui/src/views/developerKit/VisualComponentDesigner.tsx +++ b/ui/src/views/developerKit/VisualComponentDesigner.tsx @@ -1,6 +1,7 @@ import Editor from '@monaco-editor/react' import axios from 'axios' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Helmet } from 'react-helmet' import { Link, useParams } from 'react-router-dom' import { FaArrowLeft, @@ -31,6 +32,7 @@ import { } from 'react-icons/fa' import { useComponents } from '@/contexts/ComponentContext' import type { CrudEndpoint, CustomComponent } from '@/proxy/developerKit/models' +import { APP_NAME } from '@/constants/app.constant' import { ROUTES_ENUM } from '@/routes/route.constant' import apiService from '@/services/api.service' import { developerKitService } from '@/services/developerKit.service' @@ -50,14 +52,29 @@ import { findDesignerNode, getDesignerCollectionProperty, getDesignerValueByPath, + getSqlDataSourceEndpointId, + getSqlDataSourceKeyField, + getSqlDataSourceKeyParam, + getSqlDataSourceKeySource, + getSqlDataSourcePreviewKey, + getSqlDataSourceRecord, isDesignerOptionComponent, isDesignerTabularComponent, + isSqlDataSourceNode, resolveDesignerResponse, + toDesignerHttpMethod, walkDesignerNodes, + appendSqlDataSourceQueryParam, + bindSqlDataSourceUrl, + hasSqlDataSourceUrlParams, + DESIGNER_HTTP_METHODS, + SQL_DATA_SOURCE_KEY_SOURCES, + SQL_DATA_SOURCE_SLOTS, type DesignerComponentDefinition, type DesignerBinding, type DesignerDataSource, type DesignerDocument, + type DesignerHttpMethod, type DesignerNode, type DesignerPropertyInfo, } from '@/components/visualDesigner/types' @@ -100,6 +117,34 @@ const DATA_BINDABLE_PROPERTY_NAMES = new Set([ 'disabled', 'loading', ]) +/** + * Properties worth binding to a SqlDataSource record column, most used first. + * A component exposes dozens of props; offering every one of them turns the + * panel into noise, so only the ones that actually carry record data are listed. + */ +const SQL_RECORD_FIELD_PROPERTIES = [ + 'value', + 'checked', + 'children', + 'text', + 'label', + 'title', + 'placeholder', + 'src', + 'url', + 'href', + 'alt', + 'content', + 'description', + 'header', + 'footer', + 'defaultValue', + 'disabled', +] + +const getSqlRecordFieldOrder = (propertyName: string) => + SQL_RECORD_FIELD_PROPERTIES.indexOf(propertyName) + // Derived from DESIGNER_DATA_COMPONENTS so every toolbox `data` component is // covered by the static/endpoint panel by construction. const isOptionDataComponent = isDesignerOptionComponent @@ -559,7 +604,10 @@ const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument ...document, nodes: normalizeNodes(document.nodes), canvas: { width: document.canvas?.width || 'responsive' }, - dataSources: Array.isArray(document.dataSources) ? document.dataSources : [], + // Documents saved before non-GET sources existed have no usable method value. + dataSources: (Array.isArray(document.dataSources) ? document.dataSources : []).map( + (source) => ({ ...source, method: toDesignerHttpMethod(source.method) }), + ), } } @@ -1103,10 +1151,13 @@ const VisualComponentDesigner = () => { const [isCatalogSourceSaving, setIsCatalogSourceSaving] = useState(false) const undoStack = useRef([]) const redoStack = useRef([]) - const initialDataSourcesToTestRef = useRef>(new Set()) const component = componentDetails?.id === id ? componentDetails : undefined const name = component?.name || 'VisualComponent' + // The tab shows what is being edited: the human readable description when the + // component carries one, the technical name otherwise. + const pageTitle = + component?.description?.trim() || component?.name || translate('::App.DeveloperKit.Components') const customNames = useMemo( () => components.filter((item) => item.id !== id && item.isActive).map((item) => item.name), [components, id], @@ -1146,7 +1197,6 @@ const VisualComponentDesigner = () => { setComponentLoadError('') setLoadedId(null) setSelectedId(null) - initialDataSourcesToTestRef.current.clear() if (!id) return () => undefined @@ -1181,9 +1231,6 @@ const VisualComponentDesigner = () => { ? propsDocument : codeBackupDocument || migratedCodeDocument || propsDocument const initialDocument = visualDocument || createEmptyDesignerDocument('code') - initialDataSourcesToTestRef.current = new Set( - initialDocument.dataSources.map((source) => source.id), - ) setDocument(initialDocument) setDataTestResults({}) setDataSourceSamples({}) @@ -1242,13 +1289,117 @@ const VisualComponentDesigner = () => { [catalogByName, commitDocument, selectDesignerNode], ) + /** + * Toolbox drop next to an existing component rather than inside it — the only + * way to place something beside a node that cannot host children. + */ + const addComponentBeside = useCallback( + (definitionName: string, targetId: string, placement: 'before' | 'after') => { + const definition = catalogByName.get(definitionName) + if (!definition) return + const node = definitionToNode(definition) + 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. + const target = findDesignerNode(current.nodes, targetId) + if (target?.slot) node.slot = target.slot + return { + ...current, + sourceMode: 'visual', + nodes: insertRelativeToNode(current.nodes, targetId, node, placement), + } + }) + selectDesignerNode(node.id) + setWorkspaceTab('design') + }, + [catalogByName, commitDocument, selectDesignerNode], + ) + const selectedNode = useMemo( () => findDesignerNode(document.nodes, selectedId), [document.nodes, selectedId], ) const selectedDefinition = selectedNode ? catalogByName.get(selectedNode.type) : undefined + /** + * Record of every SqlDataSource on the canvas, derived from the tested Select + * endpoint. Children bind to it through the container's node id, so it is + * merged into the canvas data values and into the binding inspector samples. + */ + const sqlDataSourceRecords = useMemo(() => { + const records: Record = {} + walkDesignerNodes(document.nodes, (node) => { + if (isSqlDataSourceNode(node.type)) { + records[node.id] = getSqlDataSourceRecord(node, dataSourceSamples) + } + }) + return records + }, [dataSourceSamples, document.nodes]) + const previewDataValues = useMemo( + () => ({ ...dataSourceSamples, ...sqlDataSourceRecords }), + [dataSourceSamples, sqlDataSourceRecords], + ) + const selectedAncestors = useMemo( + () => findDesignerAncestors(document.nodes, selectedId) || [], + [document.nodes, selectedId], + ) + const selectedIsSqlDataSource = isSqlDataSourceNode(selectedNode?.type) + /** Nearest SqlDataSource above the selection; its record is bindable. */ + const sqlScopeNode = useMemo( + () => [...selectedAncestors].reverse().find((node) => isSqlDataSourceNode(node.type)), + [selectedAncestors], + ) + /** + * The container's record presented as a data source, so the existing endpoint + * dropdown, field discovery and binding editors work on it unchanged. + */ + const sqlScopeSource = useMemo( + () => + sqlScopeNode + ? { + id: sqlScopeNode.id, + name: `SqlDataSource kaydı · ${getSqlDataSourceKeyField(sqlScopeNode)}`, + method: 'GET', + url: '', + responsePath: '', + } + : null, + [sqlScopeNode], + ) + /** + * Only a GET endpoint returns rows, so only GET can feed a property binding — + * and only one that is directly callable: a `{id}` URL has no key to fill it + * with outside a SqlDataSource, so it cannot back a Grid or a Select list. + */ + const bindableDataSources = useMemo( + () => + document.dataSources.filter( + (source) => source.method === 'GET' && !hasSqlDataSourceUrlParams(source.url), + ), + [document.dataSources], + ) + /** The Select slot additionally accepts GetById, whose key comes from the URL. */ + const selectSlotDataSources = useMemo( + () => document.dataSources.filter((source) => source.method === 'GET'), + [document.dataSources], + ) + /** + * A collection component keeps managing its own list endpoint even inside a + * SqlDataSource — the option list of a Select and the record the form edits are + * two different things. Everything else inside the container is a record field + * and may only bind to the container's Select result. + */ + const selectedIsCollectionComponent = + isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type) + const sqlScopeLocked = Boolean(sqlScopeNode) && !selectedIsCollectionComponent + const inspectorDataSources = useMemo( + () => (sqlScopeLocked ? (sqlScopeSource ? [sqlScopeSource] : []) : bindableDataSources), + [bindableDataSources, sqlScopeLocked, sqlScopeSource], + ) const selectedIsDataComponent = Boolean( - selectedNode && DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type), + selectedNode && + (DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type) || + selectedIsSqlDataSource || + sqlScopeNode), ) const selectedProperties = useMemo(() => { if (!selectedNode) return [] @@ -1283,17 +1434,20 @@ const VisualComponentDesigner = () => { }, [selectedDefinition, selectedNode]) const selectedBindingSourceId = selectedNode ? Object.values(selectedNode.bindings || {}).find((binding) => - document.dataSources.some((source) => source.id === binding.sourceId), + inspectorDataSources.some((source) => source.id === binding.sourceId), )?.sourceId : undefined useEffect(() => { setDataPanelSourceId((current) => { if (selectedBindingSourceId) return selectedBindingSourceId - if (document.dataSources.some((source) => source.id === current)) return current - return document.dataSources[0]?.id || '' + if (inspectorDataSources.some((source) => source.id === current)) return current + // Must fall back within the selectable set: a default the picker does not + // list leaves `activeDataSource` undefined while the dropdown still paints + // its first option, so the panel looks configured when nothing is selected. + return inspectorDataSources[0]?.id || '' }) - }, [document.dataSources, selectedBindingSourceId]) + }, [inspectorDataSources, selectedBindingSourceId]) const dataSourceCatalog = useMemo(() => { const items = new Map() @@ -1325,15 +1479,15 @@ const VisualComponentDesigner = () => { }) generatedEndpoints - .filter( - (endpoint) => - endpoint.isActive && endpoint.method === 'GET' && !endpoint.path.includes('{'), - ) + // Every active endpoint is offered, including GetById: its `{id}` is filled + // by the SqlDataSource from the page URL, exactly like Update and Delete. + .filter((endpoint) => endpoint.isActive) .forEach((endpoint) => { - const dataSourceKey = `GET:${endpoint.path.trim()}:` + const method = toDesignerHttpMethod(endpoint.method) + const dataSourceKey = `${method}:${endpoint.path.trim()}:` if (items.has(dataSourceKey)) return const attached = document.dataSources.some( - (source) => source.method === 'GET' && source.url.trim() === endpoint.path.trim(), + (source) => source.method === method && source.url.trim() === endpoint.path.trim(), ) items.set(dataSourceKey, { attached, @@ -1343,7 +1497,7 @@ const VisualComponentDesigner = () => { source: { id: `generated_${endpoint.id}`, name: `${endpoint.entityName} · ${endpoint.operationType}`, - method: 'GET', + method, url: endpoint.path, responsePath: '', }, @@ -1441,7 +1595,15 @@ const VisualComponentDesigner = () => { source.url.trim() === catalogItem.source.url.trim() && source.responsePath.trim() === catalogItem.source.responsePath.trim(), ) + // Only a directly callable GET source produces rows. A write endpoint or a + // GetById is attached to a SqlDataSource command slot from the inspector. + const bindsCollection = + catalogItem.source.method === 'GET' && !hasSqlDataSourceUrlParams(catalogItem.source.url) if (existingSource) { + if (!bindsCollection) { + setDataPanelSourceId(existingSource.id) + return + } if (selectedId && isTabularDataComponent(selectedNode?.type)) { updateSelectedBinding('items', existingSource.id, '') setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' })) @@ -1461,6 +1623,7 @@ const VisualComponentDesigner = () => { ...current, dataSources: [...current.dataSources, dataSource], nodes: + bindsCollection && selectedId && (isTabularDataComponent(selectedNode?.type) || isOptionDataComponent(selectedNode?.type)) ? updateNodeTree(current.nodes, selectedId, (node) => ({ @@ -1478,6 +1641,7 @@ const VisualComponentDesigner = () => { : current.nodes, })) if ( + bindsCollection && selectedId && (isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type)) ) { @@ -1486,47 +1650,73 @@ const VisualComponentDesigner = () => { setDataPanelSourceId(dataSource.id) } - const testDataSource = useCallback(async (source: DesignerDataSource, showResult = false) => { - if (showResult) setEndpointResultModal(null) - setDataTestResults((current) => ({ - ...current, - [source.id]: { status: 'loading', message: 'İstek gönderiliyor…' }, - })) - try { - if (!source.url.trim()) { - throw new Error('Endpoint URL alanını doldurun.') + /** + * `urlOverride` carries a URL whose `{id}` was already filled in — a GetById + * endpoint cannot be sampled otherwise, and without a sample the designer has + * no columns to offer the components inside the SqlDataSource. + */ + const testDataSource = useCallback( + async (source: DesignerDataSource, showResult = false, urlOverride?: string) => { + // Running a POST/PUT/DELETE endpoint would mutate real data, so write sources + // are never executed from the designer — they are only wired to a command slot. + if (source.method !== 'GET') { + setDataTestResults((current) => ({ + ...current, + [source.id]: { + status: 'error', + message: `${source.method} endpointleri tasarım ekranından çalıştırılmaz; SqlDataSource komutuna bağlayın.`, + }, + })) + return } - if (!source.url.trim().startsWith('/api/')) { - throw new Error('Platform endpoint adresi /api/ ile başlamalıdır.') - } - if (!isRunnableDataSourceUrl(source.url)) { - throw new Error('Çağrılabilir bir endpoint URL girin.') - } - const response = await apiService.fetchData({ method: source.method, url: source.url.trim() }) - const result = resolveDesignerResponse(response.data, source.responsePath) - if (source.responsePath.trim() && result === undefined) { - throw new Error(`Response path bulunamadı: ${source.responsePath}`) - } - const message = - typeof result === 'string' ? result : (JSON.stringify(result, null, 2) ?? String(result)) + if (showResult) setEndpointResultModal(null) setDataTestResults((current) => ({ ...current, - [source.id]: { status: 'success', message }, + [source.id]: { status: 'loading', message: 'İstek gönderiliyor…' }, })) - setDataSourceSamples((current) => ({ ...current, [source.id]: result })) - if (showResult) setEndpointResultModal({ source, result }) - } catch (error) { - setDataSourceSamples((current) => { - const next = { ...current } - delete next[source.id] - return next - }) - setDataTestResults((current) => ({ - ...current, - [source.id]: { status: 'error', message: getSaveErrorMessage(error) }, - })) - } - }, []) + const requestUrl = (urlOverride ?? source.url).trim() + try { + if (!requestUrl) { + throw new Error('Endpoint URL alanını doldurun.') + } + if (!requestUrl.startsWith('/api/')) { + throw new Error('Platform endpoint adresi /api/ ile başlamalıdır.') + } + if (!isRunnableDataSourceUrl(requestUrl)) { + throw new Error('Çağrılabilir bir endpoint URL girin.') + } + if (hasSqlDataSourceUrlParams(requestUrl)) { + throw new Error( + 'Bu endpoint bir key parametresi bekliyor. SqlDataSource üzerinde önizleme key değeri girin.', + ) + } + const response = await apiService.fetchData({ method: source.method, url: requestUrl }) + const result = resolveDesignerResponse(response.data, source.responsePath) + if (source.responsePath.trim() && result === undefined) { + throw new Error(`Response path bulunamadı: ${source.responsePath}`) + } + const message = + typeof result === 'string' ? result : (JSON.stringify(result, null, 2) ?? String(result)) + setDataTestResults((current) => ({ + ...current, + [source.id]: { status: 'success', message }, + })) + setDataSourceSamples((current) => ({ ...current, [source.id]: result })) + if (showResult) setEndpointResultModal({ source, result }) + } catch (error) { + setDataSourceSamples((current) => { + const next = { ...current } + delete next[source.id] + return next + }) + setDataTestResults((current) => ({ + ...current, + [source.id]: { status: 'error', message: getSaveErrorMessage(error) }, + })) + } + }, + [], + ) const persistCatalogOwnerDocument = useCallback( async (item: DataSourceCatalogItem, nextDocument: DesignerDocument) => { @@ -1592,6 +1782,13 @@ const VisualComponentDesigner = () => { const testCatalogSource = async () => { if (!catalogSourceEditor) return const source = catalogSourceEditor.draft + if (source.method !== 'GET') { + setCatalogSourceTestResult({ + status: 'error', + message: `${source.method} endpointleri veriyi değiştirdiği için tasarım ekranından çalıştırılmaz.`, + }) + return + } setCatalogSourceTestResult({ status: 'loading', message: 'İstek gönderiliyor…' }) try { if (!source.name.trim()) throw new Error('Endpoint adı boş olamaz.') @@ -1634,7 +1831,9 @@ const VisualComponentDesigner = () => { }) return } - if (catalogSourceTestResult?.status !== 'success') { + // A write endpoint cannot be verified without side effects, so the successful + // run is only demanded from GET sources. + if (draft.method === 'GET' && catalogSourceTestResult?.status !== 'success') { setCatalogSourceTestResult({ status: 'error', message: 'Kaydetmeden önce endpointi başarıyla çalıştırın.', @@ -1786,16 +1985,6 @@ const VisualComponentDesigner = () => { } } - useEffect(() => { - if (!id || loadedId !== id) return - document.dataSources.forEach((source) => { - if (!initialDataSourcesToTestRef.current.has(source.id)) return - initialDataSourcesToTestRef.current.delete(source.id) - if (!isRunnableDataSourceUrl(source.url)) return - void testDataSource(source) - }) - }, [document.dataSources, id, loadedId, testDataSource]) - const deleteNode = useCallback( (nodeId: string) => { commitDocument((current) => ({ ...current, nodes: removeNodeTree(current.nodes, nodeId) })) @@ -1981,8 +2170,56 @@ const VisualComponentDesigner = () => { ).filter(([, definitions]) => definitions.length > 0) }, [filteredCatalog]) - const activeDataSource = document.dataSources.find((source) => source.id === dataPanelSourceId) - const activeDataSample = activeDataSource ? dataSourceSamples[activeDataSource.id] : undefined + const activeDataSource = inspectorDataSources.find((source) => source.id === dataPanelSourceId) + const activeDataSample = activeDataSource ? previewDataValues[activeDataSource.id] : undefined + + /** + * Reads every GET endpoint that has no sample yet: the ones stored with the + * document, and any attached later from the reusable catalog. Without this a + * freshly attached endpoint shows up as selected in the inspector while its + * column list — and therefore the label/value mapping — stays empty. + * A recorded test result means it already ran, so failures do not loop. + */ + useEffect(() => { + if (!id || loadedId !== id) return + // A GetById Select is sampled through the owning SqlDataSource's preview key, + // since there is no page URL to read the real key from while designing. + const previewUrls = new Map() + walkDesignerNodes(document.nodes, (node) => { + if (!isSqlDataSourceNode(node.type)) return + const previewKey = getSqlDataSourcePreviewKey(node) + if (!previewKey) return + const source = document.dataSources.find( + (item) => item.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'), + ) + if (!source) return + const parameterName = getSqlDataSourceKeyParam(node) + const bound = bindSqlDataSourceUrl(source.url, { [parameterName]: previewKey }, parameterName) + previewUrls.set( + source.id, + bound.keyBound + ? bound.url + : appendSqlDataSourceQueryParam(bound.url, parameterName, previewKey), + ) + }) + + document.dataSources.forEach((source) => { + if (source.method !== 'GET') return + if (dataSourceSamples[source.id] !== undefined || dataTestResults[source.id]) return + const requestUrl = previewUrls.get(source.id) ?? source.url + // An unresolved `{id}` would just 404; it waits for a preview key instead. + if (hasSqlDataSourceUrlParams(requestUrl) || !isRunnableDataSourceUrl(requestUrl)) return + void testDataSource(source, false, requestUrl) + }) + }, [ + dataSourceSamples, + dataTestResults, + document.dataSources, + document.nodes, + id, + loadedId, + testDataSource, + ]) const optionDataProperty = getOptionDataProperty(selectedNode?.type) const selectOptionsBinding = isOptionDataComponent(selectedNode?.type) ? selectedNode?.bindings?.[optionDataProperty] @@ -2050,7 +2287,9 @@ const VisualComponentDesigner = () => { return setSelectDataModes((current) => ({ ...current, [selectedId]: mode })) if (isTabularDataComponent(selectedNode?.type)) { - const source = activeDataSource || document.dataSources[0] + // Only a source the picker lists can be bound; a POST/PUT entry or the + // SqlDataSource record would silently produce an unusable collection. + const source = activeDataSource || inspectorDataSources[0] commitDocument((current) => ({ ...current, nodes: updateNodeTree(current.nodes, selectedId, (node) => { @@ -2081,7 +2320,7 @@ const VisualComponentDesigner = () => { updateSelectedBinding(optionDataProperty, '') return } - const source = activeDataSource || document.dataSources[0] + const source = activeDataSource || inspectorDataSources[0] if (source) { updateSelectedBinding(optionDataProperty, source.id, '') if (dataSourceSamples[source.id] === undefined) void testDataSource(source) @@ -2126,10 +2365,6 @@ const VisualComponentDesigner = () => { })) } } - const selectedAncestors = useMemo( - () => findDesignerAncestors(document.nodes, selectedId) || [], - [document.nodes, selectedId], - ) const repeatedGridAncestor = [...selectedAncestors] .reverse() .find((node) => node.type === 'Grid' && node.bindings?.items?.sourceId) @@ -2236,7 +2471,9 @@ const VisualComponentDesigner = () => { }, [activeDataSample, activeDataSource?.id, commitDocument, selectedId, selectedNode]) const dataBindableProperties = - selectedNode && selectedDefinition && DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type) + selectedNode && + selectedDefinition && + (DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type) || Boolean(sqlScopeNode)) ? selectedDefinition.properties.filter( (property) => property.category !== 'events' && @@ -2245,11 +2482,19 @@ const VisualComponentDesigner = () => { ? property.name === 'items' : isOptionDataComponent(selectedNode.type) ? property.name === getOptionDataProperty(selectedNode.type) - : DATA_BINDABLE_PROPERTY_NAMES.has(property.name) || - property.type === 'array' || - ['number', 'boolean', 'string', 'select'].includes(property.type)), + : sqlScopeLocked + ? // A record field only needs the props that carry data. + getSqlRecordFieldOrder(property.name) >= 0 + : DATA_BINDABLE_PROPERTY_NAMES.has(property.name) || + property.type === 'array' || + ['number', 'boolean', 'string', 'select'].includes(property.type)), ) : [] + const orderedBindableProperties = sqlScopeLocked + ? [...dataBindableProperties].sort( + (left, right) => getSqlRecordFieldOrder(left.name) - getSqlRecordFieldOrder(right.name), + ) + : dataBindableProperties const isCollectionProperty = (propertyName: string, propertyType: string) => propertyType === 'array' || ['items', 'data', 'dataSource', 'options'].includes(propertyName) @@ -2340,7 +2585,7 @@ const VisualComponentDesigner = () => { )) ) : (

- Bu componente eklenebilecek başka bir GET endpointi bulunmuyor. + Bu componente eklenebilecek başka bir endpoint bulunmuyor.

)} {endpointCatalogError && ( @@ -2618,6 +2863,291 @@ const VisualComponentDesigner = () => { ) } + /** + * SqlDataSource command panel: the ASP.NET style Select/Insert/Update/Delete + * slots. Each slot only accepts a data source declared with the matching HTTP + * method, which is what enables the Save/Delete buttons at runtime. + */ + const renderSqlDataSourceConfiguration = () => { + if (!selectedNode || !selectedIsSqlDataSource) return null + const selectSourceId = String(selectedNode.props.selectEndpoint || '') + const selectSample = selectSourceId ? dataSourceSamples[selectSourceId] : undefined + const collectionPaths = selectSample + ? [ + '', + ...discoverDataFields(selectSample) + .filter((field) => field.type === 'array') + .map((field) => field.path), + ] + : [''] + const record = sqlDataSourceRecords[selectedNode.id] + const recordFields = record === undefined ? [] : discoverDataFields(record) + const selectSource = document.dataSources.find((source) => source.id === selectSourceId) + // A GetById URL cannot be sampled at design time without a stand-in key. + const selectNeedsPreviewKey = Boolean( + selectSource && hasSqlDataSourceUrlParams(selectSource.url), + ) + + return ( +
+
+ Endpointleri Data çalışma alanında tanımlayın, burada komut yuvalarına + bağlayın. İçine bıraktığınız komponentleri seçip Data sekmesinden{' '} + SqlDataSource kaydı kaynağını seçerek sütuna bağlayabilirsiniz. +
+ {SQL_DATA_SOURCE_SLOTS.map((slot) => { + const options = + slot.method === 'GET' + ? selectSlotDataSources + : document.dataSources.filter((source) => source.method === slot.method) + const currentValue = String(selectedNode.props[slot.property] || '') + return ( + + ) + })} +
+ + +
+
+
+ Select key parametresi +
+

+ Key değeri sayfa URL’sinden okunur ve endpoint adresindeki {'{id}'} yerine + yazılır; yer tutucu yoksa query string olarak eklenir. Bir key bulunduğunda liste sonucu + da bu değere göre tek kayda indirgenir. {'{id}'} içeren bir GET + seçildiğinde query string otomatik kullanılır. +

+ + updateSelectedProp('keyParamName', event.target.value)} + /> +
+ {selectNeedsPreviewKey && ( +
+
+ Önizleme key değeri +
+

+ Seçilen GET endpointi bir key bekliyor. Tasarım ekranında sütunları okuyabilmek için + örnek bir değer girin; çalışma zamanında bu değer yerine sayfa URL’sindeki key + kullanılır. +

+ updateSelectedProp('previewKeyValue', event.target.value)} + /> +
+ )} +
+ {( + [ + ['autoLoad', 'Açılışta Select endpointini çağır', true], + ['showToolbar', 'Yeni / Kaydet / Sil / Yenile butonlarını göster', true], + ] as const + ).map(([property, label, defaultChecked]) => ( + + ))} +
+
+
+ Kayıt sütunları +
+ {recordFields.length ? ( +
+ {recordFields.map((field) => ( +
+ + {field.path} + + {field.type} +
+ ))} +
+ ) : ( +

+ Select endpointini seçip çalıştırdığınızda sütunlar burada listelenir. +

+ )} +
+
+ ) + } + + /** + * Banner shown for anything inside a SqlDataSource: it states which record the + * component is bound to and, when the container has no Select endpoint yet, + * why no column can be picked. + */ + const renderSqlScopeNotice = () => { + if (!sqlScopeNode) return null + const hasSelect = Boolean(getSqlDataSourceEndpointId(sqlScopeNode, 'selectEndpoint')) + const record = sqlDataSourceRecords[sqlScopeNode.id] + const hasColumns = record !== undefined && discoverDataFields(record).length > 0 + + if (!hasSelect) { + return ( +
+ Bu komponent bir SqlDataSource içinde. Sütunlara bağlanabilmesi için önce + SqlDataSource’u seçip Data sekmesinden Select (GET) endpointini + tanımlayın. +
+ ) + } + return ( +
+ Bu komponent SqlDataSource kaydına bağlıdır; alanlar Select (GET) + cevabındaki sütunlardan gelir. + {!hasColumns && ' Sütunları görmek için Select endpointini bir kez çalıştırın.'} +
+ ) + } + + /** + * Value binding for an option component inside a SqlDataSource. Its option list + * still comes from its own GET endpoint above; this only says which record + * column the selection reads from and writes back to. + */ + const renderSqlRecordValueBinding = () => { + if (!sqlScopeNode || !selectedNode) return null + if (!['AutoComplete', 'Radio.Group', 'Select'].includes(selectedNode.type)) return null + const record = sqlDataSourceRecords[sqlScopeNode.id] + const columns = + record === undefined + ? [] + : discoverDataFields(record).filter((field) => !['array', 'object'].includes(field.type)) + const binding = selectedNode.bindings?.value + const current = binding?.sourceId === sqlScopeNode.id ? binding.path : '' + + return ( +
+
+ Kayıt alanı (value) +
+

+ Seçimin okunacağı ve kaydedileceği SqlDataSource sütunu. +

+ + {!columns.length && ( +

+ SqlDataSource Select endpointini tanımlayıp çalıştırın. +

+ )} +
+ ) + } + const renderDataInspector = () => { if (selectedNode?.kind === 'platform') { return ( @@ -2628,28 +3158,30 @@ const VisualComponentDesigner = () => { ) } + if (selectedIsSqlDataSource) return renderSqlDataSourceConfiguration() + const scopeNotice = renderSqlScopeNotice() const selectModeSelector = renderSelectDataModeSelector() const selectConfiguration = renderSelectDataConfiguration() - if ( - (isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type)) && - selectDataMode === 'static' - ) { + if (selectedIsCollectionComponent && selectDataMode === 'static') { return (
+ {scopeNotice} {selectModeSelector} {isOptionDataComponent(selectedNode?.type) && selectConfiguration} {isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()} {isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()} + {renderSqlRecordValueBinding()}
) } - if (!document.dataSources.length) { + if (!inspectorDataSources.length) { return (
+ {scopeNotice} {selectModeSelector}
- Bu componentte seçilebilecek tanımlı bir endpoint bulunmuyor. Endpointleri ana{' '} + Bu componentte seçilebilecek tanımlı bir GET endpointi bulunmuyor. Endpointleri ana{' '} Data çalışma alanından tanımlayabilirsiniz.
@@ -2658,55 +3190,71 @@ const VisualComponentDesigner = () => { return (
+ {scopeNotice} {selectModeSelector} + {/* Inside a SqlDataSource a record field has exactly one legal source, so + the picker is replaced by a read-only statement of that source. */}
@@ -2814,12 +3362,12 @@ const VisualComponentDesigner = () => {
Property bağlantıları
- {!dataBindableProperties.length ? ( + {!orderedBindableProperties.length ? (

Bu komponent endpoint verisi alabilecek bir property tanımlamıyor.

) : ( - dataBindableProperties.map((property) => { + orderedBindableProperties.map((property) => { const choice = getBindingChoice(property.name) const collection = isCollectionProperty(property.name, property.type) const selectableFields = collection @@ -2829,6 +3377,13 @@ const VisualComponentDesigner = () => { const currentBindingSource = document.dataSources.find( (source) => source.id === currentBinding?.sourceId, ) + // A binding saved before the SqlDataSource lock existed can still + // point at an unrelated endpoint; the dropdown cannot show it, so + // it is called out explicitly instead of failing silently. + const foreignBinding = + sqlScopeLocked && + Boolean(currentBinding?.sourceId) && + currentBinding?.sourceId !== sqlScopeSource?.id const knownChoice = choice === '' || choice === '__root__' || @@ -2882,18 +3437,34 @@ const VisualComponentDesigner = () => { ))} - {currentBinding?.sourceId && ( + {currentBinding?.sourceId && !foreignBinding && (
{currentBindingSource?.name || currentBinding.sourceId}:{' '} {currentBinding.path || '(root)'}
)} + {foreignBinding && ( +
+ + {currentBindingSource?.name || currentBinding?.sourceId}:{' '} + {currentBinding?.path || '(root)'} + + +
+ )} ) }) )}
)} + {renderSqlRecordValueBinding()} ) } @@ -2915,9 +3486,14 @@ const VisualComponentDesigner = () => { )) + const pageHelmet = ( + + ) + if (!component) { return (
+ {pageHelmet} {componentLoadError || 'Bileşen yükleniyor…'}
) @@ -2928,11 +3504,12 @@ const VisualComponentDesigner = () => { interactive nodes={document.nodes} selectedId={selectedId} - dataValues={dataSourceSamples} + dataValues={previewDataValues} renderCustomComponent={(componentName, props) => renderComponent(componentName, props)} onSelect={(nodeId) => selectDesignerNode(nodeId || null)} onNodePropChange={updateNodeProp} onDropComponent={addComponent} + onDropComponentBeside={addComponentBeside} onMoveIntoContainer={moveNodeIntoContainerNode} onMove={moveNode} onReorder={reorderNode} @@ -2974,6 +3551,7 @@ const VisualComponentDesigner = () => { return (
+ {pageHelmet}
{
+
+ GET kaynakları sayfa açılışında okunur; POST/PUT/DELETE kaynakları yalnızca bir{' '} + SqlDataSource komut yuvasına bağlandığında çalışır ve tasarım + ekranından tetiklenmez. +
Endpoint ayarları component ile birlikte kaydedilir. Tekrarlı kartlar için Grid items alanını endpoint’e boş path ile bağlayın; Grid @@ -3504,6 +4087,23 @@ const VisualComponentDesigner = () => { !( isTabularDataComponent(selectedNode.type) && ['items', 'data', 'columns', 'dataColumns'].includes(property.name) + ) && + // Command slots and the key/collection settings are + // owned by the SqlDataSource panel in the Data tab. + !( + selectedIsSqlDataSource && + [ + ...SQL_DATA_SOURCE_SLOTS.map((slot) => slot.property), + 'keyFieldName', + 'collectionPath', + 'keySource', + 'keyParamName', + 'previewKeyValue', + 'autoLoad', + 'showToolbar', + 'showNavigation', + 'filterByUrlKey', + ].includes(property.name) ), ) .map((property) => { @@ -3594,7 +4194,7 @@ const VisualComponentDesigner = () => {

{catalogSourceEditor.item?.origin || - 'Bu componente yeni bir GET kaynağı ekleyin.'} + 'Bu componente yeni bir GET / POST / PUT / DELETE kaynağı ekleyin.'}