feat(fe): boolean filter. Improve KVP
feat(fe): boolean filter. Improve KVP
This commit is contained in:
+12
-5
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user