feat(fe): "filter by property" from selection info panel
feat(fe): "filter by property" from selection info panel
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user