From 0b84f7d5ab39d76ef398f3ea3f38e40ddf79ef33 Mon Sep 17 00:00:00 2001 From: Bircck <55695195+Bircck@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:55:26 +0200 Subject: [PATCH] Fix scoped global column search results --- .../components/datamodelview/Attributes.tsx | 5 +- Website/components/datamodelview/List.tsx | 2 +- .../components/datamodelview/searchWorker.ts | 47 ++++--------------- Website/lib/columnSearch.ts | 47 +++++++++++++++++++ Website/scripts/test-column-search.cjs | 44 +++++++++++++++++ 5 files changed, 104 insertions(+), 41 deletions(-) create mode 100644 Website/lib/columnSearch.ts create mode 100644 Website/scripts/test-column-search.cjs diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index 3aa8a86..9962955 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -1,5 +1,6 @@ 'use client' +import { columnMatchesSearch } from "@/lib/columnSearch" import { EntityType, AttributeType } from "@/lib/Types" import { useState, useEffect } from "react" import { AttributeDetails } from "./entity/AttributeDetails" @@ -105,7 +106,7 @@ export const Attributes = ({ entity, search = "", onVisibleCountChange }: IAttri // Also filter by parent search prop if provided if (search && search.length >= 3) { const query = search.toLowerCase() - filteredAttributes = filteredAttributes.filter(attr => attributeMatchesSearch(attr, query)) + filteredAttributes = filteredAttributes.filter(attr => columnMatchesSearch(attr, query, searchScope)) } if (hideStandardFields) filteredAttributes = filteredAttributes.filter(attr => (attr.IsCustomAttribute || attr.IsStandardFieldModified) && !attr.SchemaName.endsWith("Base")); @@ -457,4 +458,4 @@ function getAttributeComponent(entity: EntityType, attribute: AttributeType, hig default: return null; } -} \ No newline at end of file +} diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 37a77d5..a0686df 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -56,7 +56,7 @@ export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { // Only recalculate items when filtered or search changes const flatItems = useMemo(() => { - if (filtered && filtered.length > 0) return filtered.filter(item => item.type !== 'attribute' && item.type !== 'relationship'); + if (search.length >= 3) return filtered.filter(item => item.type !== 'attribute' && item.type !== 'relationship'); const lowerSearch = search.trim().toLowerCase(); const items: Array< diff --git a/Website/components/datamodelview/searchWorker.ts b/Website/components/datamodelview/searchWorker.ts index 6266b3f..d0d9f39 100644 --- a/Website/components/datamodelview/searchWorker.ts +++ b/Website/components/datamodelview/searchWorker.ts @@ -1,3 +1,4 @@ +import { columnMatchesSearch } from "@/lib/columnSearch"; import { GroupType, EntityType, AttributeType, RelationshipType } from "@/lib/Types"; // Worker message types @@ -134,7 +135,7 @@ self.onmessage = async function (e: MessageEvent) { // Apply hideStandardFields filter if (entityFilter.hideStandardFields) { const isStandardFieldHidden = !attr.IsCustomAttribute && !attr.IsStandardFieldModified; - if (isStandardFieldHidden) return false; + if (isStandardFieldHidden || attr.SchemaName.endsWith("Base")) return false; } // Apply type filter @@ -151,43 +152,7 @@ self.onmessage = async function (e: MessageEvent) { } } - // Apply search matching based on scope - let matches = false; - - // Column names (SchemaName and DisplayName) - if (searchScope.columnNames) { - if (attr.SchemaName.toLowerCase().includes(search)) matches = true; - if (attr.DisplayName && attr.DisplayName.toLowerCase().includes(search)) matches = true; - } - - // Column descriptions - if (searchScope.columnDescriptions) { - if (attr.Description && attr.Description.toLowerCase().includes(search)) matches = true; - } - - // Column data types - if (searchScope.columnDataTypes) { - if (attr.AttributeType.toLowerCase().includes(search)) matches = true; - - // Also search in specific type properties - if (attr.AttributeType === 'ChoiceAttribute' || attr.AttributeType === 'StatusAttribute') { - if (attr.Options.some(option => option.Name.toLowerCase().includes(search))) matches = true; - } else if (attr.AttributeType === 'DateTimeAttribute') { - if (attr.Format.toLowerCase().includes(search) || attr.Behavior.toLowerCase().includes(search)) matches = true; - } else if (attr.AttributeType === 'IntegerAttribute') { - if (attr.Format.toLowerCase().includes(search)) matches = true; - } else if (attr.AttributeType === 'StringAttribute') { - if (attr.Format.toLowerCase().includes(search)) matches = true; - } else if (attr.AttributeType === 'DecimalAttribute') { - if (attr.Type.toLowerCase().includes(search)) matches = true; - } else if (attr.AttributeType === 'LookupAttribute') { - if (attr.Targets.some(target => target.Name.toLowerCase().includes(search))) matches = true; - } else if (attr.AttributeType === 'BooleanAttribute') { - if (attr.TrueLabel.toLowerCase().includes(search) || attr.FalseLabel.toLowerCase().includes(search)) matches = true; - } - } - - return matches; + return columnMatchesSearch(attr, search, searchScope); }); // Check for table description matches @@ -260,6 +225,12 @@ self.onmessage = async function (e: MessageEvent) { } } + if (allItems.length === 0) { + const response: WorkerResponse = { type: 'results', data: [], complete: true, requestId }; + self.postMessage(response); + return; + } + // Send results in chunks to prevent UI blocking for (let i = 0; i < allItems.length; i += CHUNK_SIZE) { const chunk = allItems.slice(i, i + CHUNK_SIZE); diff --git a/Website/lib/columnSearch.ts b/Website/lib/columnSearch.ts new file mode 100644 index 0000000..ec43808 --- /dev/null +++ b/Website/lib/columnSearch.ts @@ -0,0 +1,47 @@ +import type { AttributeType } from "./Types"; + +export interface ColumnSearchScope { + columnNames: boolean; + columnDescriptions: boolean; + columnDataTypes: boolean; +} + +// Keep worker results and the rendered column rows on the same matching rules. +export function columnMatchesSearch(attribute: AttributeType, query: string, scope: ColumnSearchScope): boolean { + const term = query.trim().toLowerCase(); + if (!term) return true; + const contains = (value: string | null | undefined) => !!value?.toLowerCase().includes(term); + if (scope.columnNames && (contains(attribute.SchemaName) || contains(attribute.DisplayName))) return true; + if (scope.columnDescriptions && contains(attribute.Description)) return true; + if (!scope.columnDataTypes) return false; + + const values: string[] = [attribute.AttributeType]; + switch (attribute.AttributeType) { + case "ChoiceAttribute": + values.push("Choice", `${attribute.Type}-select`, ...attribute.Options.map(option => option.Name)); + break; + case "StatusAttribute": + values.push("Choice", "Single-select", ...attribute.Options.map(option => option.Name)); + break; + case "DateTimeAttribute": + values.push(attribute.Format, attribute.Behavior, `${attribute.Format} - ${attribute.Behavior}`); + break; + case "StringAttribute": + values.push("Text", attribute.Format); + break; + case "IntegerAttribute": + values.push(attribute.Format); + break; + case "GenericAttribute": + case "DecimalAttribute": + values.push(attribute.Type); + break; + case "LookupAttribute": + values.push(...attribute.Targets.map(target => target.Name)); + break; + case "BooleanAttribute": + values.push(attribute.TrueLabel, attribute.FalseLabel); + break; + } + return values.some(contains); +} diff --git a/Website/scripts/test-column-search.cjs b/Website/scripts/test-column-search.cjs new file mode 100644 index 0000000..400da4b --- /dev/null +++ b/Website/scripts/test-column-search.cjs @@ -0,0 +1,44 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +const root = path.resolve(__dirname, '..'); +const compile = file => ts.transpileModule(fs.readFileSync(path.join(root, file), 'utf8'), {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020}}).outputText; +const matcherExports = {}; +vm.runInNewContext(compile('lib/columnSearch.ts'), {exports: matcherExports}); +const {columnMatchesSearch} = matcherExports; +const names = {columnNames:true,columnDescriptions:false,columnDataTypes:false}; +const descriptions = {...names,columnNames:false,columnDescriptions:true}; +const types = {...names,columnNames:false,columnDataTypes:true}; +const base = {SchemaName:'dmvp_Forum',DisplayName:'Forum',Description:'Discussion forum',IsCustomAttribute:true,IsStandardFieldModified:false}; +const text = {...base,AttributeType:'StringAttribute',Format:'Rich Text',MaxLength:2000}; +const date = {...base,SchemaName:'dmvp_Start',DisplayName:'Start',AttributeType:'DateTimeAttribute',Format:'Date',Behavior:'DateOnly'}; +const choice = {...base,SchemaName:'dmvp_Category',DisplayName:'Category',Description:null,AttributeType:'ChoiceAttribute',Type:'Multi',Options:[{Name:'Selected',Value:1}]}; +assert.equal(columnMatchesSearch(text,'forum',types),false,'Data types must not search names/descriptions'); +assert.equal(columnMatchesSearch(text,'discussion',names),false,'Names scope must not search descriptions'); +assert.equal(columnMatchesSearch(text,'discussion',descriptions),true); +assert.equal(columnMatchesSearch(text,'rich',types),true); +assert.equal(columnMatchesSearch(text,'text',types),true); +assert.equal(columnMatchesSearch(date,'Date - DateOnly',types),true); +assert.equal(columnMatchesSearch({...date,Format:'Date & time'},'date & time',types),true); +assert.equal(columnMatchesSearch(choice,'choice',types),true); +assert.equal(columnMatchesSearch(choice,'multi-select',types),true); +assert.equal(columnMatchesSearch({...choice,Type:'Single'},'single-select',types),true); +assert.equal(columnMatchesSearch(choice,'selected',names),false); +assert.equal(columnMatchesSearch(choice,'selected',types),true); +const messages=[]; +const worker={postMessage:message=>messages.push(message)}; +vm.runInNewContext(compile('components/datamodelview/searchWorker.ts'), {exports:{},self:worker,setTimeout,require:name=>{assert.equal(name,'@/lib/columnSearch');return matcherExports;}}); +(async()=>{ + await worker.onmessage({data:{type:'init',groups:[{Name:'Playground',Entities:[{SchemaName:'dmvp_Project',DisplayName:'Project',Description:null,Attributes:[text,date,choice],Relationships:[],SecurityRoles:[]}]}]}}); + for(const [query,scope,expected] of [['forum',types,[]],['discussion',names,[]],['rich',types,['dmvp_Forum']],['Date - DateOnly',types,['dmvp_Start']],['multi-select',types,['dmvp_Category']],['nomatches',types,[]]]) { + messages.length=0; + await worker.onmessage({data:{type:'search',data:query,searchScope:scope,requestId:42}}); + assert.equal(messages.at(-1).complete,true,`${query}: completion required even without results`); + assert.equal(messages.at(-1).requestId,42); + const rows=messages.flatMap(message=>message.data||[]).filter(item=>item.type==='attribute').map(item=>item.attribute.SchemaName); + assert.deepEqual(Array.from(rows),expected,query); + } + console.log('PASS: 12 scoped matching assertions and 6 worker result/completion scenarios'); +})().catch(error=>{console.error(error);process.exitCode=1;});