diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index cb40ef3..d6b2268 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" @@ -14,7 +15,7 @@ import LookupAttribute from "./attributes/LookupAttribute" import StatusAttribute from "./attributes/StatusAttribute" import StringAttribute from "./attributes/StringAttribute" import React from "react" -import { highlightMatch } from "../datamodelview/List"; +import { highlightMatch, highlightTypeMatch } from "@/lib/searchHighlight"; import { Box, Button, FormControl, InputAdornment, InputLabel, MenuItem, Select, Table, TableBody, TableCell, TableHead, TableRow, TextField, Tooltip, Typography, useTheme } from "@mui/material" import { ClearRounded, SearchRounded, Visibility, VisibilityOff, ArrowUpwardRounded, ArrowDownwardRounded } from "@mui/icons-material" import { useEntityFiltersDispatch } from "@/contexts/EntityFiltersContext" @@ -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")); @@ -151,6 +152,8 @@ export const Attributes = ({ entity, search = "", onVisibleCountChange }: IAttri const highlightTerm = searchQuery || (search && (searchScope.columnNames || searchScope.columnDescriptions || searchScope.columnDataTypes) ? search : ""); + const typeHighlightTerm = searchQuery || (searchScope.columnDataTypes ? search : ""); + // Notify parent of visible count changes useEffect(() => { onVisibleCountChange?.(sortedAttributes.length); @@ -415,7 +418,7 @@ export const Attributes = ({ entity, search = "", onVisibleCountChange }: IAttri {highlightMatch(attribute.SchemaName, highlightTerm)} - {getAttributeComponent(entity, attribute, highlightMatch, highlightTerm)} + {getAttributeComponent(entity, attribute, highlightTypeMatch, typeHighlightTerm)} {highlightMatch(attribute.Description ?? "", highlightTerm)} @@ -441,11 +444,11 @@ function getAttributeComponent(entity: EntityType, attribute: AttributeType, hig case 'GenericAttribute': return ; case 'IntegerAttribute': - return ; + return ; case 'LookupAttribute': - return ; + return ; case 'DecimalAttribute': - return ; + return ; case 'StatusAttribute': return ; case 'StringAttribute': @@ -453,8 +456,8 @@ function getAttributeComponent(entity: EntityType, attribute: AttributeType, hig case 'BooleanAttribute': return ; case 'FileAttribute': - return ; + return ; default: return null; } -} \ No newline at end of file +} diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 9c3553a..8231e01 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -16,13 +16,7 @@ interface IListProps { entityActiveTabs: Map; } -// Helper to highlight search matches -export function highlightMatch(text: string, search: string) { - if (!search || search.length < 3) return text; - const idx = text.toLowerCase().indexOf(search.toLowerCase()); - if (idx === -1) return text; - return <>{text.slice(0, idx)}{text.slice(idx, idx + search.length)}{text.slice(idx + search.length)}; -} +export { highlightMatch } from "@/lib/searchHighlight"; export const List = ({ setCurrentIndex, entityActiveTabs }: IListProps) => { const dispatch = useDatamodelViewDispatch(); @@ -56,7 +50,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/attributes/BooleanAttribute.tsx b/Website/components/datamodelview/attributes/BooleanAttribute.tsx index 82e44dd..3386274 100644 --- a/Website/components/datamodelview/attributes/BooleanAttribute.tsx +++ b/Website/components/datamodelview/attributes/BooleanAttribute.tsx @@ -4,18 +4,18 @@ import { Box, Typography, Chip } from "@mui/material" import { CheckRounded, RadioButtonCheckedRounded, RadioButtonUncheckedRounded } from "@mui/icons-material"; import React from "react"; -export default function BooleanAttribute({ attribute, highlightMatch, highlightTerm }: { attribute: BooleanAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { +export default function BooleanAttribute({ attribute, highlightMatch = text => text, highlightTerm = "" }: { attribute: BooleanAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { const isMobile = useIsMobile(); return ( - {highlightMatch && highlightTerm ? highlightMatch("Boolean", highlightTerm) : "Boolean"} + {highlightTerm ? highlightMatch("Boolean", highlightTerm) : "Boolean"} {attribute.DefaultValue !== null && !isMobile && ( } - label={`Default: ${attribute.DefaultValue === true ? attribute.TrueLabel : attribute.FalseLabel}`} + label={highlightMatch(`Default: ${attribute.DefaultValue === true ? attribute.TrueLabel : attribute.FalseLabel}`, highlightTerm)} size="small" color="success" variant="outlined" @@ -30,11 +30,11 @@ export default function BooleanAttribute({ attribute, highlightMatch, highlightT ) : ( )} - {highlightMatch && highlightTerm ? highlightMatch(attribute.TrueLabel, highlightTerm) : attribute.TrueLabel} + {highlightTerm ? highlightMatch(attribute.TrueLabel, highlightTerm) : attribute.TrueLabel} )} - {highlightMatch && highlightTerm ? highlightMatch(attribute.FalseLabel, highlightTerm) : attribute.FalseLabel} + {highlightTerm ? highlightMatch(attribute.FalseLabel, highlightTerm) : attribute.FalseLabel} - {attribute.Type}-select + {highlightMatch(`${attribute.Type}-select`, highlightTerm)} {attribute.DefaultValue !== null && attribute.DefaultValue !== -1 && !isMobile && ( } - label={`Default: ${attribute.Options.find(o => o.Value === attribute.DefaultValue)?.Name}`} + label={highlightMatch(`Default: ${attribute.Options.find(o => o.Value === attribute.DefaultValue)?.Name}`, highlightTerm)} size="small" color="success" variant="outlined" @@ -61,7 +61,7 @@ export default function ChoiceAttribute({ attribute, highlightMatch, highlightTe - {option.Description} + {highlightMatch(option.Description, highlightTerm)} )} diff --git a/Website/components/datamodelview/attributes/DecimalAttribute.tsx b/Website/components/datamodelview/attributes/DecimalAttribute.tsx index 7397af7..1b5d0aa 100644 --- a/Website/components/datamodelview/attributes/DecimalAttribute.tsx +++ b/Website/components/datamodelview/attributes/DecimalAttribute.tsx @@ -1,37 +1,16 @@ import { DecimalAttributeType } from "@/lib/Types" -import { formatNumberSeperator } from "@/lib/utils" +import { decimalRange, precisionText } from "@/lib/columnTypeText" import { Typography } from "@mui/material" -export default function MoneyAttribute({ attribute }: { attribute: DecimalAttributeType }) { - const formatNumber = - attribute.Type === "Money" - ? FormatMoney - : FormatDecimal - +export default function MoneyAttribute({ attribute, highlightMatch = text => text, highlightTerm = "" }: { attribute: DecimalAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { return ( <> - {attribute.Type} + {highlightMatch(attribute.Type, highlightTerm)} {" "} - ({formatNumber(attribute.MinValue)} to {formatNumber(attribute.MaxValue)}) + {highlightMatch(decimalRange(attribute), highlightTerm)} - Precision: {attribute.Precision} + {highlightMatch(precisionText(attribute.Precision), highlightTerm)} ) } - -function FormatMoney(number: number) { - if (number === 922337203685477) - return "Max" - if (number === -922337203685477) - return "Min" - return formatNumberSeperator(number) -} - -function FormatDecimal(number: number) { - if (number === 100000000000) - return "Max" - if (number === -100000000000) - return "Min" - return formatNumberSeperator(number) -} \ No newline at end of file diff --git a/Website/components/datamodelview/attributes/FileAttribute.tsx b/Website/components/datamodelview/attributes/FileAttribute.tsx index c73f975..d6e5ac1 100644 --- a/Website/components/datamodelview/attributes/FileAttribute.tsx +++ b/Website/components/datamodelview/attributes/FileAttribute.tsx @@ -1,13 +1,13 @@ import { FileAttributeType } from "@/lib/Types"; -import { formatNumberSeperator } from "@/lib/utils"; +import { fileSize } from "@/lib/columnTypeText"; import { Typography } from "@mui/material"; -export default function FileAttribute({ attribute } : { attribute: FileAttributeType }) { +export default function FileAttribute({ attribute, highlightMatch = text => text, highlightTerm = "" }: { attribute: FileAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { return ( <> - File + {highlightMatch("File", highlightTerm)} {" "} - (Max {formatNumberSeperator(attribute.MaxSize)}KB) + {highlightMatch(fileSize(attribute.MaxSize), highlightTerm)} ) } \ No newline at end of file diff --git a/Website/components/datamodelview/attributes/IntegerAttribute.tsx b/Website/components/datamodelview/attributes/IntegerAttribute.tsx index 62f9d8b..5ad218b 100644 --- a/Website/components/datamodelview/attributes/IntegerAttribute.tsx +++ b/Website/components/datamodelview/attributes/IntegerAttribute.tsx @@ -1,21 +1,13 @@ import { IntegerAttributeType } from "@/lib/Types" -import { formatNumberSeperator } from "@/lib/utils" +import { integerRange } from "@/lib/columnTypeText" import { Typography } from "@mui/material" -export default function IntegerAttribute({ attribute } : { attribute: IntegerAttributeType }) { +export default function IntegerAttribute({ attribute, highlightMatch = text => text, highlightTerm = "" }: { attribute: IntegerAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { return ( <> - {attribute.Format} + {highlightMatch(attribute.Format, highlightTerm)} {" "} - ({FormatNumber(attribute.MinValue)} to {FormatNumber(attribute.MaxValue)}) + {highlightMatch(integerRange(attribute.MinValue, attribute.MaxValue), highlightTerm)} ) } - -function FormatNumber(number: number) { - if (number === 2147483647) - return "Max" - if (number === -2147483648) - return "Min" - return formatNumberSeperator(number) -} \ No newline at end of file diff --git a/Website/components/datamodelview/attributes/LookupAttribute.tsx b/Website/components/datamodelview/attributes/LookupAttribute.tsx index 0534271..cbe1389 100644 --- a/Website/components/datamodelview/attributes/LookupAttribute.tsx +++ b/Website/components/datamodelview/attributes/LookupAttribute.tsx @@ -3,14 +3,14 @@ import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/Datamodel import { Box, Typography, Button, Chip } from "@mui/material" import { ContentPasteOffRounded, ContentPasteSearchRounded } from "@mui/icons-material"; -export default function LookupAttribute({ attribute }: { attribute: LookupAttributeType }) { +export default function LookupAttribute({ attribute, highlightMatch = text => text, highlightTerm = "" }: { attribute: LookupAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { const { scrollToSection } = useDatamodelView(); const dispatch = useDatamodelViewDispatch(); return ( - Lookup + {highlightMatch("Lookup", highlightTerm)} {attribute.Targets .map(target => target.IsInSolution ? @@ -35,12 +35,12 @@ export default function LookupAttribute({ attribute }: { attribute: LookupAttrib } }} > - {target.Name} + {highlightMatch(target.Name, highlightTerm)} : } - label={target.Name} + label={highlightMatch(target.Name, highlightTerm)} size="small" disabled sx={{ diff --git a/Website/components/datamodelview/attributes/StatusAttribute.tsx b/Website/components/datamodelview/attributes/StatusAttribute.tsx index 45aadca..54caf98 100644 --- a/Website/components/datamodelview/attributes/StatusAttribute.tsx +++ b/Website/components/datamodelview/attributes/StatusAttribute.tsx @@ -4,7 +4,7 @@ import { CircleRounded } from "@mui/icons-material"; import { Box, Typography, Chip } from "@mui/material"; import React from "react"; -export default function StatusAttribute({ attribute, highlightMatch, highlightTerm }: { attribute: StatusAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { +export default function StatusAttribute({ attribute, highlightMatch = text => text, highlightTerm = "" }: { attribute: StatusAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { const groupedOptions = attribute.Options.reduce((acc, option) => { if (!acc[option.State]) { acc[option.State] = []; @@ -16,12 +16,12 @@ export default function StatusAttribute({ attribute, highlightMatch, highlightTe return ( - {highlightMatch && highlightTerm ? highlightMatch("State/Status", highlightTerm) : "State/Status"} + {highlightTerm ? highlightMatch("State/Status", highlightTerm) : "State/Status"} {/* No DefaultValue for StatusAttributeType, so no default badge */} {Object.entries(groupedOptions).map(([state, options]) => ( - {highlightMatch && highlightTerm ? highlightMatch(state, highlightTerm) : state} + {highlightTerm ? highlightMatch(state, highlightTerm) : state} {options.map(option => ( @@ -30,12 +30,12 @@ export default function StatusAttribute({ attribute, highlightMatch, highlightTe {/* No DefaultValue, so always show Circle icon */} - {highlightMatch && highlightTerm ? highlightMatch(option.Name, highlightTerm) : option.Name} + {highlightTerm ? highlightMatch(option.Name, highlightTerm) : option.Name} string | React.JSX.Element, highlightTerm?: string }) { +export default function StringAttribute({ attribute, highlightMatch = text => text, highlightTerm = "" } : { attribute: StringAttributeType, highlightMatch?: (text: string, term: string) => string | React.JSX.Element, highlightTerm?: string }) { return ( <> - {highlightMatch && highlightTerm ? highlightMatch("Text", highlightTerm) : "Text"} + {highlightTerm ? highlightMatch("Text", highlightTerm) : "Text"} {" "} - ({formatNumberSeperator(attribute.MaxLength)}) + {highlightMatch(textLength(attribute.MaxLength), highlightTerm)} {attribute.Format !== "Text" && ( <> {" - "} - {highlightMatch && highlightTerm ? highlightMatch(attribute.Format, highlightTerm) : attribute.Format} + {highlightTerm ? highlightMatch(attribute.Format, highlightTerm) : attribute.Format} )} diff --git a/Website/components/datamodelview/entity/EntityHeader.tsx b/Website/components/datamodelview/entity/EntityHeader.tsx index 907176a..8942063 100644 --- a/Website/components/datamodelview/entity/EntityHeader.tsx +++ b/Website/components/datamodelview/entity/EntityHeader.tsx @@ -1,5 +1,7 @@ 'use client' +import { useDatamodelData } from "@/contexts/DatamodelDataContext"; +import { highlightMatch } from "@/lib/searchHighlight"; import { EntityType } from "@/lib/Types"; import { EntityDetails } from "./EntityDetails"; import { Box, Typography, Paper, useTheme, Tooltip } from '@mui/material'; @@ -10,6 +12,7 @@ import { useSnackbar } from "@/contexts/SnackbarContext"; export function EntityHeader({ entity }: { entity: EntityType }) { const theme = useTheme(); + const { search, searchScope } = useDatamodelData(); const { showSnackbar } = useSnackbar(); const handleCopyLink = useCallback(async () => { @@ -127,7 +130,7 @@ export function EntityHeader({ entity }: { entity: EntityType }) { lineHeight: 1.6 }} > - {entity.Description} + {highlightMatch(entity.Description, searchScope.tableDescriptions ? search : "")} )} 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..7957598 --- /dev/null +++ b/Website/lib/columnSearch.ts @@ -0,0 +1,20 @@ +import type { AttributeType } from "./Types"; +import { columnTypeSearchValues } from "./columnTypeText"; + +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; + + return columnTypeSearchValues(attribute).some(contains); +} diff --git a/Website/lib/columnTypeText.ts b/Website/lib/columnTypeText.ts new file mode 100644 index 0000000..c8db568 --- /dev/null +++ b/Website/lib/columnTypeText.ts @@ -0,0 +1,79 @@ +import type { AttributeType, DecimalAttributeType } from "./Types"; +import { formatNumberSeperator } from "./utils"; + +export function integerBound(value: number): string { + if (value === 2147483647) return "Max"; + if (value === -2147483648) return "Min"; + return formatNumberSeperator(value); +} + +export function decimalBound(value: number, type: DecimalAttributeType["Type"]): string { + const limit = type === "Money" ? 922337203685477 : 100000000000; + if (value === limit) return "Max"; + if (value === -limit) return "Min"; + return formatNumberSeperator(value); +} + +export const textLength = (value: number) => `(${formatNumberSeperator(value)})`; +export const fileSize = (value: number) => `(Max ${formatNumberSeperator(value)}KB)`; +export const integerRange = (min: number, max: number) => `(${integerBound(min)} to ${integerBound(max)})`; +export const decimalRange = (attribute: DecimalAttributeType) => `(${decimalBound(attribute.MinValue, attribute.Type)} to ${decimalBound(attribute.MaxValue, attribute.Type)})`; +export const precisionText = (value: number) => `Precision: ${value}`; + +// Include rendered text and raw numbers, so both 2000 and the displayed 2.000 work. +export function columnTypeSearchValues(attribute: AttributeType): string[] { + const values: string[] = [attribute.AttributeType]; + const number = (value: number) => values.push(String(value), formatNumberSeperator(value)); + switch (attribute.AttributeType) { + case "ChoiceAttribute": { + values.push("Choice", `${attribute.Type}-select`); + for (const option of attribute.Options) { + values.push(option.Name, option.Description ?? ""); + number(option.Value); + } + const defaultOption = attribute.Options.find(option => option.Value === attribute.DefaultValue); + if (attribute.DefaultValue !== null && attribute.DefaultValue !== -1 && defaultOption) values.push(`Default: ${defaultOption.Name}`); + break; + } + case "StatusAttribute": + values.push("Choice", "Single-select", "State/Status"); + for (const option of attribute.Options) { + values.push(option.Name, option.State); + number(option.Value); + } + break; + case "DateTimeAttribute": + values.push(attribute.Format, attribute.Behavior, `${attribute.Format} - ${attribute.Behavior}`); + break; + case "StringAttribute": + values.push("Text", attribute.Format, `Text ${textLength(attribute.MaxLength)}${attribute.Format === "Text" ? "" : ` - ${attribute.Format}`}`); + number(attribute.MaxLength); + break; + case "IntegerAttribute": + values.push(`${attribute.Format} ${integerRange(attribute.MinValue, attribute.MaxValue)}`); + number(attribute.MinValue); + number(attribute.MaxValue); + break; + case "DecimalAttribute": + values.push(`${attribute.Type} ${decimalRange(attribute)}`, precisionText(attribute.Precision)); + number(attribute.MinValue); + number(attribute.MaxValue); + number(attribute.Precision); + break; + case "GenericAttribute": + values.push(attribute.Type); + break; + case "LookupAttribute": + values.push("Lookup", ...attribute.Targets.map(target => target.Name)); + break; + case "BooleanAttribute": + values.push("Boolean", "True", "False", attribute.TrueLabel, attribute.FalseLabel); + if (attribute.DefaultValue !== null) values.push(`Default: ${attribute.DefaultValue ? attribute.TrueLabel : attribute.FalseLabel}`); + break; + case "FileAttribute": + values.push(`File ${fileSize(attribute.MaxSize)}`); + number(attribute.MaxSize); + break; + } + return values; +} diff --git a/Website/lib/searchHighlight.tsx b/Website/lib/searchHighlight.tsx new file mode 100644 index 0000000..f4530b6 --- /dev/null +++ b/Website/lib/searchHighlight.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +function marked(text: string, start: number, end: number) { + return <>{text.slice(0, start)}{text.slice(start, end)}{text.slice(end)}; +} + +export function highlightMatch(text: string, search: string) { + const term = search.trim(); + if (term.length < 3) return text; + const index = text.toLowerCase().indexOf(term.toLowerCase()); + return index < 0 ? text : marked(text, index, index + term.length); +} + +export function highlightTypeMatch(text: string, search: string) { + const highlighted = highlightMatch(text, search); + const term = search.trim(); + if (highlighted !== text || term.length < 3 || !/^-?\d+(?:\.\d+)?$/.test(term)) return highlighted; + + // Map raw numeric matches back onto the displayed thousands/decimal separators. + for (const match of text.matchAll(/-?\d+(?:\.\d{3})*(?:,\d+)?/g)) { + const positions: number[] = []; + let raw = ""; + for (let i = 0; i < match[0].length; i++) { + const char = match[0][i]; + if (char === ".") continue; + positions.push(match.index + i); + raw += char === "," ? "." : char; + } + const index = raw.indexOf(term); + if (index >= 0) return marked(text, positions[index], positions[index + term.length - 1] + 1); + } + return text; +} diff --git a/Website/scripts/test-column-search.cjs b/Website/scripts/test-column-search.cjs new file mode 100644 index 0000000..e1d680b --- /dev/null +++ b/Website/scripts/test-column-search.cjs @@ -0,0 +1,99 @@ +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, jsx: ts.JsxEmit.ReactJSX}}).outputText; +const loaded = new Map(); +function load(file) { + if (loaded.has(file)) return loaded.get(file); + const exports = {}; + loaded.set(file, exports); + vm.runInNewContext(compile(file), {exports, require: name => { + if (name.startsWith('.')) return load(path.posix.join(path.posix.dirname(file), name) + '.ts'); + return require(name); + }}); + return exports; +} +const matcherExports = load('lib/columnSearch.ts'); +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 details = [ + [text, ['2000', '2.000', '(2.000)', 'rich text']], + [{...date, Behavior:'TimeZoneIndependent'}, ['timezoneindependent']], + [{...base, AttributeType:'IntegerAttribute', Format:'Whole Number', MinValue:-2147483648, MaxValue:2147483647}, ['whole number', 'min to max']], + [{...base, AttributeType:'IntegerAttribute', Format:'Duration', MinValue:-1200, MaxValue:3500}, ['-1200', '-1.200', '3500', '3.500']], + [{...base, AttributeType:'DecimalAttribute', Type:'Decimal', MinValue:-1250.5, MaxValue:9876.25, Precision:3}, ['-1250.5', '-1.250,5', '9876.25', '9.876,25', 'precision: 3']], + [{...base, AttributeType:'DecimalAttribute', Type:'Money', MinValue:-922337203685477, MaxValue:922337203685477, Precision:2}, ['money', 'min to max', 'precision: 2']], + [{...base, AttributeType:'FileAttribute', MaxSize:32768}, ['file', '32768', '32.768kb', 'max 32.768kb']], + [{...choice, DefaultValue:123456, Options:[{Name:'Selected', Value:123456, Description:'Ready for launch'}]}, ['123456', '123.456', 'ready for launch', 'default: selected']], + [{...base, AttributeType:'StatusAttribute', Options:[{Name:'Ready', Value:123456, State:'Active'}]}, ['state/status', 'active', '123456', '123.456']], + [{...base, AttributeType:'LookupAttribute', Targets:[{Name:'dmvp_Planet'}]}, ['lookup', 'dmvp_planet']], + [{...base, AttributeType:'BooleanAttribute', TrueLabel:'Enabled', FalseLabel:'Disabled', DefaultValue:false}, ['boolean', 'true', 'false', 'enabled', 'default: disabled']], + [{...base, AttributeType:'GenericAttribute', Type:'Uniqueidentifier'}, ['uniqueidentifier']], +]; +for (const [attribute, queries] of details) { + for (const query of queries) { + assert.equal(columnMatchesSearch(attribute, query, types), true, `${attribute.AttributeType}: ${query}`); + assert.equal(columnMatchesSearch(attribute, query, names), false, `Type value leaked into names: ${query}`); + assert.equal(columnMatchesSearch(attribute, query, descriptions), false, `Type value leaked into descriptions: ${query}`); + } +} +const {renderToStaticMarkup} = require('react-dom/server'); +const {highlightMatch, highlightTypeMatch} = load('lib/searchHighlight.tsx'); +for (const [value, query, expected] of [ + ['(2.000)', '2000', '2.000'], ['(2.000)', '200', '2.00'], ['(2.000)', '2.000', '2.000'], + ['(-1.250,5 to 9.876,25)', '-1250.5', '-1.250,5'], + ['(Max 32.768KB)', '32768', '32.768'], ['TimeZoneIndependent', ' timezoneindependent ', 'TimeZoneIndependent'], +]) assert.match(renderToStaticMarkup(highlightTypeMatch(value, query)), new RegExp(`]*>${expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); +assert.match(renderToStaticMarkup(highlightMatch('Synthetic table description', 'TABLE')), /]*>table<\/mark>/); +assert.equal(highlightMatch('Description', ''), 'Description'); +assert.equal(highlightTypeMatch('(2.000)', '999'), '(2.000)'); +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); + } + for (const [index, [attribute, queries]] of details.entries()) { + await worker.onmessage({data:{type:'init',groups:[{Name:'Playground',Entities:[{SchemaName:'dmvp_Project',DisplayName:'Project',Description:'Synthetic table description',Attributes:[attribute],Relationships:[],SecurityRoles:[]}]}]}}); + for (const query of queries) { + messages.length=0; + await worker.onmessage({data:{type:'search',data:query,searchScope:types,requestId:index}}); + assert.equal(messages.at(-1).complete,true); + assert.equal(messages.flatMap(message=>message.data||[]).filter(item=>item.type==='attribute').length,1, `Worker: ${query}`); + } + } + for (const enabled of [true, false]) { + messages.length=0; + await worker.onmessage({data:{type:'search',data:'synthetic table description',searchScope:{columnNames:false,columnDescriptions:false,columnDataTypes:false,tableDescriptions:enabled},requestId:99}}); + assert.equal(messages.flatMap(message=>message.data||[]).filter(item=>item.type==='entity').length,enabled ? 1 : 0); + } + console.log('PASS: scoped matching and worker results for all 10 column types, raw/formatted numbers, empty results, table descriptions, and highlight markup'); +})().catch(error=>{console.error(error);process.exitCode=1;});