Claude güncellemesi SqlDataSource komponenti ve Localization
This commit is contained in:
parent
0224f4b3e1
commit
81960df31b
19 changed files with 2996 additions and 1054 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/c
|
|||
import generatedComponentProps from './generated/componentProps.json'
|
||||
import {
|
||||
DESIGNER_DATA_COMPONENT_NAMES,
|
||||
SQL_DATA_SOURCE_TYPE,
|
||||
getDesignerCollectionProperty,
|
||||
isDesignerOptionComponent,
|
||||
isDesignerTabularComponent,
|
||||
|
|
@ -471,6 +472,112 @@ export const PLATFORM_COMPONENTS: DesignerComponentDefinition[] = [
|
|||
platformDefinition('ChartView', 'Chart', 'Sozsoft dinamik grafik görünümü', '420px'),
|
||||
]
|
||||
|
||||
/**
|
||||
* ASP.NET's SqlDataSource + FormView in one component: it owns the four CRUD
|
||||
* endpoints and acts as a container, so every component dropped inside it can
|
||||
* bind to a column of the Select result and write back through Save/Delete.
|
||||
* The endpoints are configured from the inspector's Data tab, not here.
|
||||
*/
|
||||
export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
|
||||
name: SQL_DATA_SOURCE_TYPE,
|
||||
icon: 'Database',
|
||||
category: 'data',
|
||||
kind: 'layout',
|
||||
toolboxGroup: 'data',
|
||||
description:
|
||||
'GET/POST/PUT/DELETE endpointlerini tek kayıt üzerinde yöneten veri kabı; içine bırakılan komponentler sütunlara bağlanır',
|
||||
acceptsChildren: true,
|
||||
properties: [
|
||||
{
|
||||
name: 'selectEndpoint',
|
||||
type: 'string',
|
||||
value: '',
|
||||
category: 'properties',
|
||||
description: 'Kaydı okuyan GET endpointinin data source id’si (Data sekmesinden seçilir)',
|
||||
},
|
||||
{
|
||||
name: 'insertEndpoint',
|
||||
type: 'string',
|
||||
value: '',
|
||||
category: 'properties',
|
||||
description: 'Yeni kayıt için POST endpointi',
|
||||
},
|
||||
{
|
||||
name: 'updateEndpoint',
|
||||
type: 'string',
|
||||
value: '',
|
||||
category: 'properties',
|
||||
description: 'Mevcut kaydı güncelleyen PUT endpointi',
|
||||
},
|
||||
{
|
||||
name: 'deleteEndpoint',
|
||||
type: 'string',
|
||||
value: '',
|
||||
category: 'properties',
|
||||
description: 'Kaydı silen DELETE endpointi',
|
||||
},
|
||||
{
|
||||
name: 'keyFieldName',
|
||||
type: 'string',
|
||||
value: 'id',
|
||||
category: 'properties',
|
||||
description: 'Insert/Update ayrımı ve endpoint parametreleri bu sütundan doldurulur',
|
||||
},
|
||||
{
|
||||
name: 'collectionPath',
|
||||
type: 'string',
|
||||
value: '',
|
||||
category: 'properties',
|
||||
description: 'GET cevabı içinde satırların bulunduğu path (boşsa cevabın kendisi kullanılır)',
|
||||
},
|
||||
{
|
||||
name: 'keySource',
|
||||
type: 'select',
|
||||
value: 'query',
|
||||
options: ['query', 'route'],
|
||||
category: 'properties',
|
||||
description: 'Key değerinin sayfa URL’sinden okunma şekli',
|
||||
},
|
||||
{
|
||||
name: 'keyParamName',
|
||||
type: 'string',
|
||||
value: '',
|
||||
category: 'properties',
|
||||
description: 'URL’den okunacak parametre adı (boşsa key field kullanılır)',
|
||||
},
|
||||
{
|
||||
name: 'previewKeyValue',
|
||||
type: 'string',
|
||||
value: '',
|
||||
category: 'properties',
|
||||
description:
|
||||
'Sadece tasarım ekranı: GetById endpointinin sütunlarını okuyabilmek için örnek key değeri',
|
||||
},
|
||||
{
|
||||
name: 'autoLoad',
|
||||
type: 'boolean',
|
||||
value: true,
|
||||
category: 'properties',
|
||||
description: 'Sayfa açıldığında Select endpointini otomatik çağırır',
|
||||
},
|
||||
{
|
||||
name: 'showToolbar',
|
||||
type: 'boolean',
|
||||
value: true,
|
||||
category: 'properties',
|
||||
description: 'New / Save / Delete / Reload butonlarını gösterir',
|
||||
},
|
||||
{
|
||||
name: 'gap',
|
||||
type: 'number',
|
||||
value: 16,
|
||||
category: 'styling',
|
||||
},
|
||||
{ name: 'className', type: 'string', value: '', category: 'styling' },
|
||||
],
|
||||
hooks: [],
|
||||
}
|
||||
|
||||
export const DESIGNER_EXTRAS: DesignerComponentDefinition[] = [
|
||||
{
|
||||
name: 'PageContainer',
|
||||
|
|
@ -610,5 +717,13 @@ export const getDesignerCatalog = (customNames: string[] = []): DesignerComponen
|
|||
hooks: [],
|
||||
}))
|
||||
|
||||
return [...DESIGNER_EXTRAS, ...PLATFORM_COMPONENTS, ...html, ...ui, ...generatedOnlyUi, ...custom]
|
||||
return [
|
||||
...DESIGNER_EXTRAS,
|
||||
SQL_DATA_SOURCE_DEFINITION,
|
||||
...PLATFORM_COMPONENTS,
|
||||
...html,
|
||||
...ui,
|
||||
...generatedOnlyUi,
|
||||
...custom,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
89
ui/src/utils/localeFormat.ts
Normal file
89
ui/src/utils/localeFormat.ts
Normal 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
Loading…
Reference in a new issue