From cc0066b93a0b0030003abb2fb4b3288c108c504d Mon Sep 17 00:00:00 2001 From: ksvfs Date: Wed, 3 Jun 2026 08:57:25 +0000 Subject: [PATCH 01/19] feat: add topic and table forms --- .../ConfirmationDialog/ConfirmationDialog.tsx | 8 +- .../RangeInputPicker/RangeInputPicker.scss | 22 + .../RangeInputPicker/RangeInputPicker.tsx | 197 +++ src/components/RangeInputPicker/index.ts | 1 + .../Diagnostics/Consumers/Consumers.tsx | 2 +- .../Overview/TopicStats/TopicStats.tsx | 2 +- .../Diagnostics/Partitions/Partitions.tsx | 2 +- .../TopicMessageDetails.tsx | 2 +- .../Diagnostics/TopicData/columns/columns.tsx | 2 +- .../Tenant/Diagnostics/TopicData/getData.ts | 2 +- .../TopicData/useTopicProbeQuery.ts | 2 +- .../Tenant/ObjectSummary/ObjectSummary.tsx | 81 +- .../ObjectSummary/SchemaTree/SchemaTree.tsx | 74 ++ .../Query/Preview/components/TopicPreview.tsx | 2 +- .../TableFormDialog/TableFormDialog.scss | 402 ++++++ .../TableFormDialog/TableFormDialog.tsx | 395 ++++++ .../__test__/validation.test.ts | 165 +++ .../TableFormDialog/columnValueValidation.ts | 146 +++ .../components/ColumnSelectorField.scss | 177 +++ .../components/ColumnSelectorField.tsx | 253 ++++ .../TableFormDialog/components/layout.tsx | 108 ++ .../Tenant/TableFormDialog/constants.ts | 107 ++ .../Tenant/TableFormDialog/i18n/en.json | 145 +++ .../Tenant/TableFormDialog/i18n/index.ts | 7 + .../sections/GeneralSection.tsx | 95 ++ .../sections/PartitioningSection.tsx | 73 ++ .../sections/SettingsSection.tsx | 390 ++++++ .../sections/SplitPointDialog.tsx | 252 ++++ .../TableFormDialog/sections/TTLSection.tsx | 213 +++ .../sections/YdbColumnsSection.tsx | 488 +++++++ .../sections/YdbIndexesSection.tsx | 121 ++ .../Tenant/TableFormDialog/types.ts | 40 + .../Tenant/TableFormDialog/utils.ts | 278 ++++ .../Tenant/TableFormDialog/validation.ts | 277 ++++ src/containers/Tenant/Tenant.tsx | 45 +- .../TopicFormDialog/TopicFormDialog.scss | 148 +++ .../TopicFormDialog/TopicFormDialog.tsx | 1142 +++++++++++++++++ .../TopicFormDialog/__test__/utils.test.ts | 26 + .../__test__/validation.test.ts | 109 ++ .../Tenant/TopicFormDialog/i18n/en.json | 79 ++ .../Tenant/TopicFormDialog/i18n/index.ts | 7 + .../Tenant/TopicFormDialog/utils.ts | 183 +++ .../Tenant/TopicFormDialog/validation.ts | 154 +++ src/containers/Tenant/i18n/en.json | 2 + src/containers/Tenant/utils/schemaActions.tsx | 79 +- .../reducers/table/__test__/utils.test.ts | 289 +++++ src/store/reducers/table/constants.ts | 40 + src/store/reducers/table/table.ts | 170 +++ src/store/reducers/table/types.ts | 212 +++ src/store/reducers/table/utils.ts | 634 +++++++++ src/store/reducers/topic.ts | 148 --- .../reducers/topic/__test__/topic.test.ts | 110 ++ .../reducers/topic/__test__/utils.test.ts | 60 + src/store/reducers/topic/topic.ts | 319 +++++ src/store/reducers/topic/utils.ts | 148 +++ src/types/api/topic.ts | 23 + 56 files changed, 8428 insertions(+), 230 deletions(-) create mode 100644 src/components/RangeInputPicker/RangeInputPicker.scss create mode 100644 src/components/RangeInputPicker/RangeInputPicker.tsx create mode 100644 src/components/RangeInputPicker/index.ts create mode 100644 src/containers/Tenant/TableFormDialog/TableFormDialog.scss create mode 100644 src/containers/Tenant/TableFormDialog/TableFormDialog.tsx create mode 100644 src/containers/Tenant/TableFormDialog/__test__/validation.test.ts create mode 100644 src/containers/Tenant/TableFormDialog/columnValueValidation.ts create mode 100644 src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.scss create mode 100644 src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.tsx create mode 100644 src/containers/Tenant/TableFormDialog/components/layout.tsx create mode 100644 src/containers/Tenant/TableFormDialog/constants.ts create mode 100644 src/containers/Tenant/TableFormDialog/i18n/en.json create mode 100644 src/containers/Tenant/TableFormDialog/i18n/index.ts create mode 100644 src/containers/Tenant/TableFormDialog/sections/GeneralSection.tsx create mode 100644 src/containers/Tenant/TableFormDialog/sections/PartitioningSection.tsx create mode 100644 src/containers/Tenant/TableFormDialog/sections/SettingsSection.tsx create mode 100644 src/containers/Tenant/TableFormDialog/sections/SplitPointDialog.tsx create mode 100644 src/containers/Tenant/TableFormDialog/sections/TTLSection.tsx create mode 100644 src/containers/Tenant/TableFormDialog/sections/YdbColumnsSection.tsx create mode 100644 src/containers/Tenant/TableFormDialog/sections/YdbIndexesSection.tsx create mode 100644 src/containers/Tenant/TableFormDialog/types.ts create mode 100644 src/containers/Tenant/TableFormDialog/utils.ts create mode 100644 src/containers/Tenant/TableFormDialog/validation.ts create mode 100644 src/containers/Tenant/TopicFormDialog/TopicFormDialog.scss create mode 100644 src/containers/Tenant/TopicFormDialog/TopicFormDialog.tsx create mode 100644 src/containers/Tenant/TopicFormDialog/__test__/utils.test.ts create mode 100644 src/containers/Tenant/TopicFormDialog/__test__/validation.test.ts create mode 100644 src/containers/Tenant/TopicFormDialog/i18n/en.json create mode 100644 src/containers/Tenant/TopicFormDialog/i18n/index.ts create mode 100644 src/containers/Tenant/TopicFormDialog/utils.ts create mode 100644 src/containers/Tenant/TopicFormDialog/validation.ts create mode 100644 src/store/reducers/table/__test__/utils.test.ts create mode 100644 src/store/reducers/table/constants.ts create mode 100644 src/store/reducers/table/table.ts create mode 100644 src/store/reducers/table/types.ts create mode 100644 src/store/reducers/table/utils.ts delete mode 100644 src/store/reducers/topic.ts create mode 100644 src/store/reducers/topic/__test__/topic.test.ts create mode 100644 src/store/reducers/topic/__test__/utils.test.ts create mode 100644 src/store/reducers/topic/topic.ts create mode 100644 src/store/reducers/topic/utils.ts diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 941475e49c..0af5efba27 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -22,6 +22,7 @@ interface CommonDialogProps { textButtonApply?: string; buttonApplyView?: ButtonView; className?: string; + disableOutsideClick?: boolean; onConfirm?: () => void; } @@ -40,6 +41,8 @@ export const CONFIRMATION_DIALOG = 'confirmation-dialog'; function ConfirmationDialog({ caption = '', children, + message, + body, onConfirm, onClose, progress, @@ -47,6 +50,7 @@ function ConfirmationDialog({ textButtonCancel, buttonApplyView = 'normal', className, + disableOutsideClick = true, renderButtons, open, confirmOnEnter, @@ -56,12 +60,12 @@ function ConfirmationDialog({ className={block(null, className)} size="s" onClose={onClose} - disableOutsideClick + disableOutsideClick={disableOutsideClick} open={open} onEnterKeyDown={confirmOnEnter ? onConfirm : undefined} > {caption}} /> - {children} + {children ?? body ?? message} string; + onUpdate: (value: number) => void; + acceptInputValue?: (value: string) => boolean; + parseInputValue?: (value: string) => number | undefined; + formatInputValue?: (value: number) => string; + disabled?: boolean; + endContent?: React.ReactNode; + className?: string; +} + +function clamp(value: number, min: number, max: number) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +function defaultAcceptInputValue(value: string) { + return /^\d*$/.test(value); +} + +function defaultParseInputValue(value: string) { + return Number.parseInt(value, 10); +} + +function defaultFormatInputValue(value: number) { + return String(value); +} + +export function RangeInputPicker({ + value, + min, + max, + step = 1, + marks, + markFormat, + onUpdate, + acceptInputValue = defaultAcceptInputValue, + parseInputValue = defaultParseInputValue, + formatInputValue = defaultFormatInputValue, + disabled, + endContent, + className, +}: RangeInputPickerProps) { + const hasNumericValue = typeof value === 'number' && Number.isFinite(value); + const displayValue = hasNumericValue ? formatInputValue(value) : ''; + + const [inputValue, setInputValue] = React.useState(displayValue); + const [isInputFocused, setIsInputFocused] = React.useState(false); + const [hasInputChanges, setHasInputChanges] = React.useState(false); + + React.useEffect(() => { + if (!isInputFocused) { + setInputValue(displayValue); + } + }, [displayValue, isInputFocused]); + + const sliderValue = React.useMemo(() => { + if (!isInputFocused || !hasInputChanges) { + return hasNumericValue ? clamp(value, min, max) : min; + } + + const parsedDraft = parseInputValue(inputValue); + if (typeof parsedDraft === 'number' && !Number.isNaN(parsedDraft)) { + return clamp(parsedDraft, min, max); + } + return hasNumericValue ? clamp(value, min, max) : min; + }, [ + hasInputChanges, + hasNumericValue, + inputValue, + isInputFocused, + max, + min, + parseInputValue, + value, + ]); + + const handleSliderUpdate = React.useCallback( + (nextValue: number | number[]) => { + const normalizedValue = Array.isArray(nextValue) ? nextValue[0] : nextValue; + + setInputValue(formatInputValue(normalizedValue)); + onUpdate(normalizedValue); + }, + [formatInputValue, onUpdate], + ); + + const handleInputUpdate = React.useCallback( + (nextValue: string) => { + if (!acceptInputValue(nextValue)) { + return; + } + + setInputValue(nextValue); + setHasInputChanges(true); + + const parsedValue = parseInputValue(nextValue); + if ( + typeof parsedValue === 'number' && + !Number.isNaN(parsedValue) && + parsedValue >= min && + parsedValue <= max + ) { + onUpdate(parsedValue); + } + }, + [acceptInputValue, max, min, onUpdate, parseInputValue], + ); + + const handleInputFocus = React.useCallback(() => { + setIsInputFocused(true); + setHasInputChanges(false); + }, []); + + const handleInputBlur = React.useCallback(() => { + if (!hasInputChanges) { + setIsInputFocused(false); + setInputValue(displayValue); + return; + } + + const fallbackValue = hasNumericValue ? clamp(value, min, max) : min; + + let normalizedValue: number; + if (inputValue === '') { + normalizedValue = fallbackValue; + } else { + const parsedValue = parseInputValue(inputValue); + if (typeof parsedValue !== 'number' || Number.isNaN(parsedValue)) { + normalizedValue = fallbackValue; + } else { + normalizedValue = clamp(parsedValue, min, max); + } + } + + setIsInputFocused(false); + setInputValue(formatInputValue(normalizedValue)); + onUpdate(normalizedValue); + }, [ + displayValue, + formatInputValue, + hasInputChanges, + hasNumericValue, + inputValue, + max, + min, + onUpdate, + parseInputValue, + value, + ]); + + return ( +
+ + +
+ ); +} diff --git a/src/components/RangeInputPicker/index.ts b/src/components/RangeInputPicker/index.ts new file mode 100644 index 0000000000..c00f8541ad --- /dev/null +++ b/src/components/RangeInputPicker/index.ts @@ -0,0 +1 @@ +export * from './RangeInputPicker'; diff --git a/src/containers/Tenant/Diagnostics/Consumers/Consumers.tsx b/src/containers/Tenant/Diagnostics/Consumers/Consumers.tsx index 25f3191283..51fd33908a 100644 --- a/src/containers/Tenant/Diagnostics/Consumers/Consumers.tsx +++ b/src/containers/Tenant/Diagnostics/Consumers/Consumers.tsx @@ -11,7 +11,7 @@ import { selectPreparedConsumersData, selectPreparedTopicStats, topicApi, -} from '../../../../store/reducers/topic'; +} from '../../../../store/reducers/topic/topic'; import type {EPathType} from '../../../../types/api/schema'; import {cn} from '../../../../utils/cn'; import {DEFAULT_TABLE_SETTINGS} from '../../../../utils/constants'; diff --git a/src/containers/Tenant/Diagnostics/Overview/TopicStats/TopicStats.tsx b/src/containers/Tenant/Diagnostics/Overview/TopicStats/TopicStats.tsx index bf3c8b824a..15f4a0913e 100644 --- a/src/containers/Tenant/Diagnostics/Overview/TopicStats/TopicStats.tsx +++ b/src/containers/Tenant/Diagnostics/Overview/TopicStats/TopicStats.tsx @@ -8,7 +8,7 @@ import {LagPopoverContent} from '../../../../../components/LagPopoverContent'; import {Loader} from '../../../../../components/Loader'; import {SpeedMultiMeter} from '../../../../../components/SpeedMultiMeter'; import {useClusterWithProxy} from '../../../../../store/reducers/cluster/cluster'; -import {selectPreparedTopicStats, topicApi} from '../../../../../store/reducers/topic'; +import {selectPreparedTopicStats, topicApi} from '../../../../../store/reducers/topic/topic'; import type {IPreparedTopicStats} from '../../../../../types/store/topic'; import {cn} from '../../../../../utils/cn'; import {formatBps, formatBytes} from '../../../../../utils/dataFormatters/dataFormatters'; diff --git a/src/containers/Tenant/Diagnostics/Partitions/Partitions.tsx b/src/containers/Tenant/Diagnostics/Partitions/Partitions.tsx index 40f5badaf0..74b54084cb 100644 --- a/src/containers/Tenant/Diagnostics/Partitions/Partitions.tsx +++ b/src/containers/Tenant/Diagnostics/Partitions/Partitions.tsx @@ -9,7 +9,7 @@ import {TableWithControlsLayout} from '../../../../components/TableWithControlsL import {useClusterWithProxy} from '../../../../store/reducers/cluster/cluster'; import {nodesListApi, selectNodesMap} from '../../../../store/reducers/nodesList'; import {partitionsApi, setSelectedConsumer} from '../../../../store/reducers/partitions/partitions'; -import {selectConsumersNames, topicApi} from '../../../../store/reducers/topic'; +import {selectConsumersNames, topicApi} from '../../../../store/reducers/topic/topic'; import {cn} from '../../../../utils/cn'; import {DEFAULT_TABLE_SETTINGS} from '../../../../utils/constants'; import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../../../utils/hooks'; diff --git a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/TopicMessageDetails.tsx b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/TopicMessageDetails.tsx index f0c8c9ec82..7fbc46dd6b 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/TopicMessageDetails.tsx +++ b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/TopicMessageDetails.tsx @@ -5,7 +5,7 @@ import {skipToken} from '@reduxjs/toolkit/query'; import {isNil} from 'lodash'; import {LoaderWrapper} from '../../../../../components/LoaderWrapper/LoaderWrapper'; -import {topicApi} from '../../../../../store/reducers/topic'; +import {topicApi} from '../../../../../store/reducers/topic/topic'; import type {TopicDataRequest} from '../../../../../types/api/topic'; import {isResponseError} from '../../../../../utils/response'; import {safeParseNumber} from '../../../../../utils/utils'; diff --git a/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx b/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx index 3673a938a9..b8a048a9ce 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx +++ b/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx @@ -11,7 +11,7 @@ import {EntityStatus} from '../../../../../components/EntityStatus/EntityStatus' import {MultilineTableHeader} from '../../../../../components/MultilineTableHeader/MultilineTableHeader'; import type {Column} from '../../../../../components/PaginatedTable'; import {TENANT_DIAGNOSTICS_TABS_IDS} from '../../../../../store/reducers/tenant/constants'; -import {TOPIC_MESSAGE_SIZE_LIMIT} from '../../../../../store/reducers/topic'; +import {TOPIC_MESSAGE_SIZE_LIMIT} from '../../../../../store/reducers/topic/topic'; import type {TopicMessageEnhanced} from '../../../../../types/api/topic'; import {cn} from '../../../../../utils/cn'; import {EMPTY_DATA_PLACEHOLDER, YDB_POPOVER_CLASS_NAME} from '../../../../../utils/constants'; diff --git a/src/containers/Tenant/Diagnostics/TopicData/getData.ts b/src/containers/Tenant/Diagnostics/TopicData/getData.ts index 703208ae8e..412e56ef0e 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/getData.ts +++ b/src/containers/Tenant/Diagnostics/TopicData/getData.ts @@ -1,7 +1,7 @@ import {isNil} from 'lodash'; import type {FetchData} from '../../../../components/PaginatedTable'; -import {TOPIC_MESSAGE_SIZE_LIMIT} from '../../../../store/reducers/topic'; +import {TOPIC_MESSAGE_SIZE_LIMIT} from '../../../../store/reducers/topic/topic'; import type { TopicDataRequest, TopicDataResponse, diff --git a/src/containers/Tenant/Diagnostics/TopicData/useTopicProbeQuery.ts b/src/containers/Tenant/Diagnostics/TopicData/useTopicProbeQuery.ts index 9472fdedde..cad3309406 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/useTopicProbeQuery.ts +++ b/src/containers/Tenant/Diagnostics/TopicData/useTopicProbeQuery.ts @@ -3,7 +3,7 @@ import React from 'react'; import {skipToken} from '@reduxjs/toolkit/query'; import {isNil} from 'lodash'; -import {topicApi} from '../../../../store/reducers/topic'; +import {topicApi} from '../../../../store/reducers/topic/topic'; import type {TopicDataRequest, TopicDataResponse} from '../../../../types/api/topic'; import {safeParseNumber} from '../../../../utils/utils'; diff --git a/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx b/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx index c54dfe22b0..1485c5f367 100644 --- a/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx +++ b/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx @@ -55,7 +55,6 @@ import {useNavigationV2Enabled} from '../utils/useNavigationV2Enabled'; import {ObjectTree} from './ObjectTree'; import {SchemaActions} from './SchemaActions'; import {RefreshTreeButton} from './SchemaTree/RefreshTreeButton'; -import {TreeKeyProvider} from './UpdateTreeContext'; import i18n from './i18n'; import {b} from './shared'; import {isDomain, transformPath} from './transformPath'; @@ -476,51 +475,49 @@ export function ObjectSummary({ const renderContent = () => { return ( - -
-
- - -
-
-
-
- {renderEntityTypeBadge()} -
{relativePath}
-
-
- {renderCommonInfoControls()} -
+
+
+ + +
+
+
+
+ {renderEntityTypeBadge()} +
{relativePath}
+
+
+ {renderCommonInfoControls()}
- {renderTabs()}
-
{renderTabContent()}
+ {renderTabs()}
- -
- - {!isCollapsed && } - - +
{renderTabContent()}
+
+
- + + {!isCollapsed && } + + +
); }; diff --git a/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx b/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx index 515a9dca8f..da36e484f3 100644 --- a/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx +++ b/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx @@ -30,7 +30,9 @@ import {canShowTenantMonitoringTab} from '../../../../utils/monitoringVisibility import {findRunningTableCompactionOperation} from '../../../../utils/tableCompaction'; import {openCompactTableDialog} from '../../Diagnostics/Overview/TableInfo/CompactTableAction/CompactTableAction'; import {useTableCompaction} from '../../Diagnostics/Overview/TableInfo/hooks/useTableCompaction'; +import {openTableFormDialog} from '../../TableFormDialog/TableFormDialog'; import {useTenantPage} from '../../TenantNavigation/useTenantNavigation'; +import {openTopicFormDialog} from '../../TopicFormDialog/TopicFormDialog'; import {getSchemaControls} from '../../utils/controls'; import { isChildlessPathType, @@ -212,6 +214,70 @@ export function SchemaTree(props: SchemaTreeProps) { ], ); + const handleOpenCreateTopicDialog = React.useCallback( + (nextParentPath: string) => { + openTopicFormDialog({ + mode: 'create', + database, + databaseFullPath, + parentPath: nextParentPath, + onSuccess: (createdPath) => { + onActivePathUpdate(createdPath); + setSchemaTreeKey(createdPath); + }, + }); + }, + [database, databaseFullPath, onActivePathUpdate, setSchemaTreeKey], + ); + + const handleOpenCreateTableDialog = React.useCallback( + (nextParentPath: string) => { + openTableFormDialog({ + mode: 'create', + database, + databaseFullPath, + parentPath: nextParentPath, + onSuccess: (createdPath) => { + onActivePathUpdate(createdPath); + setSchemaTreeKey(createdPath); + }, + }); + }, + [database, databaseFullPath, onActivePathUpdate, setSchemaTreeKey], + ); + + const handleOpenUpdateTopicDialog = React.useCallback( + (topicPath: string) => { + openTopicFormDialog({ + mode: 'update', + database, + databaseFullPath, + topicPath, + onSuccess: (updatedPath) => { + onActivePathUpdate(updatedPath); + setSchemaTreeKey(updatedPath); + }, + }); + }, + [database, databaseFullPath, onActivePathUpdate, setSchemaTreeKey], + ); + + const handleOpenUpdateTableDialog = React.useCallback( + (tablePath: string) => { + openTableFormDialog({ + mode: 'update', + database, + databaseFullPath, + path: tablePath, + onSuccess: (updatedPath) => { + onActivePathUpdate(updatedPath); + setSchemaTreeKey(updatedPath); + }, + }); + }, + [database, databaseFullPath, onActivePathUpdate, setSchemaTreeKey], + ); + const {monitoring: clusterMonitoring} = useClusterBaseInfo(); const {controlPlane} = useTenantBaseInfo(database); const getTreeNodeActions = React.useMemo(() => { @@ -224,6 +290,10 @@ export function SchemaTree(props: SchemaTreeProps) { showCreateDirectoryDialog: createDirectoryFeatureAvailable ? handleOpenCreateDirectoryDialog : undefined, + showCreateTableDialog: handleOpenCreateTableDialog, + showCreateTopicDialog: handleOpenCreateTopicDialog, + showUpdateTableDialog: handleOpenUpdateTableDialog, + showUpdateTopicDialog: handleOpenUpdateTopicDialog, isMultiTabEnabled, getConfirmation: input && isDirty && !isMultiTabEnabled ? getConfirmation : undefined, @@ -250,6 +320,10 @@ export function SchemaTree(props: SchemaTreeProps) { onActivePathUpdate, handleTenantPageChange, createDirectoryFeatureAvailable, + handleOpenCreateTableDialog, + handleOpenCreateTopicDialog, + handleOpenUpdateTableDialog, + handleOpenUpdateTopicDialog, input, isDirty, isMultiTabEnabled, diff --git a/src/containers/Tenant/Query/Preview/components/TopicPreview.tsx b/src/containers/Tenant/Query/Preview/components/TopicPreview.tsx index bfa77a2efb..6aef031e52 100644 --- a/src/containers/Tenant/Query/Preview/components/TopicPreview.tsx +++ b/src/containers/Tenant/Query/Preview/components/TopicPreview.tsx @@ -10,7 +10,7 @@ import { useClusterWithProxy, } from '../../../../../store/reducers/cluster/cluster'; import {partitionsApi} from '../../../../../store/reducers/partitions/partitions'; -import {topicApi} from '../../../../../store/reducers/topic'; +import {topicApi} from '../../../../../store/reducers/topic/topic'; import type {TopicDataRequest} from '../../../../../types/api/topic'; import {useClusterNameFromQuery} from '../../../../../utils/hooks/useDatabaseFromQuery'; import {safeParseNumber} from '../../../../../utils/utils'; diff --git a/src/containers/Tenant/TableFormDialog/TableFormDialog.scss b/src/containers/Tenant/TableFormDialog/TableFormDialog.scss new file mode 100644 index 0000000000..cb2bbf08ad --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/TableFormDialog.scss @@ -0,0 +1,402 @@ +.ydb-table-form-dialog { + &__modal { + overscroll-behavior: none; + } + + &__body { + padding-top: var(--g-spacing-5); + } + + &__form { + width: 100%; + } + + &__section + &__section { + margin-top: var(--g-spacing-6); + } + + &__section-title { + display: flex; + align-items: center; + gap: var(--g-spacing-1); + + margin-bottom: var(--g-spacing-4); + } + + &__row { + display: grid; + align-items: start; + grid-template-columns: 220px minmax(0, 1fr); + gap: var(--g-spacing-3); + + & + & { + margin-top: var(--g-spacing-4); + } + } + + &__label { + display: flex; + align-items: center; + gap: var(--g-spacing-1); + + padding-top: 7px; + + font-weight: 500; + } + + &__label-title { + min-width: 0; + } + + &__help-mark { + flex-shrink: 0; + } + + &__help-mark-popup { + max-width: 300px; + } + + &__required { + color: var(--g-color-text-danger); + } + + &__field-error { + color: var(--g-color-text-danger); + } + + &__row-control { + min-width: 0; + } + + &__control, + &__control.g-text-input, + &__control.g-select, + &__control.ydb-table-form-column-selector, + &__control.ydb-range-input-picker { + width: 320px; + max-width: 100%; + } + + &__control-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--g-spacing-2); + } + + &__table-type-info { + max-width: 320px; + } + + &__special-columns { + display: flex; + flex-wrap: wrap; + gap: var(--g-spacing-1) var(--g-spacing-4); + + margin-bottom: var(--g-spacing-3); + } + + &__special-column-label { + font-weight: 500; + } + + &__columns-table { + display: flex; + flex-direction: column; + gap: var(--g-spacing-3); + } + + &__columns-head { + display: grid; + align-items: center; + grid-template-columns: + minmax(160px, 1.2fr) minmax(140px, 0.8fr) 105px 145px minmax(160px, 1.2fr) + 28px; + gap: var(--g-spacing-3); + + font-weight: 500; + + color: var(--g-color-text-secondary); + } + + &__columns-head-cell { + display: flex; + align-items: center; + gap: var(--g-spacing-1); + + white-space: nowrap; + } + + &__columns-table_update &__columns-head, + &__columns-table_update &__columns-row { + grid-template-columns: + minmax(160px, 1.2fr) minmax(140px, 0.8fr) 145px minmax(160px, 1.2fr) + 28px; + } + + &__columns-row { + display: grid; + align-items: start; + grid-template-columns: + minmax(160px, 1.2fr) minmax(140px, 0.8fr) 105px 145px minmax(160px, 1.2fr) + 28px; + gap: var(--g-spacing-3); + } + + &__columns-row_readonly { + align-items: center; + + color: var(--g-color-text-secondary); + } + + &__columns-row_readonly &__columns-cell { + display: flex; + align-items: center; + + min-height: 28px; + } + + &__columns-row_deleting { + text-decoration: line-through; + + opacity: 0.6; + } + + &__columns-cell { + min-width: 0; + } + + &__columns-cell_key, + &__columns-cell_not-null, + &__columns-cell_default, + &__columns-cell_action { + display: flex; + align-items: center; + + min-height: 28px; + } + + &__columns-cell_action { + justify-content: center; + } + + &__columns-separator { + margin: 0; + + border: none; + border-top: 1px solid var(--g-color-line-generic); + } + + &__default-row { + display: flex; + align-items: center; + gap: var(--g-spacing-2); + } + + &__indexes-table { + display: flex; + flex-direction: column; + gap: var(--g-spacing-2); + } + + &__indexes-head { + display: grid; + grid-template-columns: minmax(0, 224px) minmax(0, 224px) max-content; + gap: var(--g-spacing-2); + + font-weight: 500; + + color: var(--g-color-text-secondary); + } + + &__indexes-row { + display: grid; + align-items: start; + grid-template-columns: minmax(0, 224px) minmax(0, 224px) max-content; + gap: var(--g-spacing-2); + + padding: var(--g-spacing-1) 0; + } + + &__indexes-row_deleting { + text-decoration: line-through; + + opacity: 0.6; + } + + &__indexes-cell { + min-width: 0; + } + + &__indexes-cell_action { + display: flex; + justify-content: flex-start; + align-items: center; + + min-height: 28px; + } + + &__readonly-text { + padding-top: 6px; + + color: var(--g-color-text-secondary); + } + + &__fixed-value { + min-height: 28px; + padding-top: 5px; + } + + &__ttl-lifetime { + display: flex; + align-items: flex-start; + gap: var(--g-spacing-2); + + width: 320px; + max-width: 100%; + } + + &__ttl-lifetime-input { + flex: 1; + + min-width: 0; + } + + &__ttl-lifetime-select { + flex: 0 0 120px; + } + + &__split-points { + display: flex; + flex-direction: column; + gap: var(--g-spacing-2); + } + + &__split-point-row { + display: flex; + align-items: center; + gap: var(--g-spacing-2); + } + + &__split-point-body { + display: flex; + flex-direction: column; + gap: var(--g-spacing-4); + } + + &__split-point-pk { + margin-bottom: 0; + } + + &__split-point-pk-label { + font-weight: 500; + } + + &__split-point-fields { + display: flex; + flex-direction: column; + gap: var(--g-spacing-3); + } + + &__split-point-dialog-row { + display: grid; + align-items: start; + grid-template-columns: minmax(0, 160px) minmax(0, 1fr); + gap: var(--g-spacing-4); + } + + &__split-point-meta { + padding-top: 7px; + } + + &__split-point-control { + display: flex; + flex-direction: column; + gap: var(--g-spacing-2); + + width: 100%; + min-width: 0; + } + + &__split-point-toggle { + display: flex; + align-items: center; + + min-height: 28px; + } + + &__split-point-input, + &__split-point-input.g-text-input, + &__split-point-input.g-text-area { + width: 100%; + min-width: 0; + max-width: 100%; + } + + &__split-point-label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--g-spacing-1); + + font-weight: 500; + } + + &__split-point-type { + font-weight: 400; + + color: var(--g-color-text-secondary); + } + + &__not-null-label { + cursor: default; + user-select: none; + } + + &__control_null { + font-style: italic; + } + + &__advanced { + display: flex; + flex-direction: column; + gap: var(--g-spacing-3); + + padding-top: var(--g-spacing-3); + } + + &__disclosure { + margin-top: var(--g-spacing-2); + + .g-disclosure__trigger { + display: inline-flex; + flex-direction: row-reverse; + align-items: center; + gap: var(--g-spacing-1); + } + } + + &__checkbox-control { + display: flex; + align-items: center; + + min-height: 28px; + } + + &__input-suffix { + margin-right: var(--g-spacing-1); + + color: var(--g-color-text-secondary); + } + + &__api-error { + margin-top: var(--g-spacing-3); + } + + &__loader { + display: flex; + justify-content: center; + + padding: var(--g-spacing-8) 0; + } +} diff --git a/src/containers/Tenant/TableFormDialog/TableFormDialog.tsx b/src/containers/Tenant/TableFormDialog/TableFormDialog.tsx new file mode 100644 index 0000000000..3d5a6589c1 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/TableFormDialog.tsx @@ -0,0 +1,395 @@ +import React from 'react'; + +import * as NiceModal from '@ebay/nice-modal-react'; +import {Dialog, Text} from '@gravity-ui/uikit'; +import {zodResolver} from '@hookform/resolvers/zod'; +import {skipToken} from '@reduxjs/toolkit/query'; +import {FormProvider, useForm, useWatch} from 'react-hook-form'; + +import {CONFIRMATION_DIALOG} from '../../../components/ConfirmationDialog/ConfirmationDialog'; +import {ResponseError} from '../../../components/Errors/ResponseError'; +import {Loader} from '../../../components/Loader'; +import {tableApi} from '../../../store/reducers/table/table'; +import { + getTablePathInfoForUpdate, + getUpdateTableSettings, +} from '../../../store/reducers/table/utils'; +import type {TEvDescribeSchemeResult} from '../../../types/api/schema/schema'; +import {cn} from '../../../utils/cn'; +import createToast from '../../../utils/createToast'; +import {prepareCommonErrorMessage} from '../../../utils/errors'; +import {transformPath} from '../ObjectSummary/transformPath'; + +import { + TABLE_FORM_DIALOG, + YDB_COLUMN_PK_TYPES, + YDB_COLUMN_TABLE_TYPES, + YDB_PK_TYPES, + YDB_TABLE_TYPES, +} from './constants'; +import i18n from './i18n'; +import {GeneralSection} from './sections/GeneralSection'; +import {PartitioningSection} from './sections/PartitioningSection'; +import {SettingsSection} from './sections/SettingsSection'; +import {TTLSection} from './sections/TTLSection'; +import {YdbColumnsSection} from './sections/YdbColumnsSection'; +import {YdbIndexesSection} from './sections/YdbIndexesSection'; +import type {FormMode, FormValues, OriginalTableInfo, TableType} from './types'; +import {describeOriginalTable, getCreateInitialValues, getUpdateInitialValues} from './utils'; +import {buildTableValidationSchema} from './validation'; + +import './TableFormDialog.scss'; + +const b = cn('ydb-table-form-dialog'); + +interface CommonDialogProps { + mode: FormMode; + database: string; + databaseFullPath: string; + parentPath?: string; + path?: string; + onSuccess?: (path: string) => void; +} + +interface TableFormDialogNiceModalProps extends CommonDialogProps { + onClose?: () => void; +} + +interface TableFormDialogInnerProps extends CommonDialogProps { + open: boolean; + onClose: () => void; +} + +interface TableFormProps { + mode: FormMode; + database: string; + databaseFullPath: string; + parentPath?: string; + path?: string; + initialValues: FormValues; + originalInfo?: OriginalTableInfo; + originalTable?: TEvDescribeSchemeResult; + onClose: () => void; + onSuccess?: (path: string) => void; + nameInputRef?: React.Ref; +} + +function buildTablePath(parentPath: string, name: string) { + const trimmedParentPath = parentPath.replace(/\/+$/, ''); + const trimmedName = name.replace(/^\/+|\/+$/g, ''); + return `${trimmedParentPath}/${trimmedName}`; +} + +function confirmTtlColumnDeletion() { + return NiceModal.show(CONFIRMATION_DIALOG, { + id: CONFIRMATION_DIALOG, + caption: i18n('label_ttl-remove-column-warning'), + message: i18n('label_ttl-remove-column-text'), + textButtonApply: i18n('action_delete'), + buttonApplyView: 'action', + }) as Promise; +} + +function TableForm({ + mode, + database, + databaseFullPath, + parentPath, + path, + initialValues, + originalInfo, + originalTable, + onClose, + onSuccess, + nameInputRef, +}: TableFormProps) { + const [createTable, createState] = tableApi.useCreateTableMutation(); + const [updateTable, updateState] = tableApi.useUpdateTableMutation(); + + const validationSchema = React.useMemo( + () => buildTableValidationSchema({mode, originalInfo}), + [mode, originalInfo], + ); + + const methods = useForm({ + defaultValues: initialValues, + resolver: zodResolver(validationSchema), + mode: 'onChange', + }); + + const { + control, + handleSubmit, + setValue, + formState: {dirtyFields}, + } = methods; + const type: TableType = useWatch({control, name: 'type'}); + + const previousTypeRef = React.useRef(initialValues.type); + React.useEffect(() => { + if (mode === 'update') { + return; + } + if (type === previousTypeRef.current) { + return; + } + + previousTypeRef.current = type; + + const nextValues = getCreateInitialValues(type); + setValue('columns', nextValues.columns, {shouldValidate: false}); + setValue('secondaryIndexes', nextValues.secondaryIndexes, {shouldValidate: false}); + setValue('partitionKey', nextValues.partitionKey, {shouldValidate: false}); + setValue('partitionCount', nextValues.partitionCount, {shouldValidate: false}); + setValue('settings', nextValues.settings, {shouldValidate: false}); + }, [mode, type, setValue]); + + const isSubmitting = createState.isLoading || updateState.isLoading; + + const handleTtlColumnDeletionRequest = React.useCallback(async (onConfirm: () => void) => { + const confirmed = await confirmTtlColumnDeletion(); + if (confirmed) { + onConfirm(); + } + }, []); + + const handleFormSubmit = handleSubmit(async (formValues) => { + try { + if (mode === 'create') { + const fullName = buildTablePath(parentPath ?? databaseFullPath, formValues.name); + await createTable({ + database, + formValues: {...formValues, name: fullName}, + }).unwrap(); + createToast({ + name: 'table-create-success', + title: i18n('alert_create-success'), + theme: 'success', + autoHiding: 5000, + }); + if (onSuccess) { + onSuccess(fullName); + } else { + onClose(); + } + return; + } + + if (!originalTable || !path) { + throw new Error('Original table is required for update'); + } + const updateSettings = getUpdateTableSettings( + formValues.settings, + dirtyFields.settings, + ); + + await updateTable({ + database, + formValues, + originalTable, + updateSettings, + }).unwrap(); + createToast({ + name: 'table-update-success', + title: i18n('alert_update-success'), + theme: 'success', + autoHiding: 5000, + }); + + const {updatedTablePath} = getTablePathInfoForUpdate(originalTable, formValues.name); + if (onSuccess) { + onSuccess(updatedTablePath); + } else { + onClose(); + } + } catch (error) { + createToast({ + name: `table-${mode}-error`, + title: mode === 'create' ? i18n('alert_create-error') : i18n('alert_update-error'), + content: prepareCommonErrorMessage(error), + theme: 'danger', + autoHiding: 5000, + }); + } + }); + + const showIndexes = type === 'row' && mode === 'create'; + const showSettings = type === 'row'; + const showPartitioning = type === 'column' && mode === 'create'; + + const columnTypes = type === 'column' ? YDB_COLUMN_TABLE_TYPES : YDB_TABLE_TYPES; + const pkTypes = type === 'column' ? YDB_COLUMN_PK_TYPES : YDB_PK_TYPES; + const keyNullable = type !== 'column'; + + return ( + +
+ + + + {showIndexes ? : null} + + {showSettings ? : null} + {showPartitioning ? : null} + + + +
+ ); +} + +function TableFormDialog({ + open, + mode, + database, + databaseFullPath, + parentPath, + path, + onClose, + onSuccess, +}: TableFormDialogInnerProps) { + const nameInputRef = React.useRef(null); + const tableQuery = tableApi.useGetTableQuery( + mode === 'update' && path ? {database, path: {path, databaseFullPath}} : skipToken, + {refetchOnMountOrArgChange: true}, + ); + + const originalTable = mode === 'update' ? tableQuery.data : undefined; + const originalInfo = React.useMemo(() => describeOriginalTable(originalTable), [originalTable]); + + const initialValues = React.useMemo(() => { + if (mode === 'create') { + return getCreateInitialValues('row'); + } + if (!originalTable) { + return undefined; + } + return getUpdateInitialValues(originalTable); + }, [mode, originalTable]); + + const renderContent = () => { + if (mode === 'update' && !path) { + return ( + + {i18n('error_load-table')} + + ); + } + if (mode === 'update' && tableQuery.error) { + return ( + + + + ); + } + if (!initialValues) { + return ( + +
+ +
+
+ ); + } + return ( + + ); + }; + + return ( + + + {renderContent()} + + ); +} + +export const TableFormDialogNiceModal = NiceModal.create((props: TableFormDialogNiceModalProps) => { + const modal = NiceModal.useModal(); + + const handleClose = () => { + modal.hide(); + modal.remove(); + }; + + return ( + { + props.onSuccess?.(path); + modal.resolve(path); + handleClose(); + }} + onClose={() => { + props.onClose?.(); + modal.resolve(null); + handleClose(); + }} + /> + ); +}); + +NiceModal.register(TABLE_FORM_DIALOG, TableFormDialogNiceModal); + +export function openTableFormDialog( + props: Omit, +): Promise { + return NiceModal.show(TABLE_FORM_DIALOG, { + id: TABLE_FORM_DIALOG, + ...props, + }) as Promise; +} diff --git a/src/containers/Tenant/TableFormDialog/__test__/validation.test.ts b/src/containers/Tenant/TableFormDialog/__test__/validation.test.ts new file mode 100644 index 0000000000..b788d0e2ee --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/__test__/validation.test.ts @@ -0,0 +1,165 @@ +import type {Column, FormValues, OriginalTableInfo} from '../types'; +import {PartitionsType} from '../types'; +import {buildTableValidationSchema} from '../validation'; + +describe('TableFormDialog validation', () => { + function createColumn(overrides: Partial = {}) { + return { + _id: 'column-id', + name: 'id', + type: 'Int64', + key: true, + notNull: true, + defaultValue: '', + withDefaultValue: false, + ...overrides, + }; + } + + function createValues(overrides: Partial = {}): FormValues { + return { + name: 'dir/table', + type: 'row', + columns: [createColumn()], + secondaryIndexes: [], + deletedColumns: [], + partitionKey: [], + partitionCount: 64, + settings: { + partitionsType: PartitionsType.None, + uniformPartitions: undefined, + partitionsAtKeys: [], + autoPartitionBySize: true, + autoPartitionByLoad: false, + autoPartitionBySizeMb: 2048, + keyBloomFilter: false, + ttl: {status: 'disabled'}, + }, + ...overrides, + }; + } + + function getIssuePaths( + result: ReturnType['safeParse']>, + ) { + if (result.success) { + return []; + } + + return result.error.issues.map(({path}) => path.join('.')); + } + + test('create mode requires a valid path-like name and at least one column', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + name: 'dir//table', + columns: [], + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toEqual(expect.arrayContaining(['name', 'columns'])); + }); + + test('update mode accepts slash-separated names for row-table moves', () => { + const schema = buildTableValidationSchema({mode: 'update'}); + + const result = schema.safeParse(createValues({name: 'archive/orders'})); + + expect(result.success).toBe(true); + }); + + test('secondary indexes cannot reference deleted columns', () => { + const originalInfo: OriginalTableInfo = { + name: 'orders', + type: 'row', + columns: [{name: 'legacy', type: 'Utf8', notNull: false}] as Column[], + partitionKey: [], + indexes: [], + hasTtl: false, + hasMinPartitions: false, + hasMaxPartitions: false, + }; + const schema = buildTableValidationSchema({mode: 'update', originalInfo}); + + const result = schema.safeParse( + createValues({ + name: 'orders', + deletedColumns: [{name: 'legacy', type: 'Utf8', notNull: false}], + secondaryIndexes: [{name: 'by_legacy', key: ['legacy']}], + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('secondaryIndexes.0.key'); + }); + + test('column table creation requires partition key and partition count in range', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + type: 'column', + partitionKey: [], + partitionCount: 1001, + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toEqual( + expect.arrayContaining(['partitionKey', 'partitionCount']), + ); + }); + + test('explicit partition split points must use a consistent key shape', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [ + createColumn(), + createColumn({_id: 'second', name: 'tenantId', type: 'Utf8'}), + ], + settings: { + partitionsType: PartitionsType.Explicit, + partitionsAtKeys: [ + [{value: '1'} as never], + [{value: '2'} as never, {value: 'tenant-a'} as never], + ], + ttl: {status: 'disabled'}, + }, + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('settings.partitionsAtKeys'); + }); + + test('ttl validation requires column, lifetime and epoch mode when needed', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + settings: { + partitionsType: PartitionsType.None, + ttl: { + status: 'enabled', + columnWithEpochMode: true, + lifetime: Number.NaN, + }, + }, + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toEqual( + expect.arrayContaining([ + 'settings.ttl.column', + 'settings.ttl.epochMode', + 'settings.ttl.lifetime', + ]), + ); + }); +}); diff --git a/src/containers/Tenant/TableFormDialog/columnValueValidation.ts b/src/containers/Tenant/TableFormDialog/columnValueValidation.ts new file mode 100644 index 0000000000..b9d3ef1830 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/columnValueValidation.ts @@ -0,0 +1,146 @@ +const INTEGER_REGEX = /^-?([0-9]|[1-9][0-9]+)$/; + +function isInt(length: 8 | 16 | 32, stringValue: string) { + if (!INTEGER_REGEX.test(stringValue)) { + return false; + } + + const value = Number(stringValue); + return !Number.isNaN(value) && value >= -(2 ** length / 2) && value < 2 ** length / 2; +} + +function isInt64(stringValue: string) { + if (!INTEGER_REGEX.test(stringValue)) { + return false; + } + + const value = BigInt(stringValue); + const total = BigInt(2) ** BigInt(64); + const min = -(total / BigInt(2)); + const max = total / BigInt(2); + + return value >= min && value < max; +} + +function isUInt(length: 8 | 16 | 32, stringValue: string) { + if (!INTEGER_REGEX.test(stringValue)) { + return false; + } + + const value = Number(stringValue); + return !Number.isNaN(value) && value >= 0 && value < 2 ** length; +} + +function isUInt64(stringValue: string) { + if (!INTEGER_REGEX.test(stringValue)) { + return false; + } + + const value = BigInt(stringValue); + const min = BigInt(0); + const max = BigInt(2) ** BigInt(64); + + return value >= min && value < max; +} + +function isDate32(stringValue: string) { + const dateRegex = /^-?\d{4,6}-(0\d|1[0-2])-([0-2]\d|3[0-1])$/; + if (!dateRegex.test(stringValue)) { + return false; + } + + const yearStr = stringValue.match(/-?\d{4,6}/)?.[0]; + if (!yearStr) { + return false; + } + + const year = parseInt(yearStr, 10); + + return year >= -144169 && year <= 148107; +} + +function isDatetime64(stringValue: string) { + const datetimeRegex = /^-?\d{4,6}-(0\d|1[0-2])-([0-2]\d|3[0-1])T\d{2}:\d{2}:\d{2}Z$/; + if (!datetimeRegex.test(stringValue)) { + return false; + } + + const yearStr = stringValue.match(/-?\d{4,6}/)?.[0]; + if (!yearStr) { + return false; + } + const year = parseInt(yearStr, 10); + + return year >= -144169 && year <= 148107; +} + +function isTimestamp64(stringValue: string) { + const timestampRegex = /^-?\d{4,6}-(0\d|1[0-2])-([0-2]\d|3[0-1])T\d{2}:\d{2}:\d{2}\.\d{1,6}Z$/; + if (!timestampRegex.test(stringValue)) { + return false; + } + + const yearStr = stringValue.match(/-?\d{4,6}/)?.[0]; + if (!yearStr) { + return false; + } + const year = parseInt(yearStr, 10); + + return year >= -144169 && year <= 148107; +} + +export function isValueForTypeValid(value: string, type: string) { + switch (type) { + case 'Bool': + return /^(true|false)$/i.test(value); + case 'Int8': + return isInt(8, value); + case 'Int16': + return isInt(16, value); + case 'Int32': + return isInt(32, value); + case 'Int64': + case 'Interval': + case 'Interval64': + return isInt64(value); + case 'Uint8': + return isUInt(8, value); + case 'Uint16': + return isUInt(16, value); + case 'Uint32': + return isUInt(32, value); + case 'Uint64': + return isUInt64(value); + case 'Uuid': + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ); + case 'Float': + case 'Double': + case 'Decimal(22,9)': + return /^-?\d+(\.\d+)?$/.test(value); + + case 'Date': + return /^\d{4}-(0\d|1[0-2])-([0-2]\d|3[0-1])$/.test(value); + case 'Date32': + return isDate32(value); + case 'Datetime': + return /^\d{4}-(0\d|1[0-2])-([0-2]\d|3[0-1])T\d{2}:\d{2}:\d{2}Z$/.test(value); + case 'Datetime64': + return isDatetime64(value); + case 'Timestamp': + return /^\d{4}-(0\d|1[0-2])-([0-2]\d|3[0-1])T\d{2}:\d{2}:\d{2}\.\d{1,6}Z$/.test(value); + case 'Timestamp64': + return isTimestamp64(value); + case 'Json': + case 'JsonDocument': + try { + JSON.parse(value); + return true; + } catch { + return false; + } + default: + return true; + } +} diff --git a/src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.scss b/src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.scss new file mode 100644 index 0000000000..080f377193 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.scss @@ -0,0 +1,177 @@ +.ydb-table-form-column-selector { + position: relative; + + width: 100%; + + &__control { + position: relative; + + display: flex; + overflow: hidden; + align-items: center; + gap: var(--g-spacing-1); + + box-sizing: border-box; + width: 100%; + height: 28px; + padding: 6px 9px; + + font-family: var(--g-text-body-font-family); + font-size: var(--g-text-body-short-font-size); + font-weight: var(--g-text-body-font-weight); + line-height: var(--g-text-body-short-line-height); + cursor: pointer; + text-align: left; + + color: var(--g-color-text-primary); + border: none; + border-radius: var(--g-border-radius-m); + outline: none; + background: none; + + &::before { + position: absolute; + inset: 0; + + content: ''; + pointer-events: none; + + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-border-radius-m); + } + + &:hover::before { + border-color: var(--g-color-line-generic-hover); + background-color: var(--g-color-base-simple-hover); + } + + &_open::before, + &:focus-visible::before { + border-color: var(--g-color-line-generic-active); + } + + &_invalid::before { + border-color: var(--g-color-line-danger); + } + } + + &__value { + overflow: hidden; + flex: 1; + + min-width: 0; + + white-space: nowrap; + text-overflow: ellipsis; + } + + &__placeholder { + overflow: hidden; + flex: 1; + + min-width: 0; + + white-space: nowrap; + text-overflow: ellipsis; + + color: var(--g-color-text-hint); + } + + &__badge { + flex-shrink: 0; + } + + &__chevron { + flex: 0 0 16px; + flex-shrink: 0; + + margin-inline-start: auto; + + color: var(--g-color-text-secondary); + + transition: transform 0.15s ease; + + &_open { + transform: rotate(180deg); + } + } + + &__panels { + --g-list-item-padding: var(--g-spacing-1) var(--g-spacing-4); + display: flex; + + min-height: 200px; + } + + &__panel { + overflow: hidden; + flex: 0 0 50%; + + padding-block-start: var(--g-spacing-2); + + &:not(:last-child) { + border-inline-end: 1px solid var(--g-color-line-generic); + } + } + + &__panel-header { + display: flex; + justify-content: space-between; + align-items: center; + + min-height: 24px; + margin-block-end: var(--g-spacing-2); + padding: var(--g-list-item-padding, var(--g-spacing-1) var(--g-spacing-2)); + } + + &__panel-title { + font-weight: var(--g-text-accent-font-weight); + } + + &__selector-item { + display: flex; + overflow: hidden; + align-items: center; + gap: var(--g-spacing-1); + + width: 100%; + + &:hover &-action, + &:focus-within &-action { + visibility: visible; + } + + &-text { + overflow: hidden; + flex: 1; + + white-space: nowrap; + text-overflow: ellipsis; + } + + &-type { + color: var(--g-color-text-secondary); + } + + &-action { + visibility: hidden; + flex-shrink: 0; + + border-radius: var(--g-border-radius-l); + + &_visible { + visibility: visible; + } + } + } + + &__popup-controls { + display: flex; + justify-content: flex-end; + gap: var(--g-spacing-2); + + padding: var(--g-spacing-4); + + border-top: 1px solid var(--g-color-line-generic); + } +} diff --git a/src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.tsx b/src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.tsx new file mode 100644 index 0000000000..2697ea7d54 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.tsx @@ -0,0 +1,253 @@ +import React from 'react'; + +import {ChevronDown, Xmark} from '@gravity-ui/icons'; +import {Button, Icon, Label, List, Popup} from '@gravity-ui/uikit'; + +import {cn} from '../../../../utils/cn'; +import i18n from '../i18n'; +import type {Column} from '../types'; + +import './ColumnSelectorField.scss'; + +interface ColumnSelectorFieldProps { + value: string[]; + onChange: (value: string[]) => void; + columns: Column[]; + invalid?: boolean; + className?: string; +} + +const b = cn('ydb-table-form-column-selector'); + +const getColumnId = (item: Column) => item.name; + +const filterColumnItem = (filter: string) => (item: Column) => { + const lower = filter.toLowerCase(); + return item.name.toLowerCase().includes(lower) || item.type.toLowerCase().includes(lower); +}; + +interface ItemSelectorProps { + items: Column[]; + value: string[]; + onUpdate: (value: string[]) => void; +} + +function ItemSelector({items, value, onUpdate}: ItemSelectorProps) { + const valueSet = React.useMemo(() => new Set(value), [value]); + const availableItems = React.useMemo( + () => items.filter((item) => !valueSet.has(getColumnId(item))), + [items, valueSet], + ); + const selectedItems = React.useMemo( + () => + value + .map((id) => items.find((item) => getColumnId(item) === id)) + .filter(Boolean) as Column[], + [value, items], + ); + + const handleSelect = React.useCallback( + (item: Column) => { + const id = getColumnId(item); + if (!valueSet.has(id)) { + onUpdate([...value, id]); + } + }, + [onUpdate, value, valueSet], + ); + + const handleRemove = React.useCallback( + (item: Column) => { + onUpdate(value.filter((v) => v !== getColumnId(item))); + }, + [onUpdate, value], + ); + + const handleSelectAll = React.useCallback(() => { + onUpdate(items.map(getColumnId)); + }, [items, onUpdate]); + + const handleClear = React.useCallback(() => { + onUpdate([]); + }, [onUpdate]); + + const renderAvailableItem = React.useCallback( + (item: Column, isActive: boolean) => ( +
+ + {item.name} + {item.type ? ( + ({item.type}) + ) : null} + + +
+ ), + [handleSelect], + ); + + const renderSelectedItem = React.useCallback( + (item: Column, isActive: boolean) => ( +
+ + {item.name} + {item.type ? ( + ({item.type}) + ) : null} + + +
+ ), + [handleRemove], + ); + + return ( +
+
+
+ {i18n('label_columns')} + +
+ + items={availableItems} + filterItem={filterColumnItem} + filterPlaceholder={i18n('label_search')} + itemsHeight={196} + renderItem={renderAvailableItem} + /> +
+
+
+ {i18n('label_selected')} + +
+ + items={selectedItems} + filterItem={filterColumnItem} + filterPlaceholder={i18n('label_search')} + itemsHeight={196} + renderItem={renderSelectedItem} + /> +
+
+ ); +} + +export function ColumnSelectorField({ + value, + onChange, + columns, + invalid, + className, +}: ColumnSelectorFieldProps) { + const [open, setOpen] = React.useState(false); + const [currentValue, setCurrentValue] = React.useState(undefined); + const controlRef = React.useRef(null); + + const items = React.useMemo(() => columns.filter(({name}) => Boolean(name)), [columns]); + + React.useEffect(() => { + setCurrentValue(undefined); + }, [value, columns]); + + React.useEffect(() => { + if (!value.length) { + return; + } + const available = new Set(items.map(getColumnId)); + const allPresent = value.every((id) => available.has(id)); + if (!allPresent) { + onChange(value.filter((id) => available.has(id))); + } + }, [items, value, onChange]); + + const handleToggle = React.useCallback(() => { + setOpen((prev) => !prev); + setCurrentValue(undefined); + }, []); + + const handleApply = React.useCallback(() => { + setOpen(false); + if (currentValue !== undefined) { + onChange(currentValue); + } + setCurrentValue(undefined); + }, [currentValue, onChange]); + + const handleCancel = React.useCallback(() => { + setOpen(false); + setCurrentValue(undefined); + }, []); + + return ( +
+ + { + if (!isOpen) { + handleCancel(); + } + }} + > + +
+ + +
+
+
+ ); +} diff --git a/src/containers/Tenant/TableFormDialog/components/layout.tsx b/src/containers/Tenant/TableFormDialog/components/layout.tsx new file mode 100644 index 0000000000..9ca71f50a6 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/components/layout.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import {HelpMark, Text} from '@gravity-ui/uikit'; + +import {cn} from '../../../../utils/cn'; + +const b = cn('ydb-table-form-dialog'); + +export function RequiredMark() { + return *; +} + +export function FormSection({ + title, + note, + children, +}: { + title?: string; + note?: string; + children: React.ReactNode; +}) { + return ( +
+ {title ? ( + + {title} + {note ? ( + + {note} + + ) : null} + + ) : null} + {children} +
+ ); +} + +export function FormRow({ + title, + note, + required, + htmlFor, + children, +}: { + title?: string; + note?: string; + required?: boolean; + htmlFor?: string; + children: React.ReactNode; +}) { + const labelTitle = title ? ( + + {title} + {required ? : null} + + ) : null; + let labelTitleNode: React.ReactNode = null; + + if (title) { + if (htmlFor) { + labelTitleNode = ( + + ); + } else { + labelTitleNode = {labelTitle}; + } + } + + return ( +
+
+ {labelTitleNode} + {note ? ( + + {note} + + ) : null} +
+
{children}
+
+ ); +} + +export function FormFieldError({message}: {message?: string}) { + if (!message) { + return null; + } + return ( + + {message} + + ); +} diff --git a/src/containers/Tenant/TableFormDialog/constants.ts b/src/containers/Tenant/TableFormDialog/constants.ts new file mode 100644 index 0000000000..0e6f29e21a --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/constants.ts @@ -0,0 +1,107 @@ +export const TABLE_FORM_DIALOG = 'table-form-dialog'; + +export const ENTITY_NAME_REG_EXP = /^[a-zA-Z0-9._-]+$/; +export const ENTITY_PATH_REG_EXP = /^[a-zA-Z0-9._-]+(\/[a-zA-Z0-9._-]+)*$/; +export const ENTITY_RENAME_PATH_REG_EXP = /^\/?[a-zA-Z0-9._-]+(\/[a-zA-Z0-9._-]+)*$/; +export const COLUMN_NAME_REG_EXP = /^\w[\w_-]*$/; + +export const MIN_PARTITION_SIZE_MB = 20; +export const MAX_PARTITION_SIZE_MB = 5120; + +export const MIN_PARTITIONS_COUNT = 1; +export const MAX_PARTITIONS_COUNT = 35000; + +export const MIN_COLUMN_PARTITION_COUNT = 1; +export const MAX_COLUMN_PARTITION_COUNT = 1000; + +export const SPLIT_POINT_STRING_TYPES = new Set(['String', 'Utf8']); + +export const YDB_TABLE_TYPES: string[] = [ + 'Bool', + 'Int8', + 'Int16', + 'Int32', + 'Int64', + 'Uint8', + 'Uint16', + 'Uint32', + 'Uint64', + 'Float', + 'Double', + 'Decimal(22,9)', + 'String', + 'Utf8', + 'Json', + 'JsonDocument', + 'Date32', + 'Date', + 'Datetime64', + 'Datetime', + 'Timestamp64', + 'Timestamp', + 'Interval64', + 'Interval', + 'Uuid', +]; + +export const YDB_COLUMN_TABLE_TYPES: string[] = [ + 'Int8', + 'Int16', + 'Int32', + 'Int64', + 'Uint8', + 'Uint16', + 'Uint32', + 'Uint64', + 'Float', + 'Double', + 'String', + 'Utf8', + 'Json', + 'JsonDocument', + 'Date32', + 'Date', + 'Datetime64', + 'Datetime', + 'Timestamp64', + 'Timestamp', +]; + +export const YDB_PK_TYPES = new Set([ + 'Bool', + 'Int8', + 'Int16', + 'Int32', + 'Int64', + 'Uint8', + 'Uint16', + 'Uint32', + 'Uint64', + 'String', + 'Utf8', + 'Date', + 'Date32', + 'Datetime', + 'Datetime64', + 'Timestamp', + 'Timestamp64', + 'Uuid', + 'Interval64', +]); + +export const YDB_COLUMN_PK_TYPES = new Set([ + 'Int32', + 'Int64', + 'Uint8', + 'Uint16', + 'Uint32', + 'Uint64', + 'String', + 'Utf8', + 'Date', + 'Date32', + 'Datetime', + 'Datetime64', + 'Timestamp', + 'Timestamp64', +]); diff --git a/src/containers/Tenant/TableFormDialog/i18n/en.json b/src/containers/Tenant/TableFormDialog/i18n/en.json new file mode 100644 index 0000000000..80d7e589e3 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/i18n/en.json @@ -0,0 +1,145 @@ +{ + "title_create": "Create table", + "title_update": "Edit table", + + "action_create": "Create", + "action_update": "Update", + "action_add": "Add", + "action_set-value": "Set value", + "action_cancel": "Cancel", + "action_delete": "Delete", + "action_apply": "Apply", + "action_clear": "Clear", + "action_select": "Select", + "action_select-all": "Select all", + "action_undo": "Undo", + + "alert_create-success": "Table created", + "alert_create-error": "Failed to create table", + "alert_update-success": "Table updated", + "alert_update-error": "Failed to update table", + + "error_load-table": "Failed to load table", + "error_required": "Empty field", + "error_name-pattern": "Can only contain Latin letters, numbers, underscores, dashes, and dots.", + "error_name-path-pattern": "Can only contain Latin letters, numbers, underscores, dashes, dots, and slashes between path segments.", + "error_column-name-pattern": "Only word characters, underscores and dashes are allowed", + "error_partition-size": "Value must be between 20 and 5120 MB", + "error_partitions-count": "Value must be between 1 and 35000", + "error_partition-count-range": "Value must be between 1 and 1000", + "error_columns-empty": "Columns are empty", + "error_indexes-key": "Incorrect set of columns in the key", + "error_partitions-at-keys-invalid": "Invalid split points", + "error_value-invalid": "Incorrect value in field", + + "button_add-column": "Add column", + "button_add-index": "Add index", + "button_add-split-point": "Add split point", + + "column_default": "Default value", + "column_index-key": "Key", + "column_name": "Name", + "column_not-null": "NOT NULL constraint", + "column_primary-key": "Primary key", + "column_type": "Type", + + "field_autopartition-by-load": "By load", + "field_autopartition-by-size": "By size", + "field_autopartition-by-size-mb": "Auto-partitioning by size in MB", + "field_autopartition-max": "Maximum number of partitions", + "field_autopartition-min": "Minimum number of partitions", + "field_explicit-partitions": "Split points", + "field_key-bloom-filter": "Bloom filter for primary key", + "field_inside": "Inside", + "field_name": "Name", + "field_partition-count": "Partition count", + "field_partition-key": "Partition key", + "field_partitions-type": "Type", + "field_ttl-column": "Column", + "field_ttl-lifetime": "Lifetime", + "field_ttl-status": "Status", + "field_ttl-unit": "Unit", + "field_type": "Table type", + "field_uniform-partitions": "Count", + + "label_autoincrement": "Autoincrement", + "label_autoincrement-note-type": "Permitted type for autoincrement: Int16, Int32, or Int64", + "label_column-table": "Column table", + "label_columns": "Columns", + "label_enable": "Enable", + "label_indexes": "Secondary indexes", + "label_info-table-type_column": "A column table is a relational table that stores a set of related data organized into rows and columns. Unlike traditional row-based tables designed for OLTP workloads, column tables are optimized for data analytics and OLAP workloads.", + "label_info-table-type_row": "A row table always has one or more columns that make up a primary key. Table rows are unique by key, meaning there can only be one row for a single key value. Row tables are always ordered by key. This means that point reads by key and range-based queries by key are performed efficiently (actually using an index). The sequence of columns in a key matters. Tables only consisting of key columns are allowed. You can't create tables without a primary key.", + "label_items": "Items", + "label_not-null-note-autoincrement": "Autoincrement columns are always NOT NULL", + "label_not-null-note-key": "Key columns must have this constraint", + "label_partitioning": "Partitioning", + "label_row-table": "Row table", + "label_section-advanced": "Advanced settings", + "label_section-autopartition": "Autopartitioning", + "label_section-general": "General", + "label_section-partition-policy": "Partition policy", + "label_section-ttl": "TTL Settings", + "label_search": "Search", + "label_select-columns": "Select 1 or more columns", + "label_selected": "Selected", + "label_ttl-remove-column-text": "Your table will stop clearing.", + "label_ttl-remove-column-warning": "This is a TTL-column. Do you really want to delete it?", + "label_ttl-warning": "You don't have columns with the right type", + + "title_split-point": "Split point", + + "context_type-bool": "Can store either true or false", + "context_type-int8": "Signed integer from −2^7 to 2^7 − 1", + "context_type-int16": "Signed integer from −2^15 to 2^15 − 1", + "context_type-int32": "Signed integer from −2^31 to 2^31 − 1", + "context_type-int64": "Signed integer from −2^63 to 2^63 − 1", + "context_type-uint8": "Unsigned integer, from 0 to 2^8 − 1", + "context_type-uint16": "Unsigned integer, from 0 to 2^16 − 1", + "context_type-uint32": "Unsigned integer, from 0 to 2^32 − 1", + "context_type-uint64": "Unsigned integer, from 0 to 2^64 − 1", + "context_type-decimal22-9": "Fixed-precision number — 22 digits, 9 digits after the decimal point", + "context_type-float": "Floating-point number 4 byte", + "context_type-double": "Double type have 8 bytes", + "context_type-string": "Arbitrary byte sequence", + "context_type-utf8": "Text encoded in UTF-8", + "context_type-json": "Valid JSON in text representation", + "context_type-jsondocument": "Valid JSON in binary indexed representation", + "context_type-date": "Precision up to days, YYYY-MM-DD", + "context_type-datetime": "Precision up to seconds, YYYY-MM-DDThh:mm:ssZ", + "context_type-timestamp": "Precision up to microseconds, YYYY-MM-DDThh:mm:ss.SSSZ", + "context_type-interval": "Time interval (signed), accuracy up to microseconds. The range of values is from -136 years to +136 years. The internal representation is a 64-bit signed integer.", + "context_type-uuid": "Universally unique identifier (UUID)", + + "tooltip_autopartition-by-load": "A table shard is split into two if it is under high loads for a certain period of time (uses a lot of CPU time)", + "tooltip_autopartition-by-size": "A table shard is split into two when the specified data size is reached", + "tooltip_autopartition-max": "The number of table shards above which no splitting by size or load is performed", + "tooltip_autopartition-min": "The number of table shards below which no shard merge by size or load is performed", + "tooltip_explicit-partitions": "Shard boundary keys during initial partitioning", + "tooltip_index-key": "A table may have multiple secondary indexes, and an index may include multiple columns. The sequence of columns in an index matters. A single column may consist of multiple indexes and even be part of a primary key and a secondary index at the same time.", + "tooltip_key-bloom-filter": "In some cases, it can speed up key reads", + "tooltip_partitions-type": "Evenly: For Uint32 and Uint64 key columns, it splits the entire range from 0 to the maximum value of the type into intervals of the same length. It's well suited for even distribution of data across shards when an evenly distributed hash function is used as key values.\nExplicitly: Lets you set shard boundary keys for initial partitioning.", + "tooltip_primary-key": "A YDB table always has one or more columns that make up a primary key. Table rows are unique by key, meaning there can only be one row for a single key value. YDB tables are always ordered by key. The sequence of columns in a key matters. Tables only consisting of key columns are allowed. You can't create tables without a primary key.", + "tooltip_section-autopartition": "Automatically split shards when the size or load increases and merge them when the size or load decreases", + "tooltip_section-ttl": "Automatically delete expired rows from a table.", + "tooltip_ttl-column": "Date, Datetime, Timestamp, Uint32, Uint64 columns are supported.", + "tooltip_ttl-lifetime": "The row will be considered as expired at the moment of time, when the value stored in column is less than or equal to the current time, and lifetime has passed.", + "tooltip_ttl-unit": "The numbers in the column are interpreted as values from the Unix epoch with a given unit.", + + "value_disabled": "Disabled", + "value_enabled": "Enabled", + "value_no": "No", + "value_yes": "Yes", + "value_partitions-explicit": "Explicitly", + "value_partitions-none": "None", + "value_partitions-uniform": "Evenly", + "value_megabyte": "MB", + "value_seconds": "seconds", + "value_minutes": "minutes", + "value_hours": "hours", + "value_days": "days", + "value_epoch-seconds": "Seconds", + "value_epoch-milliseconds": "Milliseconds", + "value_epoch-microseconds": "Microseconds", + "value_epoch-nanoseconds": "Nanoseconds" +} diff --git a/src/containers/Tenant/TableFormDialog/i18n/index.ts b/src/containers/Tenant/TableFormDialog/i18n/index.ts new file mode 100644 index 0000000000..6109fb6166 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-table-form-dialog'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Tenant/TableFormDialog/sections/GeneralSection.tsx b/src/containers/Tenant/TableFormDialog/sections/GeneralSection.tsx new file mode 100644 index 0000000000..b57e69aab4 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/sections/GeneralSection.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import type {SelectOption} from '@gravity-ui/uikit'; +import {Select, Text, TextInput} from '@gravity-ui/uikit'; +import {Controller, useFormContext, useWatch} from 'react-hook-form'; + +import {cn} from '../../../../utils/cn'; +import {FormRow, FormSection} from '../components/layout'; +import i18n from '../i18n'; +import type {FormMode, FormValues, TableType} from '../types'; + +const b = cn('ydb-table-form-dialog'); + +interface GeneralSectionProps { + mode: FormMode; + insidePath?: string; + nameInputRef?: React.Ref; +} + +const tableTypeInfo: Record = { + row: i18n('label_info-table-type_row'), + column: i18n('label_info-table-type_column'), +}; + +export function GeneralSection({mode, insidePath, nameInputRef}: GeneralSectionProps) { + const {control, formState} = useFormContext(); + const type = useWatch({control, name: 'type'}); + + const typeOptions = React.useMemo( + () => [ + {value: 'row', content: i18n('label_row-table')}, + {value: 'column', content: i18n('label_column-table')}, + ], + [], + ); + + const nameError = formState.errors.name?.message; + const nameDisabled = mode === 'update' && type === 'column'; + const typeDisabled = mode === 'update'; + + return ( + + {insidePath ? ( + + + {`${insidePath}/`} + + + ) : null} + + ( + + )} + /> + + +
+ ( +