Claude güncellemesi SqlDataSource komponenti ve Localization

This commit is contained in:
Sedat ÖZTÜRK 2026-08-07 12:32:42 +03:00
parent 0224f4b3e1
commit 81960df31b
19 changed files with 2996 additions and 1054 deletions

View file

@ -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<IActionResult> PutAsync()
{
return await Execute("PUT");
}
[HttpDelete("{**path}")]
[Authorize(PlatformConsts.AppCodes.DeveloperKits.Remove)]
public async Task<IActionResult> DeleteAsync()
{
return await Execute("DELETE");
}
private async Task<IActionResult> 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
};
}
}
/// <summary>
/// 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.
/// </summary>
private async Task<Dictionary<string, object>> ReadBodyValuesAsync()
{
var request = httpContextAccessor.HttpContext.Request;
var values = new Dictionary<string, object>(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

View file

@ -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",

View file

@ -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,

View file

@ -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",

View file

@ -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";

View file

@ -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 `<html lang>`.
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) => {
</div>
<div className="text-center">
<Logo mode={isDarkMode ? 'dark' : 'light'} type="streamline" imgClass="mx-auto" url={ROUTES_ENUM.authenticated.login} />
<Logo
mode={isDarkMode ? 'dark' : 'light'}
type="streamline"
imgClass="mx-auto"
url={ROUTES_ENUM.authenticated.login}
/>
</div>
<div className="text-center">

View file

@ -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) => {
</div>
)
// Only the store is touched here: `useLocale` watches it and owns loading the
// dayjs locale, the timezone and `<html lang>` 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 (

View file

@ -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<HTMLInputElement, DatePickerProps>(
(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<HTMLInputElement, DatePickerProps>((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<HTMLInputElement>(null)
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement, Element>
) => {
typeof onBlur === 'function' && onBlur(event)
setFocused(false)
if (inputtable) {
setDateFromInput()
}
}
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && inputtable) {
closeDropdown()
setDateFromInput()
}
}
const handleInputFocus = (
event: FocusEvent<HTMLInputElement, Element>
) => {
typeof onFocus === 'function' && onFocus(event)
setFocused(true)
}
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
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 (
<BasePicker
ref={useMergedRef(ref, inputRef)}
inputtable={inputtable}
dropdownOpened={dropdownOpened as boolean}
setDropdownOpened={setDropdownOpened}
size={size}
style={style}
className={className}
name={name}
inputLabel={inputState}
clearable={
type === 'date' ? false : clearable && !!_value && !disabled
}
clearButton={clearButton}
disabled={disabled}
type={type}
inputPrefix={inputPrefix}
inputSuffix={inputSuffix}
onChange={handleChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onKeyDown={handleKeyDown}
onClear={handleClear}
onDropdownClose={onDropdownClose}
onDropdownOpen={onDropdownOpen}
{...rest}
>
<Calendar
locale={finalLocale}
month={inputtable ? calendarMonth : undefined}
defaultMonth={
defaultMonth ||
(_value instanceof Date ? _value : new Date())
}
value={
_value instanceof Date
? _value
: _value && dayjs(_value).toDate()
}
labelFormat={labelFormat}
dayClassName={dayClassName}
dayStyle={dayStyle}
disableOutOfMonth={disableOutOfMonth}
minDate={minDate}
maxDate={maxDate}
disableDate={disableDate}
firstDayOfWeek={firstDayOfWeek}
preventFocus={inputtable}
dateViewCount={dateViewCount}
enableHeaderLabel={enableHeaderLabel}
defaultView={defaultView}
hideOutOfMonthDates={hideOutOfMonthDates}
hideWeekdays={hideWeekdays}
renderDay={renderDay}
weekendDays={weekendDays}
yearLabelFormat={yearLabelFormat}
onMonthChange={setCalendarMonth}
onChange={handleValueChange}
/>
</BasePicker>
)
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<HTMLInputElement, Element>) => {
typeof onBlur === 'function' && onBlur(event)
setFocused(false)
if (inputtable) {
setDateFromInput()
}
}
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && inputtable) {
closeDropdown()
setDateFromInput()
}
}
const handleInputFocus = (event: FocusEvent<HTMLInputElement, Element>) => {
typeof onFocus === 'function' && onFocus(event)
setFocused(true)
}
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
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 (
<BasePicker
ref={useMergedRef(ref, inputRef)}
inputtable={inputtable}
dropdownOpened={dropdownOpened as boolean}
setDropdownOpened={setDropdownOpened}
size={size}
style={style}
className={className}
name={name}
inputLabel={inputState}
clearable={type === 'date' ? false : clearable && !!_value && !disabled}
clearButton={clearButton}
disabled={disabled}
type={type}
inputPrefix={inputPrefix}
inputSuffix={inputSuffix}
onChange={handleChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onKeyDown={handleKeyDown}
onClear={handleClear}
onDropdownClose={onDropdownClose}
onDropdownOpen={onDropdownOpen}
{...rest}
>
<Calendar
locale={finalLocale}
month={inputtable ? calendarMonth : undefined}
defaultMonth={defaultMonth || (_value instanceof Date ? _value : new Date())}
value={_value instanceof Date ? _value : _value && dayjs(_value).toDate()}
labelFormat={labelFormat}
dayClassName={dayClassName}
dayStyle={dayStyle}
disableOutOfMonth={disableOutOfMonth}
minDate={minDate}
maxDate={maxDate}
disableDate={disableDate}
firstDayOfWeek={firstDayOfWeek}
preventFocus={inputtable}
dateViewCount={dateViewCount}
enableHeaderLabel={enableHeaderLabel}
defaultView={defaultView}
hideOutOfMonthDates={hideOutOfMonthDates}
hideWeekdays={hideWeekdays}
renderDay={renderDay}
weekendDays={weekendDays}
yearLabelFormat={yearLabelFormat}
onMonthChange={setCalendarMonth}
onChange={handleValueChange}
/>
</BasePicker>
)
})
DatePicker.displayName = 'DatePicker'

View file

@ -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<HTMLInputElement, DatePickerRangeProps>(
(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<HTMLInputElement, DatePickerRangeProps>((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<HTMLInputElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<BasePicker
ref={useMergedRef(ref, inputRef)}
dropdownOpened={dropdownOpened as boolean}
setDropdownOpened={handleDropdownToggle}
size={size}
style={style}
className={className}
inputLabel={
firstValueValid
? `${firstDateLabel} ${separator} ${secondDateLabel}`
: ''
}
clearable={clearable && firstValueValid}
clearButton={clearButton}
disabled={disabled}
inputPrefix={inputPrefix}
inputSuffix={inputSuffix}
onClear={handleClear}
onDropdownClose={onDropdownClose}
onDropdownOpen={onDropdownOpen}
{...rest}
>
<RangeCalendar
locale={finalLocale}
defaultMonth={
(valueValid ? _value?.[0] : defaultMonth) as Date
}
value={_value as [Date | null, Date | null]}
labelFormat={labelFormat}
dayClassName={dayClassName}
dayStyle={dayStyle}
disableOutOfMonth={disableOutOfMonth}
minDate={minDate}
maxDate={maxDate}
disableDate={disableDate}
firstDayOfWeek={firstDayOfWeek}
enableHeaderLabel={enableHeaderLabel}
singleDate={singleDate}
dateViewCount={dateViewCount}
defaultView={defaultView}
hideOutOfMonthDates={hideOutOfMonthDates}
hideWeekdays={hideWeekdays}
renderDay={renderDay}
weekendDays={weekendDays}
yearLabelFormat={yearLabelFormat}
onChange={(date) => handleValueChange(date as [Date, Date])}
/>
</BasePicker>
)
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 (
<BasePicker
ref={useMergedRef(ref, inputRef)}
dropdownOpened={dropdownOpened as boolean}
setDropdownOpened={handleDropdownToggle}
size={size}
style={style}
className={className}
inputLabel={firstValueValid ? `${firstDateLabel} ${separator} ${secondDateLabel}` : ''}
clearable={clearable && firstValueValid}
clearButton={clearButton}
disabled={disabled}
inputPrefix={inputPrefix}
inputSuffix={inputSuffix}
onClear={handleClear}
onDropdownClose={onDropdownClose}
onDropdownOpen={onDropdownOpen}
{...rest}
>
<RangeCalendar
locale={finalLocale}
defaultMonth={(valueValid ? _value?.[0] : defaultMonth) as Date}
value={_value as [Date | null, Date | null]}
labelFormat={labelFormat}
dayClassName={dayClassName}
dayStyle={dayStyle}
disableOutOfMonth={disableOutOfMonth}
minDate={minDate}
maxDate={maxDate}
disableDate={disableDate}
firstDayOfWeek={firstDayOfWeek}
enableHeaderLabel={enableHeaderLabel}
singleDate={singleDate}
dateViewCount={dateViewCount}
defaultView={defaultView}
hideOutOfMonthDates={hideOutOfMonthDates}
hideWeekdays={hideWeekdays}
renderDay={renderDay}
weekendDays={weekendDays}
yearLabelFormat={yearLabelFormat}
onChange={(date) => handleValueChange(date as [Date, Date])}
/>
</BasePicker>
)
})
DatePickerRange.displayName = 'DatePickerRange'

View file

@ -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<HTMLInputElement, DateTimepickerProps>((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<HTMLInputElement, DateTimepickerProps>(
(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<HTMLInputElement>(null)
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement, Element>
) => {
typeof onBlur === 'function' && onBlur(event)
setFocused(false)
}
const handleInputFocus = (
event: FocusEvent<HTMLInputElement, Element>
) => {
typeof onFocus === 'function' && onFocus(event)
setFocused(true)
}
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
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 (
<BasePicker
ref={useMergedRef(ref, inputRef)}
dropdownOpened={dropdownOpened as boolean}
setDropdownOpened={setDropdownOpened}
className={className}
name={name}
inputLabel={inputState}
clearable={clearable && !!_value && !disabled}
disabled={disabled}
size={size}
inputPrefix={inputPrefix}
inputSuffix={inputSuffix}
onChange={handleChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onClear={handleClear}
onDropdownClose={onDropdownClose}
onDropdownOpen={onDropdownOpen}
{...rest}
>
<Calendar
locale={finalLocale}
month={inputtable ? calendarMonth : undefined}
defaultMonth={
defaultMonth ||
(_value instanceof Date ? _value : new Date())
}
value={
_value instanceof Date
? _value
: _value && dayjs(_value).toDate()
}
labelFormat={labelFormat}
dayClassName={dayClassName}
dayStyle={dayStyle}
disableOutOfMonth={disableOutOfMonth}
minDate={minDate}
maxDate={maxDate}
disableDate={disableDate}
firstDayOfWeek={firstDayOfWeek}
preventFocus={false}
dateViewCount={dateViewCount}
enableHeaderLabel={enableHeaderLabel}
defaultView={defaultView}
hideOutOfMonthDates={hideOutOfMonthDates}
hideWeekdays={hideWeekdays}
renderDay={renderDay}
weekendDays={weekendDays}
yearLabelFormat={yearLabelFormat}
onMonthChange={setCalendarMonth}
onChange={handleValueChange}
/>
<div className="flex items-center gap-4 mt-4">
<TimeInput
disabled={!_value}
value={_value}
format={amPm ? '12' : '24'}
clearable={false}
size="sm"
onChange={handleTimeChange}
/>
<Button size="sm" variant='solid' disabled={!_value} onClick={handleOk}>
{okButtonContent}
</Button>
</div>
</BasePicker>
)
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<HTMLInputElement, Element>) => {
typeof onBlur === 'function' && onBlur(event)
setFocused(false)
}
const handleInputFocus = (event: FocusEvent<HTMLInputElement, Element>) => {
typeof onFocus === 'function' && onFocus(event)
setFocused(true)
}
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
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 (
<BasePicker
ref={useMergedRef(ref, inputRef)}
dropdownOpened={dropdownOpened as boolean}
setDropdownOpened={setDropdownOpened}
className={className}
name={name}
inputLabel={inputState}
clearable={clearable && !!_value && !disabled}
disabled={disabled}
size={size}
inputPrefix={inputPrefix}
inputSuffix={inputSuffix}
onChange={handleChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onClear={handleClear}
onDropdownClose={onDropdownClose}
onDropdownOpen={onDropdownOpen}
{...rest}
>
<Calendar
locale={finalLocale}
month={inputtable ? calendarMonth : undefined}
defaultMonth={defaultMonth || (_value instanceof Date ? _value : new Date())}
value={_value instanceof Date ? _value : _value && dayjs(_value).toDate()}
labelFormat={labelFormat}
dayClassName={dayClassName}
dayStyle={dayStyle}
disableOutOfMonth={disableOutOfMonth}
minDate={minDate}
maxDate={maxDate}
disableDate={disableDate}
firstDayOfWeek={firstDayOfWeek}
preventFocus={false}
dateViewCount={dateViewCount}
enableHeaderLabel={enableHeaderLabel}
defaultView={defaultView}
hideOutOfMonthDates={hideOutOfMonthDates}
hideWeekdays={hideWeekdays}
renderDay={renderDay}
weekendDays={weekendDays}
yearLabelFormat={yearLabelFormat}
onMonthChange={setCalendarMonth}
onChange={handleValueChange}
/>
<div className="flex items-center gap-4 mt-4">
<TimeInput
disabled={!_value}
value={_value}
format={amPm ? '12' : '24'}
clearable={false}
size="sm"
onChange={handleTimeChange}
/>
<Button size="sm" variant="solid" disabled={!_value} onClick={handleOk}>
{okButtonContent}
</Button>
</div>
</BasePicker>
)
})
DateTimepicker.displayName = 'DateTimepicker'

View file

@ -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<HTMLElement>,
): 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<HTMLElement>) => {
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<string, unknown>) => {
const getTabOptions = (props: Record<string, unknown>) =>
Array.isArray(props.items) ? (props.items as Array<Record<string, unknown>>) : []
/** 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<string, unknown>,
currentItem?: unknown,
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
translate: (key: string) => string = (key) => key,
formScope?: DesignerFormScope,
) => {
const props: Record<string, unknown> = {}
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[] }) => (
</div>
)
// 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<string, unknown>,
path: string,
value: unknown,
): Record<string, unknown> => {
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<string, unknown>
}
target[keys[keys.length - 1]] = value
return next
}
const SqlDataSourceView = ({
node,
dataValues,
interactive,
renderChildren,
}: {
node: DesignerNode
dataValues: Record<string, unknown>
interactive: boolean
renderChildren: (
childDataValues: Record<string, unknown>,
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<SqlDataSourceMode>('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<Record<string, unknown> | 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<DesignerFormScope>(
() => ({
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,
) => (
<button
key={label}
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${
tone === 'primary'
? 'bg-sky-600 text-white hover:bg-sky-700'
: tone === 'danger'
? 'bg-red-600 text-white hover:bg-red-700'
: 'border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300'
}`}
disabled={!enabled}
title={enabled && !onClick ? designTimeTitle : disabledTitle}
type="button"
onClick={(event) => {
event.stopPropagation()
onClick?.()
}}
>
{label}
</button>
)
return (
<div
className={String(node.props.className || '')}
style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
>
{interactive && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed border-sky-300 bg-sky-50 px-2.5 py-1.5 text-[10px] text-sky-800 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-200">
<span className="font-semibold uppercase tracking-wider">SqlDataSource</span>
<span>
key: <code>{keyField}</code>
</span>
<span>{selectId ? `${rows.length} kayıt` : 'Select endpointi seçilmedi'}</span>
<span className="rounded bg-sky-600 px-1.5 py-0.5 font-semibold text-white">
{mode === 'new' ? 'Yeni kayıt' : 'Düzenleme'}
</span>
{rows.length > 1 && (
<label className="flex items-center gap-1">
Satır
<select
className="rounded border border-sky-300 bg-white px-1 py-0.5 text-[10px] dark:border-sky-800 dark:bg-slate-900"
value={rowIndex}
onChange={(event) => goToRow(Number(event.target.value) || 0)}
onClick={(event) => event.stopPropagation()}
>
{rows.map((_, index) => (
<option key={index} value={index}>
{index + 1}
</option>
))}
</select>
</label>
)}
</div>
)}
{renderChildren(childDataValues, formScope)}
{/* The drop zone belongs with the content, above the command toolbar. */}
{interactive && !node.children.length && (
<div className="rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400 dark:border-slate-700">
Bileşeni buraya bırakın; Data sekmesinden sütununa bağlayın.
</div>
)}
{node.props.showToolbar !== false && (
<div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">
{/* 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)),
)}
<span className="text-xs text-slate-500">
{rows.length ? `${rowIndex + 1} / ${rows.length}` : '0 / 0'}
</span>
{toolbarButton(
'Sonraki',
rowIndex < rows.length - 1,
'plain',
'Son kayıttasınız.',
() => goToRow(Math.min(rows.length - 1, rowIndex + 1)),
)}
<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />
</>
)}
{/* 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),
)}
</div>
)}
</div>
)
}
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<string, unknown>) => 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 (
<div
@ -586,7 +882,14 @@ const renderElement = (
: typeof boundChildren === 'object'
? JSON.stringify(boundChildren)
: String(boundChildren)
const props = getPreviewProps(node, dataValues, currentItem, onNodePropChange, translate)
const props = getPreviewProps(
node,
dataValues,
currentItem,
onNodePropChange,
translate,
formScope,
)
if (node.kind === 'custom') {
return renderCustomComponent?.(node.type, { ...props, children: content }) || null
}
@ -693,6 +996,7 @@ const NodeView = ({
interactive,
onSelect,
onDropComponent,
onDropComponentBeside,
onMoveIntoContainer,
onMove,
onReorder,
@ -702,6 +1006,7 @@ const NodeView = ({
renderCustomComponent,
dataValues,
currentItem,
formScope,
}: {
node: DesignerNode
index: number
@ -711,6 +1016,11 @@ const NodeView = ({
interactive: boolean
onSelect?: (id: string) => 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<string, unknown>) => React.ReactNode
dataValues: Record<string, unknown>
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) => (
<NodeView
key={`${child.id}_${itemIndex}`}
node={child}
index={childIndex}
siblingCount={node.children.length}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
dataValues={dataValues}
currentItem={childItem}
onSelect={onSelect}
onDropComponent={onDropComponent}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
onReorder={onReorder}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
)),
)
const renderChildNodes = (
childDataValues: Record<string, unknown>,
childFormScope?: DesignerFormScope,
) =>
childContexts.flatMap((childItem, itemIndex) =>
node.children.map((child, childIndex) => (
<NodeView
key={`${child.id}_${itemIndex}`}
node={child}
index={childIndex}
siblingCount={node.children.length}
selectedId={selectedId}
interactive={interactive}
renderCustomComponent={renderCustomComponent}
dataValues={childDataValues}
currentItem={childItem}
formScope={childFormScope}
onSelect={onSelect}
onDropComponent={onDropComponent}
onDropComponentBeside={onDropComponentBeside}
onMoveIntoContainer={onMoveIntoContainer}
onMove={onMove}
onReorder={onReorder}
onDuplicate={onDuplicate}
onDelete={onDelete}
onNodePropChange={onNodePropChange}
/>
)),
)
const children = renderChildNodes(dataValues, formScope)
const sqlDataSourceContent = isSqlDataSourceNode(node.type) ? (
<SqlDataSourceView
dataValues={dataValues}
interactive={interactive}
node={node}
renderChildren={renderChildNodes}
/>
) : 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 = ({
</tbody>
</UiKit.Table>
) : 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<HTMLDivElement>) => {
if (interactive) acceptDesignerDrag(event)
},
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
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
? [
<div
key={`card_drop_${node.id}`}
className="rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400 dark:border-slate-700"
{...containerDropZoneProps}
>
Bileşeni buraya bırakın
</div>,
@ -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,
)}
</PreviewBoundary>
{/* 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' && (
<div className="m-2 rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400">
node.type !== 'Tabs' &&
// SqlDataSource renders its own placeholder below the toolbar.
!isSqlDataSourceNode(node.type) && (
<div
className="m-2 rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400"
{...containerDropZoneProps}
>
Bileşeni buraya bırakın
</div>
)}
@ -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}

View file

@ -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 idsi (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 URLsinden okunma şekli',
},
{
name: 'keyParamName',
type: 'string',
value: '',
category: 'properties',
description: 'URLden 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,
]
}

View file

@ -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_<nodeId>`, 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(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} <= 0} onClick={${names.previous}}>Önceki</button>`, level + 4)}
${indent(`<span className="text-xs text-slate-500">{\`\${${names.index} + 1} / \${${names.rows}.length}\`}</span>`, level + 4)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} >= ${names.rows}.length - 1} onClick={${names.next}}>Sonraki</button>`, level + 4)}
${indent('<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />', level + 4)}
${indent('</>', level + 3)}
${indent(') : null}', level + 2)}`
const toolbar =
node.props.showToolbar === false
? ''
: `
${indent('<div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">', level + 1)}${navigation}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('insertEndpoint')}} onClick={${names.create}}>Yeni</button>`, level + 2)}
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-sky-600 text-white hover:bg-sky-700" disabled={${names.busy} || !(${names.mode} === "new" ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')})} onClick={() => { void ${names.save}() }}>Kaydet</button>`, level + 2)}
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-red-600 text-white hover:bg-red-700" disabled={${names.busy} || !${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new"} onClick={() => { void ${names.remove}() }}>Sil</button>`, level + 2)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('selectEndpoint')}} onClick={() => { void ${names.refresh}() }}>Yenile</button>`, level + 2)}
${indent(`<span className="ml-auto text-[10px] uppercase tracking-wider text-slate-400">{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}</span>`, level + 2)}
${indent('</div>', level + 1)}`
const error = `
${indent(`{${names.error} ? <div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950 dark:text-red-200">{${names.error}}</div> : null}`, level + 1)}`
return `${indent(`<div className=${className} style={${style}}>`, level)}
${children}${toolbar}${error}
${indent('</div>', 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(
`<div aria-hidden="true" className=${JSON.stringify(String(node.props.className || ''))} style={{ height: ${Number(node.props.height) || 24} }} />`,
@ -221,7 +533,7 @@ const nodeToCode = (node: DesignerNode, level = 0, itemVariable?: string): strin
? ` className=${JSON.stringify(node.props.className)}`
: ''
const children = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable))
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
.join('\n')
return `${indent(`<div${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', 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(`<div className=${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', 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('<span />', level + 3)
return `${indent('<td>', level + 2)}\n${content}\n${indent('</td>', level + 2)}`
}).join('\n')
@ -329,7 +643,7 @@ ${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)}
${indent(`<tr key={rowIndex} className=${JSON.stringify(rowClass)}>`, 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 <td key={column} className="max-w-64 truncate text-slate-800 dark:text-slate-100 ${cellSpacingClass}" title={text}>{text}</td>`, level + 9)}
${indent('})}', level + 8)}
${indent('</tr>', 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(
`<pre className="min-w-0 overflow-auto rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-800 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">{typeof ${repeatedItemVariable} === "string" ? ${repeatedItemVariable} : JSON.stringify(${repeatedItemVariable}, null, 2)}</pre>`,
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(`<UiKit.Menu${menuPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Menu.MenuItem key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)}>{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)}</UiKit.Menu.MenuItem>)}`, level + 1)}\n${indent('</UiKit.Menu>', level)}`
}
@ -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(
`<UiKit.Pagination${paginationPropsCode} total={${optionsExpression}.length} />`,
@ -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(`<UiKit.Radio.Group${groupPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Radio key={String(option.value ?? optionIndex)} value={option.value ?? optionIndex} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Seçenek \${optionIndex + 1}\`)}</UiKit.Radio>)}`, level + 1)}\n${indent('</UiKit.Radio.Group>', 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<string>()
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 `<html lang>` 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`
}

View file

@ -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<string, unknown>
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<Record<string, unknown>> => {
const value = collectionPath.trim() ? getDesignerValueByPath(sample, collectionPath) : sample
const candidate = Array.isArray(value)
? value
: value && typeof value === 'object'
? Array.isArray((value as Record<string, unknown>).items)
? ((value as Record<string, unknown>).items as unknown[])
: [value]
: []
return candidate.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
}
export const getSqlDataSourceRecord = (
node: DesignerNode,
dataValues: Record<string, unknown>,
rowIndex = 0,
): Record<string, unknown> => {
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

View file

@ -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<CustomComponent, 'id' | 'name' | 'routePath' | 'isActive'>[],
components: Pick<CustomComponent, 'id' | 'name' | 'routePath' | 'isActive' | 'description'>[],
): 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,
}))
}

View file

@ -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 ? (
<Helmet defaultTitle={APP_NAME} title={title} titleTemplate={`%s | ${APP_NAME}`}></Helmet>
) : 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={
<PermissionGuard permissions={route.authority}>
<PageContainer>
<RouteTitle title={route.title} />
<React.Suspense fallback={<div>Loading {route.path}...</div>}>
<Component />
</React.Suspense>
@ -137,9 +147,12 @@ export const DynamicRouter: React.FC = () => {
key={route.key}
path={route.path}
element={
<React.Suspense fallback={<div>Loading {route.path}...</div>}>
<Component />
</React.Suspense>
<>
<RouteTitle title={route.title} />
<React.Suspense fallback={<div>Loading {route.path}...</div>}>
<Component />
</React.Suspense>
</>
}
/>
)

View file

@ -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

View file

@ -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 `<html lang>` 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)
}

File diff suppressed because it is too large Load diff