diff --git a/package.json b/package.json index 527112a..3e4d146 100644 --- a/package.json +++ b/package.json @@ -266,6 +266,16 @@ "default": "./build/cjs/modules/QueryComparison/index.js" } }, + "./components/DataTypePopup": { + "import": { + "types": "./build/esm/components/DataTypePopup/index.d.ts", + "default": "./build/esm/components/DataTypePopup/index.js" + }, + "require": { + "types": "./build/cjs/components/DataTypePopup/index.d.ts", + "default": "./build/cjs/components/DataTypePopup/index.js" + } + }, "./components/DataTable": { "import": { "types": "./build/esm/components/DataTable/index.d.ts", diff --git a/src/components/DataTypePopup/DataTypePopup.scss b/src/components/DataTypePopup/DataTypePopup.scss new file mode 100644 index 0000000..7e196ec --- /dev/null +++ b/src/components/DataTypePopup/DataTypePopup.scss @@ -0,0 +1,25 @@ +.qp-data-type-popup { + min-width: 0; + border-radius: var(--g-border-radius-xs); + + &_interactive { + cursor: pointer; + } + + &:focus-visible { + outline: 2px solid var(--g-color-line-focus); + outline-offset: 2px; + } + + &__icon { + flex-shrink: 0; + color: var(--g-color-text-hint); + } + + &__content { + max-height: 300px; + max-width: min(600px, calc(100vw - 32px)); + overflow: auto; + padding: var(--g-spacing-3); + } +} diff --git a/src/components/DataTypePopup/DataTypePopup.stories.tsx b/src/components/DataTypePopup/DataTypePopup.stories.tsx new file mode 100644 index 0000000..9a13b23 --- /dev/null +++ b/src/components/DataTypePopup/DataTypePopup.stories.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {ThemeProvider} from '@gravity-ui/uikit'; +import {DataTypePopup} from './DataTypePopup'; +import type {ParameterType} from '../../types/dataTypes'; + +const nestedType: ParameterType = { + name: 'Struct', + struct: [ + {key: 'id', type: {name: 'Uint64'}}, + {key: 'name', type: {name: 'Utf8', optional: true}}, + { + key: 'items', + type: { + name: 'List', + type: { + name: 'Struct', + struct: [ + {key: 'price', type: {name: 'Decimal', params: [22, 9]}}, + {key: 'tags', type: {name: 'Set', type: {name: 'String'}}}, + ], + }, + }, + }, + ], +}; + +const meta = { + title: 'Components/DataTypePopup', + component: DataTypePopup, + tags: ['autodocs'], + parameters: {layout: 'centered'}, + args: {children: 'Uint64', type: {name: 'Uint64'}}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primitive: Story = {}; +export const Nested: Story = {args: {children: 'Struct', type: nestedType}}; +export const OptionalAndTagged: Story = { + args: { + children: 'Decimal', + type: { + name: 'Decimal', + params: [22, 9], + optional: true, + optionalLevel: 3, + tagged: true, + tags: ['Money', 'USD'], + }, + }, +}; +export const Dictionary: Story = { + args: { + children: 'Dict', + type: {name: 'Dict', type: [{name: 'Utf8'}, {name: 'List', type: {name: 'Int64'}}]}, + }, +}; +export const Variant: Story = { + args: { + children: 'Variant', + type: { + name: 'Variant', + struct: [ + {key: 'text', type: {name: 'Utf8'}}, + {key: 'point', type: {name: 'Tuple', type: [{name: 'Double'}, {name: 'Double'}]}}, + {key: 'status', type: {name: 'Enum', type: [{name: 'ready'}, {name: 'pending'}]}}, + ], + }, + }, +}; +export const WithoutIcon: Story = {args: {...Nested.args, hideIcon: true}}; +export const WithoutType: Story = {args: {children: 'No type information', type: undefined}}; +export const LongStructure: Story = { + args: { + children: 'Struct with 50 fields', + type: { + name: 'Struct', + struct: Array.from({length: 50}, (_, index) => ({ + key: `field_${index}`, + type: {name: 'Utf8'}, + })), + }, + }, +}; +export const DarkTheme: Story = { + args: Nested.args, + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/src/components/DataTypePopup/DataTypePopup.tsx b/src/components/DataTypePopup/DataTypePopup.tsx new file mode 100644 index 0000000..009c2b6 --- /dev/null +++ b/src/components/DataTypePopup/DataTypePopup.tsx @@ -0,0 +1,56 @@ +import React, {useState} from 'react'; +import {CircleInfo} from '@gravity-ui/icons'; +import {Flex, Icon, Popover} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type {DataTypePopupProps} from '../../types/dataTypePopup'; +import {formatType} from '../../helpers/dataTypes'; +import {DataTypeTree} from './internal/DataTypeTree'; +import './DataTypePopup.scss'; + +const block = cn('qp-data-type-popup'); + +export function DataTypePopup({children, type, hideIcon, className}: DataTypePopupProps) { + const [open, setOpen] = useState(false); + if (!type) { + return {children}; + } + const label = formatType(type); + return ( + + + + } + > + setOpen((current) => !current)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setOpen((current) => !current); + } else if (event.key === 'Escape' && open) { + setOpen(false); + } + }} + > + {children} + {!hideIcon && } + + + ); +} diff --git a/src/components/DataTypePopup/README.md b/src/components/DataTypePopup/README.md new file mode 100644 index 0000000..60408af --- /dev/null +++ b/src/components/DataTypePopup/README.md @@ -0,0 +1,24 @@ +# DataTypePopup + +Displays a parsed data type in an interactive popover. It uses the shared +`ParameterType` descriptor from `src/types/dataTypes` and has no dependencies on +the YQL application or its type parser. + +```tsx +import {DataTypePopup} from '@gravity-ui/querieskit'; + +List<Uint64>; +``` + +- `type`: optional type descriptor. Without it, children render without a popup. +- `children`: visible trigger content. +- `hideIcon`: hides the information icon. +- `className`: styles the trigger. + +The popover supports hover, click and keyboard activation. Container types form +an expandable tree; the first two levels start expanded. Struct/Variant fields, +Enum members, Decimal parameters, Optional depth and tags are displayed. Content +scrolls when it exceeds 300px in height. + +See **Components / DataTypePopup** in Storybook. QueryParameters uses this component +for its visible type labels. diff --git a/src/components/DataTypePopup/i18n/dicts.ts b/src/components/DataTypePopup/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/components/DataTypePopup/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/components/DataTypePopup/i18n/en.json b/src/components/DataTypePopup/i18n/en.json new file mode 100644 index 0000000..1a63e55 --- /dev/null +++ b/src/components/DataTypePopup/i18n/en.json @@ -0,0 +1,4 @@ +{ + "action_expand": "Expand {{type}}", + "action_collapse": "Collapse {{type}}" +} diff --git a/src/components/DataTypePopup/i18n/index.ts b/src/components/DataTypePopup/i18n/index.ts new file mode 100644 index 0000000..58abaa6 --- /dev/null +++ b/src/components/DataTypePopup/i18n/index.ts @@ -0,0 +1,4 @@ +import {addI18Keysets} from '../../../i18n'; +import dicts from './dicts'; + +export default addI18Keysets('qp:data-type-popup', dicts); diff --git a/src/components/DataTypePopup/i18n/ru.json b/src/components/DataTypePopup/i18n/ru.json new file mode 100644 index 0000000..b8ff96b --- /dev/null +++ b/src/components/DataTypePopup/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "action_expand": "Развернуть {{type}}", + "action_collapse": "Свернуть {{type}}" +} diff --git a/src/components/DataTypePopup/index.ts b/src/components/DataTypePopup/index.ts new file mode 100644 index 0000000..89d39d3 --- /dev/null +++ b/src/components/DataTypePopup/index.ts @@ -0,0 +1,2 @@ +export {DataTypePopup} from './DataTypePopup'; +export type {DataTypePopupProps} from '../../types/dataTypePopup'; diff --git a/src/components/DataTypePopup/internal/DataTypeTree.scss b/src/components/DataTypePopup/internal/DataTypeTree.scss new file mode 100644 index 0000000..5eea17b --- /dev/null +++ b/src/components/DataTypePopup/internal/DataTypeTree.scss @@ -0,0 +1,24 @@ +.qp-data-type-tree { + min-width: 0; + overflow-wrap: anywhere; + + &__toggle { + --g-button-padding: 0; + font-family: var(--g-font-family-monospace); + font-weight: 600; + --g-button-text-color: var(--g-color-text-link); + } + + &__optional { + font-size: var(--g-text-code-1-font-size); + font-style: italic; + } + + &__children { + padding-inline-start: var(--g-spacing-4); + } + + &__expand { + align-self: flex-start; + } +} diff --git a/src/components/DataTypePopup/internal/DataTypeTree.tsx b/src/components/DataTypePopup/internal/DataTypeTree.tsx new file mode 100644 index 0000000..850ab46 --- /dev/null +++ b/src/components/DataTypePopup/internal/DataTypeTree.tsx @@ -0,0 +1,109 @@ +import React, {useState} from 'react'; +import {Button, Flex, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type {ParameterType} from '../../../types/dataTypes'; +import {optionalDepth} from '../../../helpers/dataTypes'; +import i18n from '../i18n'; +import './DataTypeTree.scss'; + +const block = cn('qp-data-type-tree'); + +function TypeChildren({type, level}: {type: ParameterType; level: number}) { + if ('struct' in type) { + return ( + <> + {type.struct.map((entry, index) => ( + + ))} + + ); + } + if (type.name === 'Enum') { + return ( + <> + {type.type.map((entry, index) => ( + + {entry.name} + + ))} + + ); + } + if ('type' in type) { + const children = Array.isArray(type.type) ? type.type : [type.type]; + return ( + <> + {children.map((child, index) => ( + + ))} + + ); + } + return null; +} + +export function DataTypeTree({ + type, + level = 0, + member, +}: { + type: ParameterType; + level?: number; + member?: string | number; +}) { + const [expanded, setExpanded] = useState(level < 2); + const complex = 'struct' in type || 'type' in type; + const depth = optionalDepth(type); + const name = type.name + (type.params?.length ? `(${type.params.join(', ')})` : ''); + return ( + + + {member !== undefined && {member}:} + {complex ? ( + + ) : ( + {name} + )} + {depth > 0 && ( + + {depth > 1 ? `Optional × ${depth}` : 'Optional'} + + )} + {type.tags?.map((tag, index) => ( + + [{tag}] + + ))} + + {complex && ( + + {expanded ? ( + + ) : ( + + )} + + )} + + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 1f264d9..051ec1e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -96,4 +96,6 @@ export * from './AttachmentList'; export * from './AttachmentListPlaceholder'; export {QueryGraph} from './QueryGraph'; export type {QueryGraphProps} from './QueryGraph'; +export {DataTypePopup} from './DataTypePopup'; +export type {DataTypePopupProps} from './DataTypePopup'; export * from './MonacoDiffEditor'; diff --git a/src/helpers/dataTypes.ts b/src/helpers/dataTypes.ts new file mode 100644 index 0000000..0108dcf --- /dev/null +++ b/src/helpers/dataTypes.ts @@ -0,0 +1,15 @@ +import type {ParameterType} from '../types/dataTypes'; + +export const optionalDepth = (type: ParameterType) => + type.optional ? Math.max(1, type.optionalLevel || 1) : 0; + +export function formatType(type: ParameterType): string { + let result: string = type.name; + if ('struct' in type) + result += `<${type.struct.map((field) => `${field.key}: ${formatType(field.type)}`).join(', ')}>`; + else if ('type' in type) { + result += `<${Array.isArray(type.type) ? type.type.map((item) => (type.name === 'Enum' ? item.name : formatType(item as ParameterType))).join(', ') : formatType(type.type)}>`; + } else if (type.params?.length) result += `(${type.params.join(', ')})`; + for (const tag of type.tags || []) result = `Tagged<${result}, ${tag}>`; + return result + '?'.repeat(optionalDepth(type)); +} diff --git a/src/helpers/parameterDate.ts b/src/helpers/parameterDate.ts new file mode 100644 index 0000000..12a59c8 --- /dev/null +++ b/src/helpers/parameterDate.ts @@ -0,0 +1,75 @@ +const SECOND = 1_000_000n; +const DAY = 86400n * SECOND; + +const ranges: Record = { + Date: [0n, 49672n], + Date32: [-53375809n, 53375807n], + Datetime: [0n, 4291747199n], + Datetime64: [-4611669897600n, 4611638361599n], + Timestamp: [0n, 4291747199999999n], + Timestamp64: [-4611669897600000000n, 4611638361599999999n], + Interval: [-4291747199999999n, 4291747199999999n], + Interval64: [-9223339190399999999n, 9223339190399999999n], +}; + +function isDateValue(value: string, name: string) { + if (!/^-?\d+$/.test(value)) return false; + const range = ranges[name]; + return Boolean(range && BigInt(value) >= range[0] && BigInt(value) <= range[1]); +} + +function parseInterval(text: string, name: string): string | undefined { + const match = + /^(-)?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.(\d{1,6}))?S)?)?$/.exec( + text, + ); + if (!match || !match.slice(2).some(Boolean) || text.endsWith('T')) return undefined; + const [, negative, weeks, days, hours, minutes, seconds, fraction] = match; + const micros = + (BigInt(weeks || 0) * 7n * DAY + + BigInt(days || 0) * DAY + + BigInt(hours || 0) * 3600n * SECOND + + BigInt(minutes || 0) * 60n * SECOND + + BigInt(seconds || 0) * SECOND + + BigInt((fraction || '').padEnd(6, '0'))) * + (negative ? -1n : 1n); + return isDateValue(String(micros), name) ? String(micros) : undefined; +} + +export function parseDate(text: string, name: string): string | undefined { + if (name.startsWith('Interval')) return parseInterval(text, name); + const dateOnly = name === 'Date' || name === 'Date32'; + const match = + /^(-?\d{4,6})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?Z)?$/.exec(text); + if (!match || (dateOnly ? match[4] !== undefined : match[4] === undefined)) return undefined; + const [, yearText, month, day, hour = '0', minute = '0', second = '0', fraction = ''] = match; + const year = Number(yearText); + if (year === 0 || (!name.startsWith('Timestamp') && fraction)) return undefined; + const jsYear = year < 0 ? year + 1 : year; + const date = new Date(0); + date.setUTCFullYear(jsYear, Number(month) - 1, Number(day)); + date.setUTCHours(Number(hour), Number(minute), Number(second), 0); + const actual = [ + date.getUTCFullYear(), + date.getUTCMonth() + 1, + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + ]; + const expected = [ + jsYear, + Number(month), + Number(day), + Number(hour), + Number(minute), + Number(second), + ]; + if (!actual.every((part, index) => part === expected[index])) return undefined; + const seconds = BigInt(date.getTime() / 1000); + let result = seconds; + if (dateOnly) result = seconds / 86400n; + else if (name.startsWith('Timestamp')) + result = seconds * SECOND + BigInt(fraction.padEnd(6, '0')); + return isDateValue(String(result), name) ? String(result) : undefined; +} diff --git a/src/helpers/parameterValidation.ts b/src/helpers/parameterValidation.ts new file mode 100644 index 0000000..371bb21 --- /dev/null +++ b/src/helpers/parameterValidation.ts @@ -0,0 +1,45 @@ +const decimalPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i; +const specialNumberPattern = /^[+-]?(?:inf|nan)$/i; + +function validateInteger(value: string, name: string, bits: bigint) { + const unsigned = name.startsWith('Uint'); + if (!/^[+-]?\d+$/.test(value)) return false; + const min = unsigned ? 0n : -(2n ** (bits - 1n)); + const max = unsigned ? 2n ** bits - 1n : 2n ** (bits - 1n) - 1n; + const integer = BigInt(value); + return integer >= min && integer <= max; +} + +function validateDecimal(value: string, params?: readonly unknown[]) { + if (specialNumberPattern.test(value)) return true; + const match = /^[+-]?(\d+)(?:\.(\d*))?$/.exec(value); + if (!match) return false; + const [precision, scale] = params?.map(Number) || []; + if (!Number.isInteger(precision) || !Number.isInteger(scale)) return true; + return ( + match[1].replace(/^0+/, '').length <= precision - scale && (match[2]?.length || 0) <= scale + ); +} + +/** Validates numeric parameter strings without losing integer precision through JS Number. */ +export function validateNumericValue( + value: unknown, + name: string, + params?: readonly unknown[], +): boolean { + if (typeof value !== 'string') return false; + const integer = /^(?:Uint|Int)(8|16|32|64)$/.exec(name); + if (integer) return validateInteger(value, name, BigInt(integer[1])); + switch (name) { + case 'Decimal': + return validateDecimal(value, params); + case 'Float': + case 'Double': + return specialNumberPattern.test(value) || decimalPattern.test(value); + case 'DyNumber': + case 'Number': + return decimalPattern.test(value); + default: + return false; + } +} diff --git a/src/index.ts b/src/index.ts index 8e94f8e..693a471 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,8 @@ export * from './types/errorTree'; export * from './types/queryStatistics'; export * from './types/queryResults'; export * from './types/queryGraph'; +export * from './types/dataTypes'; +export * from './types/dataTypePopup'; export * from './types/queryTimeline'; export * from './types/queryExecutionPanel'; export * from './types/queriesSidebar'; diff --git a/src/modules/QueryParameters/QueryParameters.tsx b/src/modules/QueryParameters/QueryParameters.tsx index 7df5cd7..49731b7 100644 --- a/src/modules/QueryParameters/QueryParameters.tsx +++ b/src/modules/QueryParameters/QueryParameters.tsx @@ -65,7 +65,7 @@ export function QueryParameters({ const parameters = session?.draft ?? value; const current = parameters.filter((parameter) => !parameter.deleted); const deleted = parameters.filter((parameter) => parameter.deleted); - const validation = attempted ? validateParameters(parameters) : {}; + const validation = editing ? validateParameters(parameters) : {}; const query = search.trim().toLocaleLowerCase(); const matches = (parameter: QueryParameter) => `${parameter.name} ${formatType(parameter.type)}`.toLocaleLowerCase().includes(query); @@ -424,7 +424,12 @@ export function QueryParameters({ <>