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;});