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} span { + min-width: 0; + overflow-wrap: anywhere; + } } - &__description { - color: var(--g-color-text-secondary); + + &__breadcrumb-icon { + flex: 0 0 auto; + + opacity: 0.3; + color: var(--g-color-text-primary); } - &__input-wrapper { - min-height: 48px; + + &__directory-path { + font-weight: 600; } } diff --git a/src/containers/Tenant/ObjectSummary/CreateDirectoryDialog/CreateDirectoryDialog.tsx b/src/containers/Tenant/ObjectSummary/CreateDirectoryDialog/CreateDirectoryDialog.tsx index 8b635a383f..99d34dacd6 100644 --- a/src/containers/Tenant/ObjectSummary/CreateDirectoryDialog/CreateDirectoryDialog.tsx +++ b/src/containers/Tenant/ObjectSummary/CreateDirectoryDialog/CreateDirectoryDialog.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import {Dialog, TextInput} from '@gravity-ui/uikit'; +import {DatabaseFill, FolderFill} from '@gravity-ui/icons'; +import {Breadcrumbs, Dialog, TextInput} from '@gravity-ui/uikit'; import {ResponseError} from '../../../../components/Errors/ResponseError'; import {useClusterWithProxy} from '../../../../store/reducers/cluster/cluster'; @@ -48,6 +49,14 @@ export function CreateDirectoryDialog({ const [create, response] = schemaApi.useCreateDirectoryMutation(); const inputRef = React.useRef(null); + const normalizedDatabasePath = databaseFullPath.replace(/^\/+|\/+$/g, ''); + const normalizedParentPath = parentPath.replace(/^\/+|\/+$/g, ''); + const relativeParentPath = transformPath(parentPath, databaseFullPath).replace( + /^\/+|\/+$/g, + '', + ); + const isDatabaseRoot = normalizedParentPath === normalizedDatabasePath; + const resetErrors = () => { setValidationError(''); response.reset(); @@ -80,43 +89,54 @@ export function CreateDirectoryDialog({ }); }; - const relativeParentPath = transformPath(parentPath, databaseFullPath); - return (
{ e.preventDefault(); - const validationError = validateRelativePath(relativePath); - setValidationError(validationError); - if (!validationError) { + const nextValidationError = validateRelativePath(relativePath); + setValidationError(nextValidationError); + if (!nextValidationError) { handleSubmit(); } }} > - -
- +
+ + + + + {normalizedDatabasePath} + + + {!isDatabaseRoot && ( + + + + + {relativeParentPath} + + + + )} +
+ {response.isError && ( { 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..cd5b749db8 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/TableFormDialog.scss @@ -0,0 +1,589 @@ +.ydb-table-form-dialog { + max-height: 90vh; + + &__body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + + min-height: 0; + padding-top: var(--g-spacing-5); + padding-bottom: 0; + } + + &__form { + display: flex; + flex: 1 1 auto; + flex-direction: column; + + width: 100%; + min-height: 0; + } + + &__scroll-container { + overflow: hidden auto; + overscroll-behavior: none; + flex: 1 1 auto; + + min-height: 0; + margin-inline: calc(-1 * var(--_--side-padding)); + padding-inline: var(--_--side-padding); + + background: + linear-gradient(var(--g-color-base-modal), var(--g-color-base-modal)) bottom / 100% 5px + no-repeat local, + linear-gradient(var(--g-color-line-generic), var(--g-color-line-generic)) bottom / 100% + 1px no-repeat scroll; + background-color: var(--g-color-base-modal); + } + + &__scroll-content { + padding-bottom: var(--g-spacing-2); + } + + &__section + &__section { + margin-top: var(--g-spacing-6); + padding-top: var(--g-spacing-6); + + border-top: 1px solid var(--g-color-line-generic); + } + + &__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; + + color: var(--g-color-text-secondary); + } + + &__label-title { + min-width: 0; + } + + &__help-mark { + flex-shrink: 0; + } + + &__help-mark-popup { + max-width: 300px; + } + + &__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: 2px; + } + + &__type-select-row { + display: flex; + align-items: center; + gap: var(--g-spacing-2); + } + + &__type-select-control, + &__type-select-control.g-select { + width: 320px; + max-width: 100%; + } + + &__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 { + --columns-table-separator-width: calc(758px + 5 * var(--g-spacing-4)); + display: flex; + flex-direction: column; + gap: var(--g-spacing-3); + } + + &__columns-head { + display: grid; + align-items: center; + grid-template-columns: 180px 180px 100px 100px 170px 28px; + gap: var(--g-spacing-4); + + font-weight: 500; + + color: var(--g-color-text-primary); + } + + &__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: 205px 180px 180px 180px 28px; + } + + &__columns-table_update { + --columns-table-separator-width: calc(773px + 4 * var(--g-spacing-4)); + } + + &__columns-table_no-default &__columns-head, + &__columns-table_no-default &__columns-row { + grid-template-columns: 180px 180px 100px 100px 28px; + } + + &__columns-table_no-default { + --columns-table-separator-width: calc(588px + 4 * var(--g-spacing-4)); + } + + #{&}__columns-table_update#{&}__columns-table_no-default #{&}__columns-head, + #{&}__columns-table_update#{&}__columns-table_no-default #{&}__columns-row { + grid-template-columns: 205px 180px 180px 28px; + } + + #{&}__columns-table_update#{&}__columns-table_no-default { + --columns-table-separator-width: calc(593px + 3 * var(--g-spacing-4)); + } + + &__columns-row { + display: grid; + align-items: start; + grid-template-columns: 180px 180px 100px 100px 170px 28px; + gap: var(--g-spacing-4); + } + + &__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_action { + display: flex; + align-items: center; + + min-height: 28px; + } + + &__columns-cell_default { + display: flex; + align-items: flex-start; + + min-height: 28px; + } + + &__columns-cell_action { + justify-content: center; + } + + &__columns-action-popover-target { + display: inline-flex; + } + + &__ttl-delete-popover { + max-width: 280px; + padding: var(--g-spacing-3); + } + + &__columns-separator { + align-self: flex-start; + + width: var(--columns-table-separator-width); + max-width: 100%; + margin: 0; + + border: none; + border-top: 1px solid var(--g-color-line-generic); + } + + &__default-row { + display: grid; + align-items: center; + grid-template-columns: max-content minmax(0, 1fr); + gap: var(--g-spacing-1) var(--g-spacing-2); + + width: 100%; + min-width: 0; + } + + &__default-value-toggle { + display: flex; + align-items: center; + grid-column: 1; + + min-height: 28px; + } + + &__default-value-input { + grid-column: 2; + + min-width: 0; + } + + &__default-value-input .g-text-input { + width: 100%; + max-width: 100%; + } + + &__default-value-error { + grid-column: 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-points-field { + display: flex; + flex-direction: column; + gap: var(--g-spacing-3); + + margin-top: var(--g-spacing-4); + } + + &__split-points-header { + display: flex; + align-items: center; + gap: var(--g-spacing-1); + } + + &__split-points-title { + font-weight: 500; + } + + &__split-points-content { + width: 420px; + max-width: 100%; + } + + &__split-point-row { + display: flex; + align-items: center; + gap: var(--g-spacing-2); + } + + &__split-point-index { + flex: 0 0 auto; + + min-width: 20px; + } + + &__split-point-display, + &__split-point-display.g-text-input { + flex: 1 1 auto; + + width: 100%; + min-width: 0; + max-width: 100%; + } + + &__split-point-dialog { + --split-point-dialog-min-width: 460px; + --split-point-dialog-max-width: 720px; + --split-point-column-gap: var(--g-spacing-10); + --split-point-inner-gap: var(--g-spacing-3); + --split-point-input-min-width: 248px; + + box-sizing: border-box; + width: fit-content; + min-width: min(var(--split-point-dialog-min-width), calc(100vw - var(--g-spacing-8))); + max-width: min(var(--split-point-dialog-max-width), calc(100vw - var(--g-spacing-8))); + } + + &__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: grid; + align-items: start; + grid-template-columns: + fit-content( + calc( + var(--split-point-dialog-max-width) - var(--split-point-input-min-width) - var( + --split-point-column-gap + ) + ) + ) + minmax(var(--split-point-input-min-width), 1fr); + gap: var(--g-spacing-3) var(--split-point-column-gap); + + width: 100%; + min-width: 0; + max-width: 100%; + } + + &__split-point-dialog-row { + display: contents; + } + + &__split-point-meta { + min-width: 0; + padding-top: 5px; + } + + &__split-point-control { + display: flex; + align-items: flex-start; + gap: var(--split-point-inner-gap); + + width: 100%; + min-width: 0; + } + + &__split-point-toggle { + display: flex; + flex: 0 0 auto; + justify-content: flex-start; + align-items: center; + + min-height: 28px; + } + + &__split-point-value { + flex: 1 1 auto; + + min-width: 0; + } + + &__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); + + min-width: 0; + overflow-wrap: anywhere; + + font-weight: 500; + } + + &__split-point-type { + font-weight: 400; + cursor: help; + + color: var(--g-color-text-secondary); + } + + &__split-point-type-popover { + max-width: 300px; + padding: var(--g-spacing-3); + } + + &__not-null-label { + margin-inline-start: var(--g-spacing-1); + + 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-4); + + .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; + } + + &__columns-type-popup.g-select-popup { + max-height: 350px; + } + + &__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..b0576c199e --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/TableFormDialog.tsx @@ -0,0 +1,458 @@ +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 {FormProvider, useForm, useWatch} from 'react-hook-form'; + +import {ResponseError} from '../../../components/Errors/ResponseError'; +import {Loader} from '../../../components/Loader'; +import {useClusterWithProxy} from '../../../store/reducers/cluster/cluster'; +import {tableApi} from '../../../store/reducers/table/table'; +import { + getTablePathInfoForUpdate, + getUpdateTableSettings, + hasUpdateTableSettings, +} 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 { + 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, + getTableQueryArgs, + getUpdateInitialValues, +} from './utils'; +import {buildTableValidationSchema} from './validation'; + +import './TableFormDialog.scss'; + +const b = cn('ydb-table-form-dialog'); + +type DialogSuccessHandler = (path: string) => void; + +interface CommonDialogProps { + mode: FormMode; + database: string; + databaseFullPath: string; + parentPath?: string; + path?: string; + onSuccess?: DialogSuccessHandler; +} + +interface TableFormDialogNiceModalProps extends CommonDialogProps { + onClose?: () => void; +} + +interface TableFormDialogInnerProps extends Omit { + open: boolean; + onClose: () => void; + onSuccess: DialogSuccessHandler; +} + +interface TableFormProps { + mode: FormMode; + database: string; + databaseFullPath: string; + parentPath?: string; + path?: string; + initialValues: FormValues; + originalInfo?: OriginalTableInfo; + originalTable?: TEvDescribeSchemeResult; + onClose: () => void; + onSuccess: DialogSuccessHandler; + nameInputRef?: React.Ref; +} + +function hasRenameConflict({ + mode, + type, + originalName, + originalTable, + name, + addedColumnsCount, + deletedColumnsCount, + updateSettings, +}: { + mode: FormMode; + type: TableType; + originalName?: string; + originalTable?: TEvDescribeSchemeResult; + name: string; + addedColumnsCount: number; + deletedColumnsCount: number; + updateSettings: ReturnType; +}) { + if ( + mode !== 'update' || + type !== 'row' || + !originalTable || + !originalName || + name === originalName + ) { + return false; + } + + const originalHadTtl = Boolean( + originalTable.PathDescription?.Table?.TTLSettings?.Enabled ?? + originalTable.PathDescription?.ColumnTableDescription?.TtlSettings?.Enabled, + ); + + return ( + deletedColumnsCount > 0 || + addedColumnsCount > 0 || + hasUpdateTableSettings(updateSettings) || + (updateSettings?.ttl?.status === 'disabled' && originalHadTtl) + ); +} + +function buildTablePath(parentPath: string, name: string) { + const trimmedParentPath = parentPath.replace(/\/+$/, ''); + const trimmedName = name.replace(/^\/+|\/+$/g, ''); + return `${trimmedParentPath}/${trimmedName}`; +} + +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, + clearErrors, + setValue, + setError, + trigger, + formState: {dirtyFields, errors}, + } = methods; + const name = useWatch({control, name: 'name'}); + const type: TableType = useWatch({control, name: 'type'}); + const columns = useWatch({control, name: 'columns'}); + const deletedColumns = useWatch({control, name: 'deletedColumns'}); + const settings = useWatch({control, name: 'settings'}); + + 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}); + trigger([ + 'columns', + 'secondaryIndexes', + 'partitionKey', + 'partitionCount', + 'settings', + ]).catch(() => undefined); + }, [mode, type, setValue, trigger]); + + const isSubmitting = createState.isLoading || updateState.isLoading; + const renameConflictMessage = i18n('error_rename-with-other-changes'); + const updateSettings = getUpdateTableSettings(settings, dirtyFields.settings); + const hasNameConflict = hasRenameConflict({ + mode, + type, + originalName: originalInfo?.name, + originalTable, + name, + addedColumnsCount: columns.length, + deletedColumnsCount: deletedColumns.length, + updateSettings, + }); + + React.useEffect(() => { + if (hasNameConflict) { + setError('name', {type: 'manual', message: renameConflictMessage}); + return; + } + + if (errors.name?.message === renameConflictMessage) { + clearErrors('name'); + } + }, [clearErrors, errors.name?.message, hasNameConflict, renameConflictMessage, setError]); + + const handleFormSubmit = handleSubmit(async (formValues) => { + if (hasNameConflict) { + setError('name', {type: 'manual', message: renameConflictMessage}); + return; + } + + 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, + }); + onSuccess(fullName); + return; + } + + if (!originalTable || !path) { + throw new Error('Original table is required for update'); + } + const nextUpdateSettings = getUpdateTableSettings( + formValues.settings, + dirtyFields.settings, + ); + + await updateTable({ + database, + formValues, + originalTable, + updateSettings: nextUpdateSettings, + }).unwrap(); + createToast({ + name: 'table-update-success', + title: i18n('alert_update-success'), + theme: 'success', + autoHiding: 5000, + }); + + const {updatedTablePath} = getTablePathInfoForUpdate(originalTable, formValues.name); + onSuccess(updatedTablePath); + } 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 useMetaProxy = useClusterWithProxy(); + const tableQuery = tableApi.useGetTableQuery( + getTableQueryArgs({mode, path, database, databaseFullPath, useMetaProxy}), + {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__/SplitPointDialog.test.ts b/src/containers/Tenant/TableFormDialog/__test__/SplitPointDialog.test.ts new file mode 100644 index 0000000000..76076e673a --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/__test__/SplitPointDialog.test.ts @@ -0,0 +1,34 @@ +import {buildSplitPointEntries} from '../sections/SplitPointDialog'; +import type {ColumnField} from '../types'; + +describe('SplitPointDialog', () => { + test('preserves saved isDefined flag for autoincrement split points', () => { + const pkColumns: ColumnField[] = [ + { + _id: 'column-id', + name: 'id', + type: 'Int64', + key: true, + notNull: true, + autoincrement: true, + withDefaultValue: false, + }, + ]; + + const [entry] = buildSplitPointEntries(pkColumns, [ + { + id: 'split-point-id', + name: 'id', + type: 'Int64', + key: true, + notNull: true, + autoincrement: true, + isDefined: true, + value: '42', + }, + ]); + + expect(entry.isDefined).toBe(true); + expect(entry.value).toBe('42'); + }); +}); diff --git a/src/containers/Tenant/TableFormDialog/__test__/columnValueValidation.test.ts b/src/containers/Tenant/TableFormDialog/__test__/columnValueValidation.test.ts new file mode 100644 index 0000000000..44e8a7b638 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/__test__/columnValueValidation.test.ts @@ -0,0 +1,65 @@ +import {isValueForTypeValid} from '../columnValueValidation'; + +describe('columnValueValidation', () => { + test.each([ + ['Date', '2025-00-01'], + ['Date', '2025-01-00'], + ['Date', '2025-02-29'], + ['Date', '2025-04-31'], + ['Date', '1969-12-31'], + ['Date', '2107-01-01'], + ['Date32', '2025-00-01'], + ['Date32', '2025-01-00'], + ['Date32', '2025-02-29'], + ['Date32', '2025-04-31'], + ['Datetime', '2025-00-01T00:00:00Z'], + ['Datetime', '2025-01-00T00:00:00Z'], + ['Datetime', '2025-02-31T00:00:00Z'], + ['Datetime', '2025-01-01T24:00:00Z'], + ['Datetime', '2025-01-01T23:60:00Z'], + ['Datetime', '2025-01-01T23:59:60Z'], + ['Datetime', '1969-12-31T23:59:59Z'], + ['Datetime', '2107-01-01T00:00:00Z'], + ['Datetime64', '2025-00-01T00:00:00Z'], + ['Datetime64', '2025-01-00T00:00:00Z'], + ['Datetime64', '2025-02-31T00:00:00Z'], + ['Datetime64', '2025-01-01T24:00:00Z'], + ['Timestamp', '2025-00-01T00:00:00.000001Z'], + ['Timestamp', '2025-01-00T00:00:00.000001Z'], + ['Timestamp', '2025-02-31T00:00:00.000001Z'], + ['Timestamp', '2025-01-01T24:00:00.000001Z'], + ['Timestamp', '2025-01-01T23:60:00.000001Z'], + ['Timestamp', '2025-01-01T23:59:60.000001Z'], + ['Timestamp', '1969-12-31T23:59:59.000001Z'], + ['Timestamp', '2107-01-01T00:00:00.000001Z'], + ['Timestamp64', '2025-00-01T00:00:00.000001Z'], + ['Timestamp64', '2025-01-00T00:00:00.000001Z'], + ['Timestamp64', '2025-02-31T00:00:00.000001Z'], + ['Timestamp64', '2025-01-01T24:00:00.000001Z'], + ])('rejects invalid %s values', (type, value) => { + expect(isValueForTypeValid(value, type)).toBe(false); + }); + + test.each([ + ['Date', '2025-01-02'], + ['Date', '2024-02-29'], + ['Date', '1970-01-01'], + ['Date', '2106-01-01'], + ['Date32', '2025-01-02'], + ['Date32', '2024-02-29'], + ['Datetime', '2025-01-02T03:04:05Z'], + ['Datetime', '2024-02-29T03:04:05Z'], + ['Datetime', '1970-01-01T00:00:00Z'], + ['Datetime', '2106-01-01T00:00:00Z'], + ['Datetime64', '2025-01-02T03:04:05Z'], + ['Datetime64', '2024-02-29T03:04:05Z'], + ['Timestamp', '2025-01-02T03:04:05.000001Z'], + ['Timestamp', '2024-02-29T03:04:05.000001Z'], + ['Timestamp', '1970-01-01T00:00:00.000001Z'], + ['Timestamp', '2106-01-01T00:00:00.000001Z'], + ['Timestamp64', '2025-01-02T03:04:05.000001Z'], + ['Timestamp64', '2024-02-29T03:04:05.000001Z'], + ])('keeps accepting valid %s values', (type, value) => { + expect(isValueForTypeValid(value, type)).toBe(true); + }); +}); diff --git a/src/containers/Tenant/TableFormDialog/__test__/utils.test.ts b/src/containers/Tenant/TableFormDialog/__test__/utils.test.ts new file mode 100644 index 0000000000..21c262d533 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/__test__/utils.test.ts @@ -0,0 +1,77 @@ +import {skipToken} from '@reduxjs/toolkit/query'; + +import {EPathType} from '../../../../types/api/schema/schema'; +import {describeOriginalTable, getTableQueryArgs} from '../utils'; + +describe('TableFormDialog utils', () => { + test('describeOriginalTable exposes the current backend ttl column and index columns', () => { + const rowTable = describeOriginalTable({ + PathDescription: { + Self: {Name: 'orders', PathType: EPathType.EPathTypeTable}, + Table: { + Columns: [], + KeyColumnNames: [], + TableIndexes: [ + { + Name: 'by_status', + KeyColumnNames: ['status'], + DataColumnNames: ['createdAt'], + }, + ], + TTLSettings: {Enabled: {ColumnName: 'createdAt'}}, + }, + }, + } as never); + + const columnTable = describeOriginalTable({ + PathDescription: { + Self: {Name: 'events', PathType: EPathType.EPathTypeColumnTable}, + ColumnTableDescription: { + Schema: {Columns: [], KeyColumnNames: []}, + TtlSettings: {Enabled: {ColumnName: 'eventAt'}}, + }, + }, + } as never); + + expect(rowTable).toMatchObject({ + hasTtl: true, + ttlColumn: 'createdAt', + indexes: [{name: 'by_status', columns: ['status', 'createdAt']}], + }); + expect(columnTable).toMatchObject({ + hasTtl: true, + ttlColumn: 'eventAt', + }); + }); + + test('getTableQueryArgs preserves meta-proxy context for update mode', () => { + expect( + getTableQueryArgs({ + mode: 'update', + path: '/Root/db1/orders', + database: '/Root/db1', + databaseFullPath: '/Root/db1', + useMetaProxy: true, + }), + ).toEqual({ + database: '/Root/db1', + path: { + path: '/Root/db1/orders', + databaseFullPath: '/Root/db1', + useMetaProxy: true, + }, + }); + }); + + test('getTableQueryArgs skips the load query outside update mode', () => { + expect( + getTableQueryArgs({ + mode: 'create', + path: '/Root/db1/orders', + database: '/Root/db1', + databaseFullPath: '/Root/db1', + useMetaProxy: true, + }), + ).toBe(skipToken); + }); +}); 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..be3e9d4e54 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/__test__/validation.test.ts @@ -0,0 +1,528 @@ +import {MAX_PARTITION_SIZE_MB} from '../constants'; +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: MAX_PARTITION_SIZE_MB, + 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('create mode requires at least one primary key column', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [createColumn({key: false})], + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('columns'); + }); + + test('rejects duplicate column names in create mode before submit', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [createColumn(), createColumn({_id: 'second', key: false})], + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toEqual( + expect.arrayContaining(['columns.0.name', 'columns.1.name']), + ); + }); + + test.each([ + ['Bool', 'maybe'], + ['Int64', 'abc'], + ['Int64', ''], + ['Date', '2025-13-40'], + ['Json', ''], + ])('rejects invalid default value %s=%s before submit', (type, defaultValue) => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [ + createColumn({ + type, + key: false, + withDefaultValue: true, + defaultValue, + }), + ], + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('columns.0.defaultValue'); + }); + + test.each([ + ['Int64', '42'], + ['Bool', 'true'], + ['Date', '2025-01-02'], + ['Utf8', ''], + ['Utf8', '$value'], + ['Json', '{"ok":true}'], + ])('accepts valid default value %s=%s', (type, defaultValue) => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [ + createColumn(), + createColumn({ + _id: 'with-default', + name: 'value_with_default', + type, + key: false, + withDefaultValue: true, + defaultValue, + }), + ], + }), + ); + + expect(result.success).toBe(true); + }); + + test('create mode ignores hidden default values for key autoincrement columns', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [ + createColumn({ + withDefaultValue: true, + autoincrement: true, + defaultValue: 'not-a-number', + }), + ], + }), + ); + + expect(result.success).toBe(true); + }); + + test('create mode accepts slash-separated table names for column tables', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + name: 'dir.v1/table-name_2', + type: 'column', + partitionKey: ['id'], + partitionCount: 64, + }), + ); + + expect(result.success).toBe(true); + }); + + 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('rejects table names with path segments longer than 255 characters', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + name: `${'a'.repeat(256)}/table`, + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('name'); + }); + + 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('rejects duplicate secondary index names before submit', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [ + createColumn(), + createColumn({_id: 'second', name: 'status', key: false, type: 'Utf8'}), + ], + secondaryIndexes: [ + {name: 'by_status', key: ['status']}, + {name: 'by_status', key: ['id']}, + ], + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toEqual( + expect.arrayContaining(['secondaryIndexes.0.name', 'secondaryIndexes.1.name']), + ); + }); + + test('existing secondary index columns cannot be deleted', () => { + const originalInfo: OriginalTableInfo = { + name: 'orders', + type: 'row', + columns: [{name: 'status', type: 'Utf8', notNull: false}] as Column[], + partitionKey: [], + indexes: [{name: 'by_status', columns: ['status']}], + hasTtl: false, + hasMinPartitions: false, + hasMaxPartitions: false, + }; + const schema = buildTableValidationSchema({mode: 'update', originalInfo}); + + const result = schema.safeParse( + createValues({ + name: 'orders', + deletedColumns: [{name: 'status', type: 'Utf8', notNull: false}], + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('columns'); + }); + + test('rejects new columns that duplicate existing non-deleted columns in update mode', () => { + const originalInfo: OriginalTableInfo = { + name: 'orders', + type: 'row', + columns: [{name: 'status', 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', + columns: [createColumn({name: 'status', key: false})], + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('columns.0.name'); + }); + + 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('explicit partition split points must match current primary key columns', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [createColumn({type: 'Utf8'})], + settings: { + partitionsType: PartitionsType.Explicit, + partitionsAtKeys: [ + [ + { + id: 'split-point-id', + name: 'id', + type: 'Int64', + key: true, + notNull: true, + isDefined: true, + value: '42', + } as never, + ], + ], + ttl: {status: 'disabled'}, + }, + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('settings.partitionsAtKeys'); + }); + + test('uniform partitions require Uint32 or Uint64 as the first primary key column', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + settings: { + partitionsType: PartitionsType.Uniform, + uniformPartitions: 4, + ttl: {status: 'disabled'}, + }, + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('settings.uniformPartitions'); + }); + + test('uniform partitions accept Uint64 as the first primary key column', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [createColumn({type: 'Uint64'})], + settings: { + partitionsType: PartitionsType.Uniform, + uniformPartitions: 4, + ttl: {status: 'disabled'}, + }, + }), + ); + + expect(result.success).toBe(true); + }); + + test('ttl validation requires column and lifetime when ttl is enabled', () => { + 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.lifetime']), + ); + }); + + test('ttl validation requires epoch mode for numeric ttl columns', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [ + createColumn(), + createColumn({ + _id: 'ttl-column', + name: 'expireAt', + type: 'Uint64', + key: false, + }), + ], + settings: { + partitionsType: PartitionsType.None, + ttl: { + status: 'enabled', + column: 'expireAt', + columnWithEpochMode: true, + lifetime: 1, + }, + }, + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('settings.ttl.epochMode'); + }); + + test('ttl validation rejects stale selected columns after type changes', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + columns: [ + createColumn(), + createColumn({ + _id: 'ttl-column', + name: 'expiresAt', + type: 'Utf8', + key: false, + }), + ], + settings: { + partitionsType: PartitionsType.None, + ttl: { + status: 'enabled', + column: 'expiresAt', + columnWithEpochMode: true, + lifetime: 1, + }, + }, + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toContain('settings.ttl.column'); + expect(getIssuePaths(result)).not.toContain('settings.ttl.epochMode'); + }); + + test('rejects inverted auto-partition min and max values before submit', () => { + const schema = buildTableValidationSchema({mode: 'create'}); + + const result = schema.safeParse( + createValues({ + settings: { + partitionsType: PartitionsType.None, + autoPartitionBySize: true, + autoPartitionByLoad: false, + autoPartitionMinPartitions: 100, + autoPartitionMaxPartitions: 10, + ttl: {status: 'disabled'}, + }, + }), + ); + + expect(result.success).toBe(false); + expect(getIssuePaths(result)).toEqual( + expect.arrayContaining([ + 'settings.autoPartitionMinPartitions', + 'settings.autoPartitionMaxPartitions', + ]), + ); + + if (result.success) { + return; + } + + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ['settings', 'autoPartitionMinPartitions'], + message: 'Minimum must not be greater than maximum', + }), + expect.objectContaining({ + path: ['settings', 'autoPartitionMaxPartitions'], + message: 'Maximum must not be less than minimum', + }), + ]), + ); + }); +}); diff --git a/src/containers/Tenant/TableFormDialog/columnValueValidation.ts b/src/containers/Tenant/TableFormDialog/columnValueValidation.ts new file mode 100644 index 0000000000..79ef92909b --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/columnValueValidation.ts @@ -0,0 +1,247 @@ +const INTEGER_REGEX = /^-?([0-9]|[1-9][0-9]+)$/; +const DATE_MONTH_PART = '(0[1-9]|1[0-2])'; +const DATE_DAY_PART = '(0[1-9]|[12]\\d|3[0-1])'; +const DATE_PATTERN = `\\d{4}-${DATE_MONTH_PART}-${DATE_DAY_PART}`; +const DATE32_PATTERN = `-?\\d{4,6}-${DATE_MONTH_PART}-${DATE_DAY_PART}`; +const DATE_REGEX = new RegExp(`^${DATE_PATTERN}$`); +const DATE32_REGEX = new RegExp(`^${DATE32_PATTERN}$`); +const DATETIME_REGEX = new RegExp(`^${DATE_PATTERN}T\\d{2}:\\d{2}:\\d{2}Z$`); +const DATETIME64_REGEX = new RegExp(`^${DATE32_PATTERN}T\\d{2}:\\d{2}:\\d{2}Z$`); +const TIMESTAMP_REGEX = new RegExp(`^${DATE_PATTERN}T\\d{2}:\\d{2}:\\d{2}\\.\\d{1,6}Z$`); +const TIMESTAMP64_REGEX = new RegExp(`^${DATE32_PATTERN}T\\d{2}:\\d{2}:\\d{2}\\.\\d{1,6}Z$`); +const DATE_PARTS_REGEX = /^(-?\d{4,6})-(\d{2})-(\d{2})/; +const TIME_PARTS_REGEX = /T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,6})?Z$/; +const MIN_DATE_YEAR = 1970; +const MAX_DATE_YEAR = 2106; +const MIN_EXTENDED_DATE_YEAR = -144169; +const MAX_EXTENDED_DATE_YEAR = 148107; + +interface DateParts { + year: number; + month: number; + day: number; +} + +interface TimeParts { + hour: number; + minute: number; + second: number; +} + +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 isLeapYear(year: number) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function getDaysInMonth(year: number, month: number) { + switch (month) { + case 2: + return isLeapYear(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } +} + +function getDateParts(stringValue: string): DateParts | undefined { + const parts = stringValue.match(DATE_PARTS_REGEX); + + if (!parts) { + return undefined; + } + + return { + year: parseInt(parts[1], 10), + month: parseInt(parts[2], 10), + day: parseInt(parts[3], 10), + } satisfies DateParts; +} + +function getTimeParts(stringValue: string): TimeParts | undefined { + const parts = stringValue.match(TIME_PARTS_REGEX); + + if (!parts) { + return undefined; + } + + return { + hour: parseInt(parts[1], 10), + minute: parseInt(parts[2], 10), + second: parseInt(parts[3], 10), + } satisfies TimeParts; +} + +function isDatePartsValid({year, month, day}: DateParts) { + return day <= getDaysInMonth(year, month); +} + +function isTimePartsValid({hour, minute, second}: TimeParts) { + return hour < 24 && minute < 60 && second < 60; +} + +function isYearInRange(year: number, min: number, max: number) { + return year >= min && year <= max; +} + +function isDateValueValid(stringValue: string, regex: RegExp, minYear: number, maxYear: number) { + if (!regex.test(stringValue)) { + return false; + } + + const dateParts = getDateParts(stringValue); + + return Boolean( + dateParts && isYearInRange(dateParts.year, minYear, maxYear) && isDatePartsValid(dateParts), + ); +} + +function isDateTimeValueValid( + stringValue: string, + regex: RegExp, + minYear: number, + maxYear: number, +) { + if (!regex.test(stringValue)) { + return false; + } + + const dateParts = getDateParts(stringValue); + const timeParts = getTimeParts(stringValue); + + return Boolean( + dateParts && + timeParts && + isYearInRange(dateParts.year, minYear, maxYear) && + isDatePartsValid(dateParts) && + isTimePartsValid(timeParts), + ); +} + +function isDate32(stringValue: string) { + return isDateValueValid( + stringValue, + DATE32_REGEX, + MIN_EXTENDED_DATE_YEAR, + MAX_EXTENDED_DATE_YEAR, + ); +} + +function isDatetime64(stringValue: string) { + return isDateTimeValueValid( + stringValue, + DATETIME64_REGEX, + MIN_EXTENDED_DATE_YEAR, + MAX_EXTENDED_DATE_YEAR, + ); +} + +function isTimestamp64(stringValue: string) { + return isDateTimeValueValid( + stringValue, + TIMESTAMP64_REGEX, + MIN_EXTENDED_DATE_YEAR, + MAX_EXTENDED_DATE_YEAR, + ); +} + +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 isDateValueValid(value, DATE_REGEX, MIN_DATE_YEAR, MAX_DATE_YEAR); + case 'Date32': + return isDate32(value); + case 'Datetime': + return isDateTimeValueValid(value, DATETIME_REGEX, MIN_DATE_YEAR, MAX_DATE_YEAR); + case 'Datetime64': + return isDatetime64(value); + case 'Timestamp': + return isDateTimeValueValid(value, TIMESTAMP_REGEX, MIN_DATE_YEAR, MAX_DATE_YEAR); + 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..517927964c --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.scss @@ -0,0 +1,175 @@ +.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); + + color: var(--g-color-text-primary); + } + + &__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; + } + + &-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..9432dc5523 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/components/ColumnSelectorField.tsx @@ -0,0 +1,245 @@ +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); +}; + +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} + +
+ ), + [handleSelect], + ); + + const renderSelectedItem = React.useCallback( + (item: Column, isActive: boolean) => ( +
+ {item.name} + +
+ ), + [handleRemove], + ); + + return ( +
+
+
+ {i18n('label_columns')} + +
+ + items={availableItems} + filterItem={filterColumnItem} + filterPlaceholder={i18n('label_search')} + itemsHeight={196} + renderItem={renderAvailableItem} + /> +
+
+
+ + {i18n('label_selected-count', {count: selectedItems.length})} + + +
+ + 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/RangeInputPicker.scss b/src/containers/Tenant/TableFormDialog/components/RangeInputPicker.scss new file mode 100644 index 0000000000..9ddcbe7a7b --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/components/RangeInputPicker.scss @@ -0,0 +1,22 @@ +.ydb-range-input-picker { + width: 100%; + + &__input { + width: 100%; + } + + &__slider { + position: relative; + z-index: 1; + + margin-top: calc(-1 * var(--g-spacing-2)); + + .g-slider__top { + display: none; + } + + .g-base-slider__rail { + visibility: hidden; + } + } +} diff --git a/src/containers/Tenant/TableFormDialog/components/RangeInputPicker.tsx b/src/containers/Tenant/TableFormDialog/components/RangeInputPicker.tsx new file mode 100644 index 0000000000..51ff756773 --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/components/RangeInputPicker.tsx @@ -0,0 +1,197 @@ +import React from 'react'; + +import {Slider, TextInput} from '@gravity-ui/uikit'; + +import {cn} from '../../../../utils/cn'; + +import './RangeInputPicker.scss'; + +const b = cn('ydb-range-input-picker'); + +interface RangeInputPickerProps { + value?: number; + min: number; + max: number; + step?: number; + marks?: number[]; + markFormat?: (value: number) => 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/containers/Tenant/TableFormDialog/components/layout.tsx b/src/containers/Tenant/TableFormDialog/components/layout.tsx new file mode 100644 index 0000000000..b22198e99c --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/components/layout.tsx @@ -0,0 +1,96 @@ +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 FormSection({ + title, + note, + children, +}: { + title?: string; + note?: string; + children: React.ReactNode; +}) { + return ( +
+ {title ? ( + + {title} + {note ? ( + + {note} + + ) : null} + + ) : null} + {children} +
+ ); +} + +export function FormRow({ + title, + note, + htmlFor, + children, +}: { + title?: string; + note?: string; + htmlFor?: string; + children: React.ReactNode; +}) { + let labelTitleNode: React.ReactNode = null; + + if (title) { + if (htmlFor) { + labelTitleNode = ( + + ); + } else { + labelTitleNode = {title}; + } + } + + 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..fec1799886 --- /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 = 10; +export const MAX_PARTITION_SIZE_MB = 2048; + +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..1afee823dc --- /dev/null +++ b/src/containers/Tenant/TableFormDialog/i18n/en.json @@ -0,0 +1,155 @@ +{ + "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", + + "context_field-name": "Use slash-separated path segments. Each segment may contain Latin letters (A-Z, a-z), numbers (0-9), and special characters: ., -, _. Maximum segment length is 255 characters.", + + "error_load-table": "Failed to load table", + "error_required": "Required field", + "error_name-pattern": "Can only contain Latin letters, numbers, underscores, dashes, and dots.", + "error_name-segment-pattern": "Can only contain Latin letters (A-Z, a-z), numbers (0-9), and special characters: ., -, _. Maximum length is 255 characters.", + "error_name-path-pattern": "Use slash-separated path segments. Each segment may contain Latin letters (A-Z, a-z), numbers (0-9), and special characters: ., -, _. Maximum segment length is 255 characters.", + "error_rename-with-other-changes": "Rename cannot be submitted together with other table changes. Apply the rename separately, or undo the other changes.", + "error_column-name-pattern": "Only word characters, underscores and dashes are allowed", + "error_column-name-duplicate": "Column names must be unique", + "error_index-name-duplicate": "Index names must be unique", + "error_maximum-less-minimum": "Maximum must not be less than minimum", + "error_minimum-greater-maximum": "Minimum must not be greater than maximum", + "error_partitions-count": "Value must be between 1 and 35000", + "error_uniform-partitions-first-key-type": "Uniform partitions require the first primary key column to have type Uint32 or Uint64", + "error_partition-count-range": "Value must be between 1 and 1000", + "error_columns-empty": "Columns are empty", + "error_indexes-delete-column": "Columns used by existing secondary indexes cannot be deleted.", + "error_primary-key-required": "At least one primary key column is required", + "error_indexes-key": "Incorrect set of columns in the key", + "error_ttl-column-invalid": "Select an existing column with a supported TTL type", + "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", + "column_index-key": "Key", + "column_name": "Name", + "column_not-null": "NotNull", + "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": "Autopartitioning by size", + "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_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": "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_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-partition-policy": "Partition policy", + "label_section-ttl": "TTL settings", + "label_search": "Search", + "label_select-columns": "Select 1 or more columns", + "label_selected": "Selected", + "label_selected-count": "Selected: {{count}}", + "label_non-null": "NonNull", + "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_index-delete-disabled": "This column is used by an existing secondary index and cannot be deleted in this form.", + "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-delete-disabled": "This column is currently used for TTL. Apply a separate update to disable or move TTL before deleting it.", + "tooltip_ttl-column": "Date, Datetime, Timestamp, Uint32, Uint64 columns are supported.", + "tooltip_ttl-lifetime": "The row will be considered expired when the value stored in the column is less than or equal to the current time and the 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..4ab5285b45 --- /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 {HelpMark, Select, 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; + nameInputRef?: React.Ref; +} + +const tableTypeInfo: Record = { + row: i18n('label_info-table-type_row'), + column: i18n('label_info-table-type_column'), +}; + +export function GeneralSection({mode, 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'; + const tableTypeHelpText = mode === 'create' && type ? tableTypeInfo[type] : undefined; + const nameHelpText = nameDisabled ? undefined : i18n('context_field-name'); + + return ( + + + ( + + )} + /> + + +
+ ( +