diff --git a/etc/blocks/ui-examples/model/src/index.ts b/etc/blocks/ui-examples/model/src/index.ts index d6eb5b2750..8da28dd613 100644 --- a/etc/blocks/ui-examples/model/src/index.ts +++ b/etc/blocks/ui-examples/model/src/index.ts @@ -355,6 +355,7 @@ export const platforma = BlockModel.create('Heavy') { type: 'link', href: '/pl-autocomplete', label: 'PlAutocomplete' }, { type: 'link', href: '/pl-autocomplete-multi', label: 'PlAutocompleteMulti' }, { type: 'link', href: '/radio', label: 'PlRadio' }, + { type: 'link', href: '/advanced-filter', label: 'PlAdvancedFilter' }, ...(dynamicSections.length ? [ { type: 'delimiter' }, diff --git a/etc/blocks/ui-examples/ui/src/app.ts b/etc/blocks/ui-examples/ui/src/app.ts index 5ab04b6cae..cab8b1a983 100644 --- a/etc/blocks/ui-examples/ui/src/app.ts +++ b/etc/blocks/ui-examples/ui/src/app.ts @@ -3,6 +3,7 @@ import { platforma } from '@milaboratories/milaboratories.ui-examples.model'; import { animate, defineApp, makeEaseOut } from '@platforma-sdk/ui-vue'; import { computed, reactive, ref } from 'vue'; import AddSectionPage from './pages/AddSectionPage.vue'; +import AdvancedFilterPage from './pages/PlAdvancedFilterPage.vue'; import { AgGridVuePage, AgGridVuePageWithBuilder } from './pages/AgGridVuePage'; import ButtonsPage from './pages/ButtonsPage.vue'; import DownloadsPage from './pages/DownloadsPage.vue'; @@ -120,6 +121,7 @@ export const sdkPlugin = defineApp(platforma, (app) => { '/add-section': () => AddSectionPage, '/section': () => SectionPage, '/radio': () => RadioPage, + '/advanced-filter': () => AdvancedFilterPage, }, }; }, { diff --git a/etc/blocks/ui-examples/ui/src/pages/PlAdvancedFilterPage.vue b/etc/blocks/ui-examples/ui/src/pages/PlAdvancedFilterPage.vue new file mode 100644 index 0000000000..781dcb1214 --- /dev/null +++ b/etc/blocks/ui-examples/ui/src/pages/PlAdvancedFilterPage.vue @@ -0,0 +1,227 @@ + + + + diff --git a/lib/ui/uikit/src/components/PlAutocomplete/PlAutocomplete.vue b/lib/ui/uikit/src/components/PlAutocomplete/PlAutocomplete.vue index d7f2fa6513..adaf7bcd86 100644 --- a/lib/ui/uikit/src/components/PlAutocomplete/PlAutocomplete.vue +++ b/lib/ui/uikit/src/components/PlAutocomplete/PlAutocomplete.vue @@ -92,6 +92,10 @@ const props = withDefaults( * Formatter for the selected value if its label is absent */ formatValue?: (value: M) => string; + /** + * Makes some of corners not rounded + * */ + groupPosition?: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle'; }>(), { modelSearch: undefined, @@ -107,6 +111,7 @@ const props = withDefaults( arrowIconLarge: undefined, optionSize: 'small', formatValue: (v: M) => String(v), + groupPosition: undefined, }, ); @@ -397,7 +402,7 @@ watch(() => optionsRequest.loading || modelOptionRequest.loading, (loading) => { />
Nothing found
- +
{{ computedError }}
diff --git a/lib/ui/uikit/src/components/PlAutocompleteMulti/PlAutocompleteMulti.vue b/lib/ui/uikit/src/components/PlAutocompleteMulti/PlAutocompleteMulti.vue index c0f2053d47..5c385953cc 100644 --- a/lib/ui/uikit/src/components/PlAutocompleteMulti/PlAutocompleteMulti.vue +++ b/lib/ui/uikit/src/components/PlAutocompleteMulti/PlAutocompleteMulti.vue @@ -119,6 +119,10 @@ const props = withDefaults( * The text to display when no options are found. */ emptyOptionsText?: string; + /** + * Makes some of corners not rounded + * */ + groupPosition?: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle'; }>(), { modelValue: () => [], @@ -131,6 +135,7 @@ const props = withDefaults( debounce: 300, emptyOptionsText: 'Nothing found', sourceId: undefined, + groupPosition: undefined, }, ); @@ -406,7 +411,7 @@ const computedError = computed(() => { />
{{ emptyOptionsText }}
- +
{{ computedError }}
diff --git a/lib/ui/uikit/src/components/PlDropdown/PlDropdown.vue b/lib/ui/uikit/src/components/PlDropdown/PlDropdown.vue index 44c268ce4f..2c8cd6d90d 100644 --- a/lib/ui/uikit/src/components/PlDropdown/PlDropdown.vue +++ b/lib/ui/uikit/src/components/PlDropdown/PlDropdown.vue @@ -60,6 +60,10 @@ const props = withDefaults( * Error message displayed below the dropdown (optional) */ error?: unknown; + /** + * Shows red border even without an error message + */ + errorStatus?: boolean; /** * Placeholder text shown when no value is selected. */ @@ -88,12 +92,17 @@ const props = withDefaults( * Option list item size */ optionSize?: 'small' | 'medium'; + /** + * Makes some of corners not rounded + * */ + groupPosition?: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle'; }>(), { label: '', helper: undefined, loadingOptionsHelper: undefined, error: undefined, + showErrorMessage: true, placeholder: '...', clearable: false, required: false, @@ -102,6 +111,7 @@ const props = withDefaults( arrowIconLarge: undefined, optionSize: 'small', options: undefined, + groupPosition: undefined, }, ); @@ -316,7 +326,7 @@ watchPostEffect(() => { ref="rootRef" :tabindex="tabindex" class="pl-dropdown" - :class="{ open: data.open, error, disabled: isDisabled }" + :class="{ open: data.open, error: error || errorStatus, disabled: isDisabled }" @keydown="handleKeydown" @focusout="onFocusOut" > @@ -367,7 +377,7 @@ watchPostEffect(() => { :option-size="optionSize" :select-option="selectOptionWrapper" /> - +
{{ computedError }}
diff --git a/lib/ui/uikit/src/components/PlDropdownMulti/PlDropdownMulti.vue b/lib/ui/uikit/src/components/PlDropdownMulti/PlDropdownMulti.vue index a297bb6098..9be8419332 100644 --- a/lib/ui/uikit/src/components/PlDropdownMulti/PlDropdownMulti.vue +++ b/lib/ui/uikit/src/components/PlDropdownMulti/PlDropdownMulti.vue @@ -66,6 +66,10 @@ const props = withDefaults( * If `true`, the dropdown component is disabled and cannot be interacted with. */ disabled?: boolean; + /** + * Makes some of corners not rounded + * */ + groupPosition?: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle'; }>(), { modelValue: () => [], @@ -76,6 +80,7 @@ const props = withDefaults( required: false, disabled: false, options: undefined, + groupPosition: undefined, }, ); @@ -308,7 +313,7 @@ watchPostEffect(() => { />
Nothing found
- +
{{ getErrorMessage(error) }}
diff --git a/lib/ui/uikit/src/components/PlElementList/PlElementList.vue b/lib/ui/uikit/src/components/PlElementList/PlElementList.vue index 3a7f5bb4a5..1ec0c490ee 100644 --- a/lib/ui/uikit/src/components/PlElementList/PlElementList.vue +++ b/lib/ui/uikit/src/components/PlElementList/PlElementList.vue @@ -14,6 +14,10 @@ const props = withDefaults( getItemKey?: (item: T, index: number) => K; itemClass?: string | string[] | ((item: T, index: number) => string | string[]); + itemClassTitle?: string | string[] | ((item: T, index: number) => string | string[]); + itemClassBefore?: string | string[] | ((item: T, index: number) => string | string[]); + itemClassAfter?: string | string[] | ((item: T, index: number) => string | string[]); + itemClassContent?: string | string[] | ((item: T, index: number) => string | string[]); isActive?: (item: T, index: number) => boolean; disableDragging?: boolean; @@ -43,6 +47,10 @@ const props = withDefaults( getItemKey: (item: T) => JSON.stringify(item) as K, itemClass: undefined, + itemClassTitle: undefined, + itemClassContent: undefined, + itemClassBefore: undefined, + itemClassAfter: undefined, isActive: undefined, disableDragging: undefined, @@ -78,6 +86,8 @@ const emits = defineEmits<{ const slots = defineSlots<{ ['item-title']: (props: { item: T; index: number }) => unknown; ['item-content']?: (props: { item: T; index: number }) => unknown; + ['item-before']?: (props: { item: T; index: number }) => unknown; + ['item-after']?: (props: { item: T; index: number }) => unknown; }>(); const dndSortingEnabled = computed((): boolean => { @@ -237,12 +247,19 @@ function handleRemove(item: T, index: number) { itemsRef.value.splice(index, 1); } -const getItemClass = (item: T, index: number): null | string | string[] => { - if (typeof props.itemClass === 'function') { - return props.itemClass(item, index); - } - return props.itemClass ?? null; -}; +function getClassFunction(propsItemClass: string | string[] | ((item: T, index: number) => string | string[]) | undefined): (item: T, index: number) => null | string | string[] { + return (item: T, index: number): null | string | string[] => { + if (typeof propsItemClass === 'function') { + return propsItemClass(item, index); + } + return propsItemClass ?? null; + }; +} +const getItemClass = getClassFunction(props.itemClass); +const getItemClassTitle = getClassFunction(props.itemClassTitle); +const getItemClassContent = getClassFunction(props.itemClassContent); +const getItemClassBefore = getClassFunction(props.itemClassBefore); +const getItemClassAfter = getClassFunction(props.itemClassAfter); @@ -252,6 +269,10 @@ const getItemClass = (item: T, index: number): null | string | string[] => { { @pin="handlePin" @expand="handleExpand" > + +
{ +
diff --git a/lib/ui/uikit/src/components/PlElementList/PlElementListItem.vue b/lib/ui/uikit/src/components/PlElementList/PlElementListItem.vue index 494d33939f..61f80fa885 100644 --- a/lib/ui/uikit/src/components/PlElementList/PlElementListItem.vue +++ b/lib/ui/uikit/src/components/PlElementList/PlElementListItem.vue @@ -16,13 +16,22 @@ const props = defineProps<{ isToggled: boolean; isPinnable: boolean; isPinned: boolean; + titleClass: string | string[] | null; + contentClass: string | string[] | null; + afterClass: string | string[] | null; + beforeClass: string | string[] | null; }>(); +defineOptions({ inheritAttrs: false }); const slots = defineSlots<{ title: (props: { item: T; index: number }) => unknown; content?: (props: { item: T; index: number }) => unknown; + after?: (props: { item: T; index: number }) => unknown; + before?: (props: { item: T; index: number }) => unknown; }>(); const hasContentSlot = computed(() => slots['content'] !== undefined); +const hasAfterSlot = computed(() => slots['after'] !== undefined); +const hasBeforeSlot = computed(() => slots['before'] !== undefined); const emit = defineEmits<{ (e: 'expand', item: T, index: number): void; @@ -33,65 +42,73 @@ const emit = defineEmits<{ diff --git a/lib/ui/uikit/src/components/PlNumberField/PlNumberField.vue b/lib/ui/uikit/src/components/PlNumberField/PlNumberField.vue index 7fda278a61..7295d55d5b 100644 --- a/lib/ui/uikit/src/components/PlNumberField/PlNumberField.vue +++ b/lib/ui/uikit/src/components/PlNumberField/PlNumberField.vue @@ -47,6 +47,8 @@ const props = withDefaults(defineProps<{ errorMessage?: string; /** Additional validity check for input value that must return an error text if failed */ validate?: (v: number) => string | undefined; + /** Makes some of corners not rounded */ + groupPosition?: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle'; }>(), { step: 1, label: undefined, @@ -57,6 +59,7 @@ const props = withDefaults(defineProps<{ updateOnEnter: false, errorMessage: undefined, validate: undefined, + groupPosition: undefined, }); const modelValue = defineModel({ required: true }); @@ -239,7 +242,7 @@ const onMousedown = (ev: MouseEvent) => { @keydown="handleKeyPress($event)" >
- +
(); const rootRef = ref(undefined); @@ -217,7 +221,7 @@ useLabelNotch(rootRef);
- +
{{ displayErrors.join(' ') }} diff --git a/lib/ui/uikit/src/composition/filters/metadata.ts b/lib/ui/uikit/src/composition/filters/metadata.ts index 4979076a6d..2a43caa8fa 100644 --- a/lib/ui/uikit/src/composition/filters/metadata.ts +++ b/lib/ui/uikit/src/composition/filters/metadata.ts @@ -23,6 +23,27 @@ export const filterUiMetadata = { }, supportedFor: isNumericValueType, }, + notEqual: { + label: 'Col ≠ X (Not Equal)', + form: { + column: { + label: 'Column', + fieldType: 'SUniversalPColumnId', + defaultValue: () => undefined, + }, + type: { + label: 'Predicate', + fieldType: 'FilterType', + defaultValue: () => 'notEqual', + }, + x: { + label: 'X', + fieldType: 'number', + defaultValue: () => 0, + }, + }, + supportedFor: isNumericValueType, + }, lessThan: { label: 'Col < X (Less Than)', form: { @@ -450,6 +471,90 @@ export const filterUiMetadata = { }, supportedFor: () => false, }, + patternMatchesRegularExpression: { + label: 'Col ~ X (Matches Regular Expression)', + form: { + column: { + label: 'Column', + fieldType: 'SUniversalPColumnId', + defaultValue: () => undefined, + }, + type: { + label: 'Predicate', + fieldType: 'FilterType', + defaultValue: () => 'patternMatchesRegularExpression', + }, + value: { + label: 'Seq', + fieldType: 'string', + defaultValue: () => '', + }, + }, + supportedFor: isStringValueType, + }, + patternFuzzyContainSubsequence: { + label: 'Col ~ Seq (Fuzzy Contain Subsequence)', + form: { + column: { + label: 'Column', + fieldType: 'SUniversalPColumnId', + defaultValue: () => undefined, + }, + type: { + label: 'Predicate', + fieldType: 'FilterType', + defaultValue: () => 'patternFuzzyContainSubsequence', + }, + value: { + label: 'Set', + fieldType: 'string', + defaultValue: () => '', + }, + }, + supportedFor: isStringValueType, + }, + inSet: { + label: 'Col ∈ Set (In Set)', + form: { + column: { + label: 'Column', + fieldType: 'SUniversalPColumnId', + defaultValue: () => undefined, + }, + type: { + label: 'Predicate', + fieldType: 'FilterType', + defaultValue: () => 'inSet', + }, + value: { + fieldType: 'unknown[]', + label: 'Set', + defaultValue: () => [], + }, + }, + supportedFor: isStringValueType, + }, + notInSet: { + label: 'Col ∉ Set (Not In Set)', + form: { + column: { + label: 'Column', + fieldType: 'SUniversalPColumnId', + defaultValue: () => undefined, + }, + type: { + label: 'Predicate', + fieldType: 'FilterType', + defaultValue: () => 'notInSet', + }, + value: { + label: 'Seq', + fieldType: 'unknown[]', + defaultValue: () => [], + }, + }, + supportedFor: isStringValueType, + }, } satisfies FilterSpecMetadataRecord; export function getFilterUiTypeOptions(columnSpec?: SimplifiedPColumnSpec) { diff --git a/lib/ui/uikit/src/utils/DoubleContour.vue b/lib/ui/uikit/src/utils/DoubleContour.vue index e34f749485..e33d74c744 100644 --- a/lib/ui/uikit/src/utils/DoubleContour.vue +++ b/lib/ui/uikit/src/utils/DoubleContour.vue @@ -1,15 +1,81 @@ - + + diff --git a/sdk/model/src/filters/converter.ts b/sdk/model/src/filters/converter.ts index 9445123408..357f8bbff6 100644 --- a/sdk/model/src/filters/converter.ts +++ b/sdk/model/src/filters/converter.ts @@ -107,6 +107,16 @@ export function convertFilterUiToExpressionImpl(value: FilterSpec): ExpressionIm return rank(col(value.column), false).over([]).le(lit(value.n)); } + if ( + value.type === 'patternMatchesRegularExpression' + || value.type === 'patternFuzzyContainSubsequence' + || value.type === 'inSet' + || value.type === 'notInSet' + || value.type === 'notEqual' + ) { + throw new Error('Not implemented filter type: ' + value.type); + } + if (value.type === undefined) { throw new Error('Filter type is undefined, this should not happen'); } diff --git a/sdk/model/src/filters/types.ts b/sdk/model/src/filters/types.ts index b4e873b79f..0cb78aa0e7 100644 --- a/sdk/model/src/filters/types.ts +++ b/sdk/model/src/filters/types.ts @@ -23,11 +23,17 @@ export type FilterSpecLeaf = | { type: 'patternNotEquals'; column: SUniversalPColumnId; value: string } | { type: 'patternContainSubsequence'; column: SUniversalPColumnId; value: string } | { type: 'patternNotContainSubsequence'; column: SUniversalPColumnId; value: string } + | { type: 'patternMatchesRegularExpression'; column: SUniversalPColumnId; value: string } + | { type: 'patternFuzzyContainSubsequence'; column: SUniversalPColumnId; value: string; maxEdits?: number; substitutionsOnly?: boolean; wildcard?: string } + + | { type: 'inSet'; column: SUniversalPColumnId; value: string[] } + | { type: 'notInSet'; column: SUniversalPColumnId; value: string[] } | { type: 'topN'; column: SUniversalPColumnId; n: number } | { type: 'bottomN'; column: SUniversalPColumnId; n: number } | { type: 'equal'; column: SUniversalPColumnId; x: number } + | { type: 'notEqual'; column: SUniversalPColumnId; x: number } | { type: 'lessThan'; column: SUniversalPColumnId; x: number } | { type: 'greaterThan'; column: SUniversalPColumnId; x: number } | { type: 'lessThanOrEqual'; column: SUniversalPColumnId; x: number } diff --git a/sdk/ui-vue/src/components/PlAdvancedFilter/OperandButton.vue b/sdk/ui-vue/src/components/PlAdvancedFilter/OperandButton.vue new file mode 100644 index 0000000000..441e37bb00 --- /dev/null +++ b/sdk/ui-vue/src/components/PlAdvancedFilter/OperandButton.vue @@ -0,0 +1,53 @@ + + + diff --git a/sdk/ui-vue/src/components/PlAdvancedFilter/PlAdvancedFilter.vue b/sdk/ui-vue/src/components/PlAdvancedFilter/PlAdvancedFilter.vue new file mode 100644 index 0000000000..a398cd7b20 --- /dev/null +++ b/sdk/ui-vue/src/components/PlAdvancedFilter/PlAdvancedFilter.vue @@ -0,0 +1,218 @@ + + + diff --git a/sdk/ui-vue/src/components/PlAdvancedFilter/SingleFilter.vue b/sdk/ui-vue/src/components/PlAdvancedFilter/SingleFilter.vue new file mode 100644 index 0000000000..a629ea2c46 --- /dev/null +++ b/sdk/ui-vue/src/components/PlAdvancedFilter/SingleFilter.vue @@ -0,0 +1,418 @@ + + + + diff --git a/sdk/ui-vue/src/components/PlAdvancedFilter/constants.ts b/sdk/ui-vue/src/components/PlAdvancedFilter/constants.ts new file mode 100644 index 0000000000..652ffeacad --- /dev/null +++ b/sdk/ui-vue/src/components/PlAdvancedFilter/constants.ts @@ -0,0 +1,43 @@ +import type { SUniversalPColumnId } from '@platforma-sdk/model'; +import type { Filter, FilterType, SupportedFilterTypes } from './types'; + +export const SUPPORTED_FILTER_TYPES = new Set([ + 'isNA', + 'isNotNA', + 'greaterThan', + 'greaterThanOrEqual', + 'lessThan', + 'lessThanOrEqual', + 'patternEquals', + 'patternNotEquals', + 'patternContainSubsequence', + 'patternNotContainSubsequence', + 'equal', + 'notEqual', + 'patternFuzzyContainSubsequence', + 'patternMatchesRegularExpression', + 'inSet', + 'notInSet', +]); + +export const DEFAULT_FILTER_TYPE: FilterType = 'isNA'; + +const emptyCommonPart = { column: '' as SUniversalPColumnId, fixedAxes: {} as Record }; +export const DEFAULT_FILTERS: Record = { + isNA: { type: 'isNA', ...emptyCommonPart }, + isNotNA: { type: 'isNotNA', ...emptyCommonPart }, + lessThan: { type: 'lessThan', x: undefined, ...emptyCommonPart }, + lessThanOrEqual: { type: 'lessThanOrEqual', x: undefined, ...emptyCommonPart }, + patternEquals: { type: 'patternEquals', value: undefined, ...emptyCommonPart }, + patternNotEquals: { type: 'patternNotEquals', value: undefined, ...emptyCommonPart }, + greaterThan: { type: 'greaterThan', x: undefined, ...emptyCommonPart }, + greaterThanOrEqual: { type: 'greaterThanOrEqual', x: undefined, ...emptyCommonPart }, + patternContainSubsequence: { type: 'patternContainSubsequence', value: '', ...emptyCommonPart }, + patternNotContainSubsequence: { type: 'patternNotContainSubsequence', value: '', ...emptyCommonPart }, + patternFuzzyContainSubsequence: { type: 'patternFuzzyContainSubsequence', maxEdits: 2, substitutionsOnly: false, wildcard: undefined, value: '', ...emptyCommonPart }, + patternMatchesRegularExpression: { type: 'patternMatchesRegularExpression', value: '', ...emptyCommonPart }, + equal: { type: 'equal', x: undefined, ...emptyCommonPart }, + notEqual: { type: 'notEqual', x: undefined, ...emptyCommonPart }, + inSet: { type: 'inSet', value: [], ...emptyCommonPart }, + notInSet: { type: 'notInSet', value: [], ...emptyCommonPart }, +}; diff --git a/sdk/ui-vue/src/components/PlAdvancedFilter/index.ts b/sdk/ui-vue/src/components/PlAdvancedFilter/index.ts new file mode 100644 index 0000000000..fd428cbaca --- /dev/null +++ b/sdk/ui-vue/src/components/PlAdvancedFilter/index.ts @@ -0,0 +1 @@ +export { default as PlAdvancedFilter } from './PlAdvancedFilter.vue'; diff --git a/sdk/ui-vue/src/components/PlAdvancedFilter/types.ts b/sdk/ui-vue/src/components/PlAdvancedFilter/types.ts new file mode 100644 index 0000000000..acf23cac53 --- /dev/null +++ b/sdk/ui-vue/src/components/PlAdvancedFilter/types.ts @@ -0,0 +1,70 @@ +import type { AxisSpec, FilterSpecLeaf, FilterSpecType, ListOptionBase, PColumnSpec, SUniversalPColumnId } from '@platforma-sdk/model'; +import { SUPPORTED_FILTER_TYPES } from './constants'; + +// Not supported: topN, bottomN, lessThanColumn, lessThanColumnOrEqual +// or, and, not - in groups +export type SupportedFilterTypes = FilterSpecType & + 'isNA' | 'isNotNA' | + 'patternEquals' | 'patternNotEquals' | + 'patternContainSubsequence' | 'patternNotContainSubsequence' | + 'patternMatchesRegularExpression' | + 'patternFuzzyContainSubsequence' | + 'inSet' | 'notInSet' | + 'equal' | 'notEqual' | + 'lessThan' | 'lessThanOrEqual' | + 'greaterThan' | 'greaterThanOrEqual'; + +export type FilterType = SupportedFilterTypes; + +export function isSupportedFilterType(type: FilterSpecType | undefined): type is SupportedFilterTypes { + if (!type) { + return false; + } + return SUPPORTED_FILTER_TYPES.has(type as SupportedFilterTypes); +} + +export type Operand = 'or' | 'and'; + +type FilterUiBase = FilterSpecLeaf & { + type: SupportedFilterTypes; + column: SUniversalPColumnId; +}; + +type RequireFields = Omit & Required>; +type OptionalFields = Omit & Partial>; + +type NumericalWithOptionalX = 'lessThan' | 'lessThanOrEqual' | 'greaterThan' | 'greaterThanOrEqual' | 'equal' | 'notEqual'; +type StringWithOptionalValue = 'patternEquals' | 'patternNotEquals'; +type EditedTypes = SupportedFilterTypes & ('patternFuzzyContainSubsequence' | NumericalWithOptionalX | StringWithOptionalValue); // types from ui with some changed by optionality fields +export type Filter = Exclude | + RequireFields, 'maxEdits' | 'substitutionsOnly'> | + OptionalFields, 'x'> | + OptionalFields, 'value'> +; +export type Group = { + id: string; + not: boolean; + filters: Filter[]; + operand: Operand; +}; + +export type PlAdvancedFilterUI = { + groups: Group[]; + operand: Operand; +}; + +export type UniqueValuesList = ListOptionBase[]; +export type OptionInfo = { error: boolean; label: string; spec: PColumnSpec | AxisSpec }; +export type FixedAxisInfo = { + idx: number; + label: string; +}; + +export type SourceOptionInfo = { + id: SUniversalPColumnId; + label: string; + error: boolean; + spec: PColumnSpec | AxisSpec; + axesToBeFixed?: FixedAxisInfo[]; + alphabet?: 'nucleotide' | 'aminoacid' | string; +}; diff --git a/sdk/ui-vue/src/components/PlAdvancedFilter/utils.ts b/sdk/ui-vue/src/components/PlAdvancedFilter/utils.ts new file mode 100644 index 0000000000..700f3600b0 --- /dev/null +++ b/sdk/ui-vue/src/components/PlAdvancedFilter/utils.ts @@ -0,0 +1,175 @@ +import type { FilterSpec, ValueType } from '@platforma-sdk/model'; +import { assertNever, type AxisSpec, type PColumnSpec, type SUniversalPColumnId } from '@platforma-sdk/model'; +import { isSupportedFilterType, type Filter, type FilterType, type Group, type PlAdvancedFilterUI, type SupportedFilterTypes } from './types'; +import { DEFAULT_FILTER_TYPE, DEFAULT_FILTERS, SUPPORTED_FILTER_TYPES } from './constants'; +import { filterUiMetadata } from '@milaboratories/uikit'; + +function toInnerFilter(outerFilter: FilterSpec): Filter | null { + if (!('column' in outerFilter) || outerFilter.type === undefined || !isSupportedFilterType(outerFilter.type)) { + return null; + } + if (outerFilter.type === 'patternFuzzyContainSubsequence') { + return { + ...outerFilter, + value: outerFilter.value, + wildcard: outerFilter.wildcard, + maxEdits: outerFilter.maxEdits ?? 2, + substitutionsOnly: outerFilter.substitutionsOnly ?? false, + }; + } + + return { ...outerFilter } as Filter; +} + +let groupIdCounter = 0; +function getNewGroupId() { + groupIdCounter++; + return String(groupIdCounter); +} + +function toInnerFiltersGroup(f: FilterSpec): Group | null { + if (f.type === 'not') { + const group = toInnerFiltersGroup(f.filter); + return group + ? { + not: !group.not, + operand: group.operand, + filters: group.filters, + id: getNewGroupId(), + } + : null; + } + if (f.type === 'and' || f.type === 'or') { + return { + operand: f.type, + not: false, + filters: f.filters.map((f) => toInnerFilter(f)).filter((v) => v !== null) as Filter[], + id: getNewGroupId(), + }; + } + if (SUPPORTED_FILTER_TYPES.has(f.type as SupportedFilterTypes)) { + const filter = toInnerFilter(f); + return { + operand: 'or', + not: false, + filters: filter ? [filter] : [], + id: getNewGroupId(), + }; + } + return null; +} + +export function toInnerModel(m: FilterSpec): PlAdvancedFilterUI { + const res: PlAdvancedFilterUI = { + operand: 'or', + groups: [], + }; + if (m.type === 'not') { + return res; // not supported 'not' for all the groups in ui + } + if (m.type === 'and' || m.type === 'or') { + // group + res.operand = m.type; + res.groups = m.filters.map(toInnerFiltersGroup).filter((v) => v !== null) as Group[]; + } else if (SUPPORTED_FILTER_TYPES.has(m.type as SupportedFilterTypes)) { + // single filter + const group = toInnerFiltersGroup(m); + res.groups = group ? [group] : []; + } + res.groups = res.groups.filter((gr) => gr.filters.length > 0); + return res; +} + +function toOuterFilter(filter: Filter): FilterSpec | null { + if ( + filter.type === 'isNA' || filter.type === 'isNotNA' + || filter.type === 'inSet' || filter.type === 'notInSet' + ) { + return filter; + } + if ( + filter.type === 'greaterThanOrEqual' || filter.type === 'lessThanOrEqual' + || filter.type === 'greaterThan' || filter.type === 'lessThan' + || filter.type === 'equal' || filter.type === 'notEqual' + ) { + return filter.x !== undefined ? { ...filter, x: filter.x } : null; + } + if ( + filter.type === 'patternEquals' || filter.type === 'patternNotEquals' + || filter.type === 'patternContainSubsequence' || filter.type === 'patternNotContainSubsequence' + || filter.type === 'patternMatchesRegularExpression' + || filter.type === 'patternFuzzyContainSubsequence' + ) { + return filter.value !== undefined ? { ...filter, value: filter.value } : null; + } + assertNever(filter.type); +} + +function toOuterFilterGroup(m: Group): FilterSpec { + const res: FilterSpec = { + type: m.operand, + filters: m.filters.map(toOuterFilter).filter((v): v is FilterSpec => v !== null), + }; + if (m.not) { + return { + type: 'not', + filter: res, + } as FilterSpec; + } + return res; +} +export function toOuterModel(m: PlAdvancedFilterUI): FilterSpec { + return { + type: m.operand, + filters: m.groups.map(toOuterFilterGroup), + }; +} + +export function createNewGroup(selectedSourceId: string) { + return { + id: getNewGroupId(), + not: false, + operand: 'and' as const, + filters: [{ + ...DEFAULT_FILTERS[DEFAULT_FILTER_TYPE], + column: selectedSourceId as SUniversalPColumnId, + }], + }; +} + +export type NormalizedSpecData = { + valueType: ValueType; + annotations: PColumnSpec['annotations']; + domain: PColumnSpec['domain']; +}; +export function getNormalizedSpec(spec: PColumnSpec | AxisSpec): NormalizedSpecData { + const valueType = 'kind' in spec ? spec.valueType : spec.type; + return { valueType, annotations: spec.annotations, domain: spec.domain }; +} + +export function isNumericValueType(spec?: PColumnSpec | AxisSpec): boolean { + if (!spec) { + return false; + } + const valueType = getNormalizedSpec(spec).valueType; + return valueType === 'Int' || valueType === 'Long' || valueType === 'Float' || valueType === 'Double'; +} + +export function isStringValueType(spec?: PColumnSpec | AxisSpec): boolean { + if (!spec) { + return false; + } + const valueType = getNormalizedSpec(spec).valueType; + return valueType === 'String'; +} + +export function isNumericFilter(filter: Filter): filter is Filter & { type: 'equal' | 'notEqual' | 'lessThan' | 'lessThanOrEqual' | 'greaterThan' | 'greaterThanOrEqual' } { + return filter.type === 'equal' || filter.type === 'notEqual' || filter.type === 'lessThan' || filter.type === 'lessThanOrEqual' || filter.type === 'greaterThan' || filter.type === 'greaterThanOrEqual'; +} +export function isStringFilter(filter: Filter): filter is Filter & { type: 'patternEquals' | 'patternNotEquals' | 'patternContainSubsequence' | 'patternNotContainSubsequence' | 'patternMatchesRegularExpression' | 'patternFuzzyContainSubsequence' } { + return filter.type === 'patternEquals' || filter.type === 'patternNotEquals' || filter.type === 'patternContainSubsequence' || filter.type === 'patternNotContainSubsequence' || filter.type === 'patternMatchesRegularExpression' || filter.type === 'patternFuzzyContainSubsequence'; +} + +export function getFilterInfo(filterType: FilterType): { label: string; supportedFor: (spec: NormalizedSpecData) => boolean } { + return filterUiMetadata[filterType as keyof typeof filterUiMetadata]; +} diff --git a/sdk/ui-vue/src/lib.ts b/sdk/ui-vue/src/lib.ts index 155b5c8edb..c1da1b189b 100644 --- a/sdk/ui-vue/src/lib.ts +++ b/sdk/ui-vue/src/lib.ts @@ -34,6 +34,8 @@ export * from './components/PlAnnotations'; export * from './components/PlBtnExportArchive'; +export * from './components/PlAdvancedFilter'; + export * from './defineApp'; export * from './createModel';