feat(fe): "filter by property" from selection info panel

feat(fe): "filter by property" from selection info panel
This commit is contained in:
andrewwallacespeckle
2025-08-15 12:13:45 +01:00
committed by GitHub
5 changed files with 265 additions and 129 deletions
@@ -243,7 +243,7 @@ const isTablet = breakpoints.smaller('lg')
const { getTooltipProps } = useSmartTooltipDelay()
const isSavedViewsEnabled = useAreSavedViewsEnabled()
const { $intercom } = useNuxtApp()
const { hasActiveFilters } = useFilterUtilities()
const { hasActiveFilters, filters } = useFilterUtilities()
const activePanel = ref<ActivePanel>('none')
const modelsSubView = ref<ModelsSubView>(ModelsSubView.Main)
@@ -339,6 +339,17 @@ watch(isSmallerOrEqualSm, (newVal) => {
activePanel.value = newVal ? 'none' : 'models'
})
// Auto-open filters panel when a new filter is applied from elsewhere
watch(
() => filters.propertyFilter.isApplied.value && filters.propertyFilter.filter.value,
(newFilterApplied, oldFilterApplied) => {
// Only trigger if we're going from no filter to having a filter (not when changing filters or removing)
if (newFilterApplied && !oldFilterApplied) {
activePanel.value = 'filters'
}
}
)
defineExpose({
forceClosePanel,
forceClosePanels: forceClosePanel
@@ -116,64 +116,19 @@ const {
removePropertyFilter,
applyPropertyFilter,
unApplyPropertyFilter,
filters: { propertyFilter }
filters: { propertyFilter },
getRelevantFilters,
isRevitProperty
} = useFilterUtilities()
const {
metadata: { availableFilters: allFilters }
} = useInjectedViewer()
const revitPropertyRegex = /^parameters\./
// Note: we've split this regex check in two to not clash with navis properties. This makes generally makes dim very sad, as we're layering hacks.
// Navis object properties come under `properties`, same as revit ones - as such we can't assume they're the same. Here we're targeting revit's
// specific two subcategories of `properties`.
const revitPropertyRegexDui3000InstanceProps = /^properties\.Instance/ // note this is partially valid for civil3d, or dim should test against it
const revitPropertyRegexDui3000TypeProps = /^properties\.Type/ // note this is partially valid for civil3d, or dim should test against it
const showAllFilters = ref(false)
const isRevitProperty = (key: string): boolean => {
return (
revitPropertyRegex.test(key) ||
revitPropertyRegexDui3000InstanceProps.test(key) ||
revitPropertyRegexDui3000TypeProps.test(key)
)
}
const relevantFilters = computed(() => {
return (allFilters.value || []).filter((f: PropertyInfo) => {
if (
f.key.endsWith('.units') ||
f.key.endsWith('.speckle_type') ||
f.key.includes('.parameters.') ||
// f.key.includes('level.') ||
f.key.includes('renderMaterial') ||
f.key.includes('.domain') ||
f.key.includes('plane.') ||
f.key.includes('baseLine') ||
f.key.includes('referenceLine') ||
f.key.includes('end.') ||
f.key.includes('start.') ||
f.key.includes('endPoint.') ||
f.key.includes('midPoint.') ||
f.key.includes('startPoint.') ||
f.key.includes('startPoint.') ||
f.key.includes('.materialName') ||
f.key.includes('.materialClass') ||
f.key.includes('.materialCategory') ||
f.key.includes('displayStyle') ||
f.key.includes('displayValue') ||
f.key.includes('displayMesh')
) {
return false
}
// handle revit params: the actual one single value we're interested is in paramters.HOST_BLA BLA_.value, the rest are not needed
if (isRevitProperty(f.key)) {
if (f.key.endsWith('.value')) return true
else return false
}
return true
})
return getRelevantFilters(allFilters.value)
})
const speckleTypeFilter = computed(() =>
@@ -0,0 +1,154 @@
<template>
<div class="flex w-full">
<div
:class="`grid grid-cols-3 w-full pl-2 h-5 items-center ${
kvp.value === null || kvp.value === undefined ? 'text-foreground-2' : ''
}`"
>
<div
class="col-span-1 truncate text-body-3xs mr-2 font-medium text-foreground-2"
:title="(kvp.key as string)"
>
{{ kvp.key }}
</div>
<div
class="group col-span-2 pl-1 truncate text-body-3xs flex gap-1 items-center text-foreground"
:title="(kvp.value as string)"
>
<div class="flex gap-1 items-center w-full">
<!-- NOTE: can't do kvp.value || 'null' because 0 || 'null' = 'null' -->
<template v-if="isUrlString(kvp.value)">
<a
:href="kvp.value as string"
target="_blank"
rel="noopener"
class="truncate border-b border-outline-3 hover:border-outline-5"
:class="kvp.value === null ? '' : 'group-hover:max-w-[calc(100%-1rem)]'"
>
{{ kvp.value }}
</a>
</template>
<template v-else>
<span
class="truncate"
:class="kvp.value === null ? '' : 'group-hover:max-w-[calc(100%-1rem)]'"
>
{{ kvp.value === null ? 'null' : kvp.value }}
</span>
</template>
<span v-if="kvp.units" class="truncate opacity-70">
{{ kvp.units }}
</span>
<LayoutMenu
v-model:open="showActionsMenu"
:items="actionsItems"
mount-menu-on-body
@click.stop.prevent
@chosen="onActionChosen"
>
<button
class="group-hover:opacity-100 hover:bg-highlight-1 rounded h-4 w-4 flex items-center justify-center"
:class="showActionsMenu ? 'bg-highlight-1 opacity-100' : 'opacity-0'"
@click="showActionsMenu = !showActionsMenu"
>
<Ellipsis class="h-3 w-3" />
</button>
</LayoutMenu>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { VALID_HTTP_URL } from '~~/lib/common/helpers/validation'
import { LayoutMenu, type LayoutMenuItem } from '@speckle/ui-components'
import { Ellipsis } from 'lucide-vue-next'
import { useFilterUtilities } from '~~/lib/viewer/composables/ui'
import { useInjectedViewer } from '~~/lib/viewer/composables/setup'
import type { PropertyInfo } from '@speckle/viewer'
const props = defineProps<{
kvp: Record<string, unknown> & { key: string; value: unknown; units?: string }
}>()
const showActionsMenu = ref(false)
const { setPropertyFilter, applyPropertyFilter, isPropertyFilterable } =
useFilterUtilities()
const {
metadata: { availableFilters }
} = useInjectedViewer()
const isUrlString = (v: unknown) => typeof v === 'string' && VALID_HTTP_URL.test(v)
const isCopyable = (kvp: Record<string, unknown>) => {
return kvp.value !== null && kvp.value !== undefined && typeof kvp.value !== 'object'
}
const isFilterable = (kvp: Record<string, unknown>) => {
const key = kvp.key as string
return isPropertyFilterable(key, availableFilters.value)
}
const handleFilterByProperty = (kvp: Record<string, unknown>) => {
const key = kvp.key as string
const filter = availableFilters.value?.find((f: PropertyInfo) => f.key === key)
if (filter) {
setPropertyFilter(filter)
applyPropertyFilter()
}
}
const handleCopy = async (kvp: Record<string, unknown>) => {
const { copy } = useClipboard()
if (isCopyable(kvp)) {
const keyName = kvp.key as string
await copy(kvp.value as string, {
successMessage: `${keyName} copied to clipboard`,
failureMessage: `Failed to copy ${keyName} to clipboard`
})
}
}
const actionsItems = computed<LayoutMenuItem[][]>(() => {
return [
[
{
title: 'Copy value',
id: 'copy-value',
disabled: !isCopyable(props.kvp),
disabledTooltip: isCopyable(props.kvp)
? undefined
: 'Cannot copy objects, arrays, or null values'
}
],
[
{
title: 'Filter by property',
id: 'filter-by-property',
disabled: !isFilterable(props.kvp),
disabledTooltip: isFilterable(props.kvp)
? undefined
: 'This property is not available for filtering'
}
]
]
})
const onActionChosen = (params: { item: LayoutMenuItem }) => {
const { item } = params
// Don't execute if item is disabled
if (item.disabled) return
switch (item.id) {
case 'copy-value':
handleCopy(props.kvp)
break
case 'filter-by-property':
handleFilterByProperty(props.kvp)
break
}
}
</script>
@@ -30,69 +30,14 @@
</div>
<div v-if="unfold" class="space-y-1 pl-0 py-1 pr-2">
<!-- key value pair display -->
<div
<ViewerSelectionKeyValuePair
v-for="(kvp, index) in [
...categorisedValuePairs.primitives,
...categorisedValuePairs.nulls
]"
:key="index"
class="flex w-full"
>
<div
:class="`grid grid-cols-3 w-full pl-2 py-0.5 ${
kvp.value === null || kvp.value === undefined ? 'text-foreground-2' : ''
}`"
>
<div
class="col-span-1 truncate text-body-3xs mr-2 font-medium text-foreground-2"
:title="(kvp.key as string)"
>
{{ kvp.key }}
</div>
<div
class="group col-span-2 pl-1 truncate text-body-3xs flex gap-1 items-center text-foreground"
:title="(kvp.value as string)"
>
<div class="flex gap-1 items-center w-full">
<!-- NOTE: can't do kvp.value || 'null' because 0 || 'null' = 'null' -->
<template v-if="isUrlString(kvp.value)">
<a
:href="kvp.value as string"
target="_blank"
rel="noopener"
class="truncate border-b border-outline-3 hover:border-outline-5"
:class="
kvp.value === null ? '' : 'group-hover:max-w-[calc(100%-1rem)]'
"
>
{{ kvp.value }}
</a>
</template>
<template v-else>
<span
class="truncate"
:class="
kvp.value === null ? '' : 'group-hover:max-w-[calc(100%-1rem)]'
"
>
{{ kvp.value === null ? 'null' : kvp.value }}
</span>
</template>
<span v-if="kvp.units" class="truncate opacity-70">
{{ kvp.units }}
</span>
<button
v-if="isCopyable(kvp)"
:class="isCopyable(kvp) ? 'cursor-pointer' : 'cursor-default'"
class="opacity-0 group-hover:opacity-100 w-4"
@click="handleCopy(kvp)"
>
<ClipboardDocumentIcon class="h-3 w-3" />
</button>
</div>
</div>
</div>
</div>
:kvp="kvp"
/>
<div
v-for="(kvp, index) in categorisedValuePairs.objects"
:key="index"
@@ -148,12 +93,10 @@
</div>
</template>
<script setup lang="ts">
import { ClipboardDocumentIcon } from '@heroicons/vue/24/outline'
import type { SpeckleObject } from '~~/lib/viewer/helpers/sceneExplorer'
import { getHeaderAndSubheaderForSpeckleObject } from '~~/lib/object-sidebar/helpers'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useHighlightedObjectsUtilities } from '~/lib/viewer/composables/ui'
import { VALID_HTTP_URL } from '~~/lib/common/helpers/validation'
const {
ui: {
@@ -174,7 +117,6 @@ const props = withDefaults(
)
const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities()
const unfold = ref(props.unfold)
const autoUnfoldKeys = ['properties', 'Instance Parameters']
@@ -241,23 +183,6 @@ const headerAndSubheader = computed(() => {
return getHeaderAndSubheaderForSpeckleObject(props.object)
})
const isUrlString = (v: unknown) => typeof v === 'string' && VALID_HTTP_URL.test(v)
const isCopyable = (kvp: Record<string, unknown>) => {
return kvp.value !== null && kvp.value !== undefined && typeof kvp.value !== 'object'
}
const handleCopy = async (kvp: Record<string, unknown>) => {
const { copy } = useClipboard()
if (isCopyable(kvp)) {
const keyName = kvp.key as string
await copy(kvp.value as string, {
successMessage: `${keyName} copied to clipboard`,
failureMessage: `Failed to copy ${keyName} to clipboard`
})
}
}
const ignoredProps = [
'__closure',
'displayMesh',
@@ -259,6 +259,93 @@ export function useFilterUtilities(
return !!filters.propertyFilter.filter.value
})
// Regex patterns for identifying Revit properties
const revitPropertyRegex = /^parameters\./
// Note: we've split this regex check in two to not clash with navis properties. This makes generally makes dim very sad, as we're layering hacks.
// Navis object properties come under `properties`, same as revit ones - as such we can't assume they're the same. Here we're targeting revit's
// specific two subcategories of `properties`.
const revitPropertyRegexDui3000InstanceProps = /^properties\.Instance/ // note this is partially valid for civil3d, or dim should test against it
const revitPropertyRegexDui3000TypeProps = /^properties\.Type/ // note this is partially valid for civil3d, or dim should test against it
/**
* Determines if a property key represents a Revit property
*/
const isRevitProperty = (key: string): boolean => {
return (
revitPropertyRegex.test(key) ||
revitPropertyRegexDui3000InstanceProps.test(key) ||
revitPropertyRegexDui3000TypeProps.test(key)
)
}
/**
* Determines if a property should be excluded from filtering based on its key
*/
const shouldExcludeFromFiltering = (key: string): boolean => {
if (
key.endsWith('.units') ||
key.endsWith('.speckle_type') ||
key.includes('.parameters.') ||
// key.includes('level.') ||
key.includes('renderMaterial') ||
key.includes('.domain') ||
key.includes('plane.') ||
key.includes('baseLine') ||
key.includes('referenceLine') ||
key.includes('end.') ||
key.includes('start.') ||
key.includes('endPoint.') ||
key.includes('midPoint.') ||
key.includes('startPoint.') ||
key.includes('.materialName') ||
key.includes('.materialClass') ||
key.includes('.materialCategory') ||
key.includes('displayStyle') ||
key.includes('displayValue') ||
key.includes('displayMesh')
) {
return true
}
// handle revit params: the actual one single value we're interested is in parameters.HOST_BLA BLA_.value, the rest are not needed
if (isRevitProperty(key)) {
if (key.endsWith('.value')) return false
else return true
}
return false
}
/**
* 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) => {
return !shouldExcludeFromFiltering(f.key)
})
}
/**
* Determines if a property key should be filterable
* (exists in available filters and is not excluded)
*/
const isPropertyFilterable = (
key: string,
availableFilters: PropertyInfo[] | null | undefined
): boolean => {
const availableFilterKeys = availableFilters?.map((f: PropertyInfo) => f.key) || []
// First check if it's in available filters
if (!availableFilterKeys.includes(key)) {
return false
}
// Then check if it should be excluded
return !shouldExcludeFromFiltering(key)
}
return {
isolateObjects,
unIsolateObjects,
@@ -272,7 +359,11 @@ export function useFilterUtilities(
resetFilters,
resetExplode,
waitForAvailableFilter,
hasActiveFilters
hasActiveFilters,
isRevitProperty,
shouldExcludeFromFiltering,
getRelevantFilters,
isPropertyFilterable
}
}