feat(fe): boolean filter. Improve KVP

feat(fe): boolean filter. Improve KVP
This commit is contained in:
andrewwallacespeckle
2025-09-12 15:47:46 +01:00
committed by GitHub
15 changed files with 457 additions and 104 deletions
@@ -109,11 +109,6 @@ const isolateOrUnisolateObjects = () => {
const metadataGradientIsSet = ref(false)
watch(filteringState, (newVal) => {
if (newVal?.activePropFilterKey !== props.functionId)
metadataGradientIsSet.value = false
})
// NOTE: This is currently a hacky convention!!!
const computedPropInfo = computed(() => {
if (!hasMetadataGradient.value) return
@@ -202,4 +197,16 @@ const iconAndColor = computed(() => {
}
}
})
watch(
() => filters.propertyFilters.value,
(newFilters) => {
if (!props.functionId) return
const hasFilter = newFilters.some((f) => f.filter?.key === props.functionId)
if (!hasFilter && metadataGradientIsSet.value) {
metadataGradientIsSet.value = false
}
},
{ deep: true }
)
</script>
@@ -87,9 +87,11 @@
<script setup lang="ts">
import { useInjectedViewerInterfaceState } from '~~/lib/viewer/composables/setup'
import type { PropertySelectOption } from '~/lib/viewer/helpers/filters/types'
import type {
PropertySelectOption,
ExtendedPropertyInfo
} from '~/lib/viewer/helpers/filters/types'
import { FilterType } from '~/lib/viewer/helpers/filters/types'
import type { PropertyInfo } from '@speckle/viewer'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { X, Plus } from 'lucide-vue-next'
import { FormButton } from '@speckle/ui-components'
@@ -120,7 +122,8 @@ const filtersContainerRef = ref<HTMLElement>()
const shouldScrollToNewFilter = ref(false)
const showLargePropertyWarning = ref(false)
const pendingProperty = ref<Nullable<{ property: PropertyInfo; count: number }>>(null)
const pendingProperty =
ref<Nullable<{ property: ExtendedPropertyInfo; count: number }>>(null)
const propertySelectOptions = computed((): PropertySelectOption[] => {
if (!showPropertySelection.value) {
@@ -145,7 +148,12 @@ const propertySelectOptions = computed((): PropertySelectOption[] => {
value: filter.key,
label: propertyName,
parentPath,
type: filter.type === 'number' ? FilterType.Numeric : FilterType.String,
type:
filter.type === 'number'
? FilterType.Numeric
: (filter as { type: string }).type === 'boolean'
? FilterType.Boolean
: FilterType.String,
hasParent: lastDotIndex !== -1
}
})
@@ -217,7 +225,7 @@ const selectProperty = async (propertyKey: string) => {
}
// Check if this property has too many unique values
const { isLarge, count } = isLargeProperty(propertyKey)
const { isLarge, count } = isLargeProperty(property.key)
if (isLarge) {
// Store the pending property and show warning
@@ -229,7 +237,10 @@ const selectProperty = async (propertyKey: string) => {
processPropertySelection(property, propertyKey)
}
const processPropertySelection = (property: PropertyInfo, propertyKey: string) => {
const processPropertySelection = (
property: ExtendedPropertyInfo,
propertyKey: string
) => {
if (swappingFilterId.value) {
updateFilterProperty(swappingFilterId.value, property)
mp.track('Viewer Action', {
@@ -1,6 +1,9 @@
<template>
<div class="border border-outline-2 rounded-xl mb-2">
<div class="p-1" :class="{ 'border-b border-outline-3': !collapsed }">
<div
class="p-1"
:class="{ 'border-b border-outline-3': !collapsed && !isBooleanFilter(filter) }"
>
<ViewerFiltersFilterHeader
v-model:collapsed="collapsed"
:filter="filter"
@@ -13,6 +16,10 @@
:class="{ 'opacity-50': !filter.isApplied }"
>
<ViewerFiltersFilterNumeric v-if="isNumericFilter(filter)" :filter="filter" />
<ViewerFiltersFilterBoolean
v-else-if="isBooleanFilter(filter)"
:filter="filter"
/>
<ViewerFiltersFilterString
v-else
:filter="filter"
@@ -33,7 +40,8 @@ import type { FilterData, ConditionOption } from '~/lib/viewer/helpers/filters/t
import {
isNumericFilter,
isExistenceCondition,
SortMode
SortMode,
isBooleanFilter
} from '~/lib/viewer/helpers/filters/types'
import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering'
@@ -21,6 +21,10 @@
v-if="filter.type === FilterType.Numeric"
class="h-3 w-3 stroke-emerald-700 dark:stroke-emerald-500 shrink-0"
/>
<ToggleLeft
v-else-if="filter.type === FilterType.Boolean"
class="h-3 w-3 stroke-amber-500 dark:stroke-amber-400 shrink-0"
/>
<CaseUpper
v-else
class="h-3 w-3 stroke-violet-600 dark:stroke-violet-500 shrink-0"
@@ -59,6 +63,7 @@
@click.stop="collapsed = !collapsed"
/>
<FormButton
v-if="props.filter.type !== FilterType.Boolean"
v-tippy="'Toggle coloring for this property'"
:color="isColoringActive ? 'primary' : 'subtle'"
size="sm"
@@ -77,7 +82,8 @@ import {
CaseUpper,
ChevronsUpDown,
Ellipsis,
ChevronsDownUp
ChevronsDownUp,
ToggleLeft
} from 'lucide-vue-next'
import {
FormButton,
@@ -0,0 +1,38 @@
<template>
<div class="pt-2 pb-3 pl-8 flex flex-col gap-1">
<FormRadio
:name="`boolean-filter-${filter.id}`"
:model-value="filter.condition"
:value="BooleanFilterCondition.IsTrue"
label="True"
size="sm"
@update:model-value="updateCondition(BooleanFilterCondition.IsTrue)"
/>
<FormRadio
:name="`boolean-filter-${filter.id}`"
:model-value="filter.condition"
:value="BooleanFilterCondition.IsFalse"
label="False"
size="sm"
@update:model-value="updateCondition(BooleanFilterCondition.IsFalse)"
/>
</div>
</template>
<script setup lang="ts">
import { BooleanFilterCondition } from '~/lib/viewer/helpers/filters/types'
import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering'
import type { BooleanFilterData } from '~/lib/viewer/helpers/filters/types'
const props = defineProps<{
filter: BooleanFilterData
}>()
const { updateFilterCondition } = useFilterUtilities()
const updateCondition = (condition: BooleanFilterCondition) => {
updateFilterCondition(props.filter.id, condition)
}
</script>
@@ -50,6 +50,7 @@ const sortMode = defineModel<SortMode>('sortMode', {
default: SortMode.Alphabetical
})
const previousLength = ref(0)
const itemHeight = 28
const maxHeight = 240
@@ -65,8 +66,31 @@ const filteredValues = computed(() => {
})
})
const { list, containerProps, wrapperProps } = useVirtualList(filteredValues, {
itemHeight,
overscan: 5
})
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(
filteredValues,
{
itemHeight,
overscan: 10
}
)
// Scroll to newly added values
watch(
() => props.filter.selectedValues,
(newValues) => {
if (newValues.length > previousLength.value) {
// Find the newly added value
const newValue = newValues[newValues.length - 1]
const index = filteredValues.value.findIndex((v) => v === newValue)
if (index !== -1) {
nextTick(() => {
const scrollIndex = Math.max(0, index - 3) // Center-ish position
scrollTo(scrollIndex)
})
}
}
previousLength.value = newValues.length
},
{ immediate: true }
)
</script>
@@ -13,6 +13,10 @@
v-if="property.type === FilterType.Numeric"
class="h-3 w-3 stroke-emerald-700 dark:stroke-emerald-500"
/>
<ToggleLeft
v-else-if="property.type === FilterType.Boolean"
class="h-3 w-3 stroke-amber-500 dark:stroke-amber-400"
/>
<CaseUpper v-else class="h-3 w-3 stroke-violet-600 dark:stroke-violet-500" />
<div class="min-w-0 flex-1">
<div
@@ -34,7 +38,7 @@
</template>
<script setup lang="ts">
import { Hash, CaseUpper } from 'lucide-vue-next'
import { Hash, CaseUpper, ToggleLeft } from 'lucide-vue-next'
import type { PropertyOption } from '~/lib/viewer/helpers/filters/types'
import { FilterType } from '~/lib/viewer/helpers/filters/types'
@@ -76,7 +76,11 @@ import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import type { KeyValuePair } from '~/components/viewer/selection/types'
import { isNumericPropertyInfo } from '~/lib/viewer/helpers/sceneExplorer'
import type { PropertyInfo } from '@speckle/viewer'
import {
BooleanFilterCondition,
type ExtendedPropertyInfo
} from '~/lib/viewer/helpers/filters/types'
import { isBooleanProperty } from '~/lib/viewer/helpers/filters/utils'
const props = defineProps<{
kvp: KeyValuePair
@@ -88,23 +92,26 @@ const {
findFilterByKvp,
addActiveFilter,
updateActiveFilterValues,
updateFilterCondition,
setNumericRange,
isLargeProperty
isLargeProperty,
getPropertyOptionsFromDataStore
} = useFilterUtilities()
const {
viewer: {
metadata: { availableFilters }
},
ui: {
panels: { active: activePanel }
}
} = useInjectedViewerState()
const availableFilters = computed(
() => getPropertyOptionsFromDataStore() as ExtendedPropertyInfo[]
)
const showActionsMenu = ref(false)
const showLargePropertyWarning = ref(false)
const pendingFilter = ref<PropertyInfo | null>(null)
const pendingFilter = ref<ExtendedPropertyInfo | null>(null)
const pendingFilterCount = ref(0)
const isUrlString = (v: unknown) => typeof v === 'string' && VALID_HTTP_URL.test(v)
@@ -118,10 +125,16 @@ const isCopyable = computed(() => {
})
const isFilterable = computed(() => {
if (props.kvp.value === null || props.kvp.value === undefined) {
return false
}
return isKvpFilterable(props.kvp, availableFilters.value)
})
const getDisabledReason = computed(() => {
if (props.kvp.value === null || props.kvp.value === undefined) {
return 'Cannot filter on null values'
}
return getFilterDisabledReason(props.kvp, availableFilters.value)
})
@@ -141,7 +154,7 @@ const handleAddToFilters = (kvp: KeyValuePair) => {
}
}
const addFilterWithValue = (filter: PropertyInfo, kvp: KeyValuePair) => {
const addFilterWithValue = (filter: ExtendedPropertyInfo, kvp: KeyValuePair) => {
const filterId = addActiveFilter(filter)
if (isNumericPropertyInfo(filter)) {
@@ -151,6 +164,13 @@ const addFilterWithValue = (filter: PropertyInfo, kvp: KeyValuePair) => {
if (!isNaN(numericValue)) {
setNumericRange(filterId, numericValue, numericValue)
}
} else if (isBooleanProperty(filter)) {
// For boolean filters, set the condition based on the value
const boolValue = kvp.value === true || kvp.value === 'true'
const condition = boolValue
? BooleanFilterCondition.IsTrue
: BooleanFilterCondition.IsFalse
updateFilterCondition(filterId, condition)
} else {
// For string filters, use the selectedValues array
const values = [String(kvp.value)]
@@ -47,6 +47,7 @@
:object="(kvp.value as SpeckleObject) || {}"
:title="(kvp.key as string)"
:unfold="autoUnfoldKeys.includes(kvp.key)"
:parent-path="currentPath"
/>
</div>
<div
@@ -113,6 +114,7 @@ const props = withDefaults(
unfold?: boolean
debug?: boolean
modifiedSibling?: boolean
parentPath?: string
}>(),
{ debug: false, unfold: false, root: false, modifiedSibling: false }
)
@@ -121,6 +123,15 @@ const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities(
const unfold = ref(props.unfold)
const autoUnfoldKeys = ['properties', 'Instance Parameters']
// Compute the current full path for this object
const currentPath = computed(() => {
if (props.root) return ''
if (!props.parentPath) return props.title || ''
return props.parentPath
? `${props.parentPath}.${props.title || ''}`
: props.title || ''
})
const isAdded = computed(() => {
if (!diffEnabled.value) return false
return (
@@ -242,14 +253,17 @@ const keyValuePairs = computed(() => {
) {
// note: handles name value pairs from dui3 -
const { value, units } = props.object[key] as { value: string; units?: string }
const fullPath = currentPath.value ? `${currentPath.value}.${key}` : key
kvps.push({
key,
type: typeof value,
value: value as string,
units
units,
backendPath: fullPath
})
continue
}
const fullPath = currentPath.value ? `${currentPath.value}.${key}` : key
kvps.push({
key,
type,
@@ -257,7 +271,7 @@ const keyValuePairs = computed(() => {
arrayLength,
arrayPreview,
value: props.object[key],
backendPath: key
backendPath: fullPath
})
}
@@ -4,7 +4,8 @@ import {
FilterLogic,
NumericFilterCondition,
StringFilterCondition,
ExistenceFilterCondition
ExistenceFilterCondition,
BooleanFilterCondition
} from '~/lib/viewer/helpers/filters/types'
import type {
DataSlice,
@@ -229,6 +230,26 @@ export function useCreateViewerFilteringDataStore() {
matchingIds.push(objectId)
}
}
} else if (criteria.condition === BooleanFilterCondition.IsTrue) {
// Find all where this property is true
for (const [objectId, objProps] of Object.entries(
dataSource.objectProperties
)) {
const value = objProps[criteria.propertyKey]
if (value === true || value === 'true') {
matchingIds.push(objectId)
}
}
} else if (criteria.condition === BooleanFilterCondition.IsFalse) {
// Find all objects where this property is false
for (const [objectId, objProps] of Object.entries(
dataSource.objectProperties
)) {
const value = objProps[criteria.propertyKey]
if (value === false || value === 'false') {
matchingIds.push(objectId)
}
}
} else {
// For value-based filtering, check each object
for (const [objectId, objProps] of Object.entries(
@@ -1,8 +1,4 @@
import type {
PropertyInfo,
NumericPropertyInfo,
StringPropertyInfo
} from '@speckle/viewer'
import type { NumericPropertyInfo, StringPropertyInfo } from '@speckle/viewer'
import { difference, uniq, partition } from 'lodash-es'
import { whenever } from '@vueuse/core'
import {
@@ -17,14 +13,19 @@ import {
type FilterData,
type NumericFilterData,
type StringFilterData,
type BooleanFilterData,
type BooleanPropertyInfo,
type CreateFilterParams,
isNumericFilter,
isBooleanFilter,
NumericFilterCondition,
StringFilterCondition,
ExistenceFilterCondition,
BooleanFilterCondition,
SortMode,
type DataSlice,
type QueryCriteria
type QueryCriteria,
type ExtendedPropertyInfo
} from '~/lib/viewer/helpers/filters/types'
import { getConditionLabel } from '~/lib/viewer/helpers/filters/constants'
import { useFilteringDataStore } from '~/lib/viewer/composables/filtering/dataStore'
@@ -124,11 +125,16 @@ export function useFilterUtilities(
/**
* Gets available values for the current property filter (used for UI display)
*/
const getAvailableFilterValues = (filter: PropertyInfo, limit?: number): string[] => {
const getAvailableFilterValues = (
filter: ExtendedPropertyInfo,
limit?: number
): string[] => {
// Type guard to check if filter has valueGroups property
const hasValueGroups = (
f: PropertyInfo
): f is PropertyInfo & { valueGroups: Array<{ value: string | number }> } => {
f: ExtendedPropertyInfo
): f is ExtendedPropertyInfo & {
valueGroups: Array<{ value: string | number }>
} => {
return (
'valueGroups' in f && Array.isArray((f as Record<string, unknown>).valueGroups)
)
@@ -181,7 +187,9 @@ export function useFilterUtilities(
/**
* Computes full property data for a given property key (min/max, valueGroups)
*/
const computeFullPropertyData = (propertyKey: string): PropertyInfo | null => {
const computeFullPropertyData = (
propertyKey: string
): ExtendedPropertyInfo | null => {
const valueToObjectIds = new Map<string, string[]>()
for (const dataSource of dataStore.dataSources.value) {
@@ -208,11 +216,26 @@ export function useFilterUtilities(
const uniqueValues = Array.from(valueToObjectIds.keys())
const firstValue = uniqueValues[0]
const isBooleanProperty =
uniqueValues.every((v) => v === 'true' || v === 'false') &&
uniqueValues.length <= 2
const isNumeric =
typeof firstValue === 'number' ||
(!isNaN(Number(firstValue)) && String(firstValue) !== '')
if (isNumeric) {
if (isBooleanProperty) {
return {
key: propertyKey,
type: 'boolean',
objectCount: valueToObjectIds.size,
valueGroups: uniqueValues.map((value) => ({
value: value === 'true',
ids: valueToObjectIds.get(value) || []
}))
} as BooleanPropertyInfo
} else if (isNumeric) {
const numericValues = uniqueValues.map((v) => Number(v)).filter((v) => !isNaN(v))
const min = parseFloat(Math.min(...numericValues).toFixed(4))
const max = parseFloat(Math.max(...numericValues).toFixed(4))
@@ -251,6 +274,12 @@ export function useFilterUtilities(
}
}
const isBooleanPropertyInfo = (
prop: ExtendedPropertyInfo
): prop is BooleanPropertyInfo => {
return prop.type === 'boolean'
}
const createFilterData = (params: CreateFilterParams): FilterData => {
const { filter, id } = params
@@ -258,7 +287,16 @@ export function useFilterUtilities(
const fullFilter =
filter.objectCount === 0 ? computeFullPropertyData(filter.key) || filter : filter
if (isNumericPropertyInfo(fullFilter)) {
if (isBooleanPropertyInfo(fullFilter)) {
return {
id,
isApplied: true,
selectedValues: [],
condition: BooleanFilterCondition.IsTrue,
type: FilterType.Boolean,
filter: fullFilter
} as BooleanFilterData
} else if (isNumericPropertyInfo(fullFilter)) {
const numericFilter = fullFilter as NumericPropertyInfo
const { min, max } = numericFilter
const range = max - min
@@ -309,7 +347,7 @@ export function useFilterUtilities(
}
}
const addActiveFilter = (filter: PropertyInfo): string => {
const addActiveFilter = (filter: ExtendedPropertyInfo): string => {
const existingIndex = filters.propertyFilters.value.findIndex(
(f) => f.filter?.key === filter.key
)
@@ -332,7 +370,7 @@ export function useFilterUtilities(
*/
const updateFilterProperty = (
filterId: string,
newProperty: PropertyInfo
newProperty: ExtendedPropertyInfo
): boolean => {
const filterIndex = filters.propertyFilters.value.findIndex(
(f) => f.id === filterId
@@ -389,7 +427,11 @@ export function useFilterUtilities(
if (filter) {
filter.selectedValues = [...values]
if (!isNumericFilter(filter) && filter.isDefaultAllSelected) {
if (
!isNumericFilter(filter) &&
!isBooleanFilter(filter) &&
filter.isDefaultAllSelected
) {
filter.isDefaultAllSelected = false
}
@@ -422,7 +464,9 @@ export function useFilterUtilities(
const allValues = getAllPropertyValues(propertyFilter.key)
filter.selectedValues = [...allValues]
}
filter.isDefaultAllSelected = false
if (!isBooleanFilter(filter)) {
filter.isDefaultAllSelected = false
}
updateDataStoreSlices()
}
@@ -515,9 +559,26 @@ export function useFilterUtilities(
objectIds: matchingObjectIds
}
newFilterSlices.push(slice)
} else if (isBooleanFilter(filter) && filter.isApplied) {
const { condition } = filter
const queryCriteria: QueryCriteria = {
propertyKey: filter.filter.key,
condition,
values: []
}
const matchingObjectIds = dataStore.queryObjects(queryCriteria)
const slice: DataSlice = {
id: `filter-${filter.id}`,
widgetId: filter.id,
name: `${getPropertyName(filter.filter.key)} ${getConditionLabel(condition)}`,
objectIds: matchingObjectIds
}
newFilterSlices.push(slice)
} else if (
!isNumericFilter(filter) &&
!isBooleanFilter(filter) &&
filter.isApplied &&
(filter.selectedValues.length > 0 ||
filter.isDefaultAllSelected ||
@@ -575,7 +636,11 @@ export function useFilterUtilities(
const index = filter.selectedValues.indexOf(value)
const wasSelected = index > -1
if (!isNumericFilter(filter) && filter.isDefaultAllSelected) {
if (
!isNumericFilter(filter) &&
!isBooleanFilter(filter) &&
filter.isDefaultAllSelected
) {
filter.selectedValues = [value]
filter.isDefaultAllSelected = false
} else {
@@ -667,7 +732,7 @@ export function useFilterUtilities(
id: string
condition: 'AND' | 'OR'
}>,
availableProperties: PropertyInfo[]
availableProperties: ExtendedPropertyInfo[]
) => {
for (const serializedFilter of serializedFilters) {
if (serializedFilter.key) {
@@ -693,9 +758,9 @@ export function useFilterUtilities(
* Filters the available filters to only include relevant ones for the filter UI
*/
const getRelevantFilters = (
allFilters: PropertyInfo[] | null | undefined
): PropertyInfo[] => {
return (allFilters || []).filter((f: PropertyInfo) => {
allFilters: ExtendedPropertyInfo[] | null | undefined
): ExtendedPropertyInfo[] => {
return (allFilters || []).filter((f: ExtendedPropertyInfo) => {
if (shouldExcludeFromFiltering(f.key)) {
return false
}
@@ -706,8 +771,11 @@ export function useFilterUtilities(
/**
* Gets property options from the data store (optimized to use propertyMap)
*/
const getPropertyOptionsFromDataStore = (): PropertyInfo[] => {
const allProperties = new Map<string, PropertyInfo>()
const getPropertyOptionsFromDataStore = (): (
| ExtendedPropertyInfo
| BooleanPropertyInfo
)[] => {
const allProperties = new Map<string, ExtendedPropertyInfo | BooleanPropertyInfo>()
for (const dataSource of dataStore.dataSources.value) {
const propertyKeys = Object.keys(dataSource.propertyMap)
@@ -719,10 +787,18 @@ export function useFilterUtilities(
const propertyInfo = dataSource.propertyMap[propertyKey]
const value = propertyInfo.value
const isBoolean = String(value) === 'true' || String(value) === 'false'
const isNumeric =
typeof value === 'number' || (!isNaN(Number(value)) && String(value) !== '')
if (isNumeric) {
if (isBoolean) {
allProperties.set(propertyKey, {
key: propertyKey,
type: 'boolean',
objectCount: 0,
valueGroups: []
} as BooleanPropertyInfo)
} else if (isNumeric) {
allProperties.set(propertyKey, {
key: propertyKey,
type: 'number',
@@ -751,7 +827,7 @@ export function useFilterUtilities(
* Gets filtered and sorted values for a string filter with search and sorting options
*/
const getFilteredFilterValues = (
filter: PropertyInfo,
filter: ExtendedPropertyInfo,
options?: {
searchQuery?: string
sortMode?: SortMode
@@ -792,7 +868,8 @@ export function useFilterUtilities(
whenever(shouldRestoreFilters, () => {
if (pendingFiltersToRestore.value) {
const availableProperties = getPropertyOptionsFromDataStore()
const availableProperties =
getPropertyOptionsFromDataStore() as ExtendedPropertyInfo[]
applyFiltersFromSerialized(pendingFiltersToRestore.value, availableProperties)
pendingFiltersToRestore.value = null
}
@@ -3,6 +3,7 @@ import {
NumericFilterCondition,
StringFilterCondition,
ExistenceFilterCondition,
BooleanFilterCondition,
FilterType
} from '~/lib/viewer/helpers/filters/types'
@@ -16,7 +17,9 @@ export const FILTER_CONDITION_CONFIG: Record<FilterCondition, { label: string }>
[NumericFilterCondition.IsLessThan]: { label: 'is less than' },
[NumericFilterCondition.IsBetween]: { label: 'is between' },
[ExistenceFilterCondition.IsSet]: { label: 'is set' },
[ExistenceFilterCondition.IsNotSet]: { label: 'is not set' }
[ExistenceFilterCondition.IsNotSet]: { label: 'is not set' },
[BooleanFilterCondition.IsTrue]: { label: 'is true' },
[BooleanFilterCondition.IsFalse]: { label: 'is false' }
} as const
// Popular Filter Properties
@@ -49,6 +52,11 @@ export const getConditionsForType = (filterType: FilterType): FilterCondition[]
...Object.values(NumericFilterCondition),
...Object.values(ExistenceFilterCondition)
]
} else if (filterType === FilterType.Boolean) {
return [
...Object.values(BooleanFilterCondition),
...Object.values(ExistenceFilterCondition)
]
} else {
return [
...Object.values(StringFilterCondition),
@@ -26,10 +26,16 @@ export enum ExistenceFilterCondition {
IsNotSet = 'is_not_set'
}
export enum BooleanFilterCondition {
IsTrue = 'is_true',
IsFalse = 'is_false'
}
export type FilterCondition =
| NumericFilterCondition
| StringFilterCondition
| ExistenceFilterCondition
| BooleanFilterCondition
// Filter Enums
export enum FilterLogic {
@@ -39,7 +45,8 @@ export enum FilterLogic {
export enum FilterType {
String = 'string',
Numeric = 'numeric'
Numeric = 'numeric',
Boolean = 'boolean'
}
export enum SortMode {
@@ -70,12 +77,32 @@ export type StringFilterData = BaseFilterData & {
isDefaultAllSelected?: boolean
}
export type FilterData = NumericFilterData | StringFilterData
export type ExtendedPropertyInfo =
| PropertyInfo
| {
key: string
objectCount: number
type: 'boolean'
valueGroups: { value: boolean; ids: string[] }[]
}
export type BooleanPropertyInfo = Extract<ExtendedPropertyInfo, { type: 'boolean' }>
export type BooleanFilterData = BaseFilterData & {
type: FilterType.Boolean
filter: BooleanPropertyInfo
}
export type FilterData = NumericFilterData | StringFilterData | BooleanFilterData
export const isNumericFilter = (filter: FilterData): filter is NumericFilterData => {
return filter.type === FilterType.Numeric
}
export const isBooleanFilter = (filter: FilterData): filter is BooleanFilterData => {
return filter.type === FilterType.Boolean
}
export const isExistenceCondition = (condition: FilterCondition): boolean => {
return (
condition === ExistenceFilterCondition.IsSet ||
@@ -107,7 +134,7 @@ export type PropertySelectionListItem = {
}
export type CreateFilterParams = {
filter: PropertyInfo
filter: ExtendedPropertyInfo
id: string
availableValues?: string[]
}
@@ -1,6 +1,11 @@
import type { PropertyInfo } from '@speckle/viewer'
import { isStringPropertyInfo } from '~/lib/viewer/helpers/sceneExplorer'
import { ExistenceFilterCondition, FilterType, type DataSource } from './types'
import {
ExistenceFilterCondition,
FilterType,
type BooleanPropertyInfo,
type DataSource,
type ExtendedPropertyInfo
} from '~/lib/viewer/helpers/filters/types'
export const revitPropertyRegex = /^parameters\./
export const revitPropertyRegexDui3000InstanceProps = /^properties\.Instance/
@@ -59,7 +64,7 @@ export const shouldExcludeFromFiltering = (key: string): boolean => {
*/
export const getPropertyName = (
key: string,
availableFilters?: PropertyInfo[] | null
availableFilters?: ExtendedPropertyInfo[] | null
): string => {
if (!key) return 'Loading'
@@ -68,7 +73,7 @@ export const getPropertyName = (
if (isRevitProperty(key) && key.endsWith('.value')) {
const correspondingProperty = (availableFilters || []).find(
(f: PropertyInfo) => f.key === key.replace('.value', '.name')
(f: ExtendedPropertyInfo) => f.key === key.replace('.value', '.name')
)
if (correspondingProperty && isStringPropertyInfo(correspondingProperty)) {
return correspondingProperty.valueGroups[0]?.value || key.split('.').pop() || key
@@ -79,16 +84,27 @@ export const getPropertyName = (
}
/**
* Finds a filter by matching display names (handles complex nested properties)
* Finds a filter by matching display names
*/
export const findFilterByDisplayName = (
displayKey: string,
availableFilters: PropertyInfo[] | null | undefined
): PropertyInfo | undefined => {
return availableFilters?.find((f) => {
const backendDisplayName = getPropertyName(f.key, availableFilters)
return backendDisplayName === displayKey || f.key.split('.').pop() === displayKey
availableFilters: ExtendedPropertyInfo[] | null | undefined
): ExtendedPropertyInfo | undefined => {
if (!availableFilters) return undefined
// First, try to find an exact display name match
const exactDisplayMatch = availableFilters.find((f) => {
const propertyDisplayName = getPropertyName(f.key, availableFilters)
return propertyDisplayName === displayKey
})
if (exactDisplayMatch) return exactDisplayMatch
// Then try to find a match where the key ends with the display key
const endMatches = availableFilters
.filter((f) => f.key.split('.').pop() === displayKey)
.sort((a, b) => a.key.length - b.key.length) // Shorter paths first
return endMatches[0] // Return the shortest matching path
}
/**
@@ -96,13 +112,14 @@ export const findFilterByDisplayName = (
*/
export const isKvpFilterable = (
kvp: { key: string; backendPath?: string },
availableFilters: PropertyInfo[] | null | undefined
availableFilters: ExtendedPropertyInfo[] | null | undefined
): boolean => {
const backendKey = kvp.backendPath || kvp.key
// Use backendPath for legacy compatibility, but prefer the direct key
const propertyKey = kvp.backendPath || kvp.key
const directMatch = availableFilters?.some((f) => f.key === backendKey)
const directMatch = availableFilters?.some((f) => f.key === propertyKey)
if (directMatch) {
return !shouldExcludeFromFiltering(backendKey)
return !shouldExcludeFromFiltering(propertyKey)
}
const displayKey = kvp.key as string
@@ -120,29 +137,51 @@ export const isKvpFilterable = (
*/
export const getFilterDisabledReason = (
kvp: { key: string; backendPath?: string },
availableFilters: PropertyInfo[] | null | undefined
availableFilters: ExtendedPropertyInfo[] | null | undefined
): string => {
const backendKey = kvp.backendPath || kvp.key
const availableKeys = availableFilters?.map((f) => f.key) || []
if (!availableKeys.includes(backendKey)) {
const similarKeys = availableKeys.filter(
(key) =>
key.toLowerCase().includes('type') ||
key.toLowerCase().includes('category') ||
key.toLowerCase().includes('class')
)
if (kvp.backendPath) {
if (!availableKeys.includes(kvp.backendPath)) {
const propertyName = kvp.key
const similarPaths = availableKeys.filter(
(key) => key.split('.').pop() === propertyName
)
const debugInfo =
similarKeys.length > 0
? ` (Similar available: ${similarKeys.slice(0, 3).join(', ')})`
: ''
if (similarPaths.length > 0) {
return `Property path '${
kvp.backendPath
}' not found. Similar properties: ${similarPaths.slice(0, 3).join(', ')}`
}
return `Property '${backendKey}' is not available in backend filters${debugInfo}`
}
return `Property path '${kvp.backendPath}' is not available in the current scene`
}
if (shouldExcludeFromFiltering(backendKey)) {
return `Property '${backendKey}' is excluded from filtering (technical property)`
if (shouldExcludeFromFiltering(kvp.backendPath)) {
return `Property '${kvp.backendPath}' is excluded from filtering (technical property)`
}
} else {
// Fallback to key-based checking
const propertyKey = kvp.key
if (!availableKeys.includes(propertyKey)) {
const similarKeys = availableKeys.filter(
(key) =>
key.toLowerCase().includes('type') ||
key.toLowerCase().includes('category') ||
key.toLowerCase().includes('class')
)
const debugInfo =
similarKeys.length > 0
? ` (Similar available: ${similarKeys.slice(0, 3).join(', ')})`
: ''
return `Property '${propertyKey}' is not available in the current scene${debugInfo}`
}
if (shouldExcludeFromFiltering(propertyKey)) {
return `Property '${propertyKey}' is excluded from filtering (technical property)`
}
}
return 'This property is not available for filtering'
@@ -153,24 +192,73 @@ export const getFilterDisabledReason = (
*/
export const findFilterByKvp = (
kvp: { key: string; backendPath?: string },
availableFilters: PropertyInfo[] | null | undefined
): PropertyInfo | undefined => {
const backendKey = kvp.backendPath || kvp.key
availableFilters: ExtendedPropertyInfo[] | null | undefined
): ExtendedPropertyInfo | undefined => {
if (!availableFilters) return undefined
let filter = availableFilters?.find((f: PropertyInfo) => f.key === backendKey)
if (!filter) {
const displayKey = kvp.key as string
filter = findFilterByDisplayName(displayKey, availableFilters)
if (kvp.backendPath) {
const exactMatch = availableFilters.find(
(f: ExtendedPropertyInfo) => f.key === kvp.backendPath
)
if (exactMatch) {
return exactMatch
}
}
return filter
const directMatch = availableFilters.find(
(f: ExtendedPropertyInfo) => f.key === kvp.key
)
if (directMatch) {
return directMatch
}
// If we have a backendPath but no exact match, try partial matching
if (kvp.backendPath) {
const pathParts = kvp.backendPath.split('.')
const partialMatches = availableFilters.filter((f: ExtendedPropertyInfo) => {
const filterParts = f.key.split('.')
if (pathParts.length === 1) {
return filterParts[filterParts.length - 1] === pathParts[0]
}
if (pathParts.length >= 2 && filterParts.length >= 2) {
const kvpEnd = pathParts.slice(-2).join('.')
const filterEnd = filterParts.slice(-2).join('.')
return kvpEnd === filterEnd
}
return false
})
if (partialMatches.length === 1) {
return partialMatches[0]
}
if (partialMatches.length > 1) {
const sortedMatches = partialMatches.sort((a, b) => a.key.length - b.key.length)
return sortedMatches[0]
}
return undefined
}
// Only fall back to fuzzy matching if no backendPath is provided (legacy support)
const displayKey = kvp.key as string
return findFilterByDisplayName(displayKey, availableFilters)
}
export const isBooleanProperty = (filter: ExtendedPropertyInfo): boolean => {
return 'type' in filter && (filter as { type: string }).type === 'boolean'
}
/**
* Get count for a specific filter value
*/
export function getFilterValueCount(filter: PropertyInfo, value: string): number {
export function getFilterValueCount(
filter: ExtendedPropertyInfo,
value: string
): number {
if (!('valueGroups' in filter) || !Array.isArray(filter.valueGroups)) {
return 0
}
@@ -190,7 +278,7 @@ export function getFilterValueCount(filter: PropertyInfo, value: string): number
* Get count for existence filters (objects that have/don't have a property set)
*/
export function getExistenceFilterCount(
filter: PropertyInfo,
filter: ExtendedPropertyInfo | BooleanPropertyInfo,
condition: ExistenceFilterCondition,
totalObjectCount?: number
): number {
@@ -1,18 +1,18 @@
import type { MaybeNullOrUndefined } from '@speckle/shared'
import type {
NumericPropertyInfo,
PropertyInfo,
SpeckleObject,
SpeckleReference,
StringPropertyInfo
} from '@speckle/viewer'
import type { Raw } from 'vue'
import type { ExtendedPropertyInfo } from '~/lib/viewer/helpers/filters/types'
export const isStringPropertyInfo = (
info: MaybeNullOrUndefined<PropertyInfo>
info: MaybeNullOrUndefined<ExtendedPropertyInfo>
): info is StringPropertyInfo => info?.type === 'string'
export const isNumericPropertyInfo = (
info: MaybeNullOrUndefined<PropertyInfo>
info: MaybeNullOrUndefined<ExtendedPropertyInfo>
): info is NumericPropertyInfo => info?.type === 'number'
export type ExplorerNode = {