Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [
"src/components/shared/ReactFlow/FlowControls/StackingControls.tsx",
"src/components/shared/ReactFlow/FlowCanvas/FlexNode",
"src/components/shared/ReactFlow/FlowCanvas/TaskNode/ArgumentsEditor/DynamicDataDropdown.tsx",
"src/components/shared/HighlightText.tsx",

// 11-20 useCallback/useMemo
// "src/components/ui", // 12
Expand Down
65 changes: 64 additions & 1 deletion src/components/Home/PipelineSection/PipelineRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { useNavigate } from "@tanstack/react-router";
import { type MouseEvent } from "react";

import { ConfirmationDialog } from "@/components/shared/Dialogs";
import { HighlightText } from "@/components/shared/HighlightText";
import { PipelineRunInfoCondensed } from "@/components/shared/PipelineRunDisplay/PipelineRunInfoCondensed";
import { PipelineRunsList } from "@/components/shared/PipelineRunDisplay/PipelineRunsList";
import { usePipelineRuns } from "@/components/shared/PipelineRunDisplay/usePipelineRuns";
import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import {
Popover,
PopoverContent,
Expand All @@ -22,6 +25,8 @@ import { deletePipeline } from "@/services/pipelineService";
import type { ComponentReferenceWithSpec } from "@/utils/componentStore";
import { formatDate } from "@/utils/date";

import type { MatchedField } from "./usePipelineFilters";

interface PipelineRowProps {
url?: string;
componentRef?: ComponentReferenceWithSpec;
Expand All @@ -30,6 +35,10 @@ interface PipelineRowProps {
onDelete?: () => void;
isSelected?: boolean;
onSelect?: (checked: boolean) => void;
searchQuery?: string;
matchedFields?: MatchedField[];
componentQuery?: string;
matchedComponentNames?: string[];
}

const PipelineRow = withSuspenseWrapper(
Expand All @@ -39,6 +48,10 @@ const PipelineRow = withSuspenseWrapper(
onDelete,
isSelected = false,
onSelect,
searchQuery,
matchedFields,
componentQuery,
matchedComponentNames,
}: PipelineRowProps) => {
const navigate = useNavigate();

Expand Down Expand Up @@ -90,7 +103,17 @@ const PipelineRow = withSuspenseWrapper(
/>
</TableCell>
<TableCell>
<Paragraph>{name}</Paragraph>
<BlockStack gap="0">
<Paragraph>
<HighlightText text={name ?? ""} query={searchQuery} />
</Paragraph>
<MatchBadges
matchedFields={matchedFields}
matchedComponentNames={matchedComponentNames}
searchQuery={searchQuery}
componentQuery={componentQuery}
/>
</BlockStack>
</TableCell>
<TableCell>
<Paragraph tone="subdued" size="xs">
Expand Down Expand Up @@ -193,6 +216,46 @@ const PipelineRunsButton = withSuspenseWrapper(
},
);

function MatchBadges({
matchedFields,
matchedComponentNames,
searchQuery,
componentQuery,
}: {
matchedFields?: MatchedField[];
matchedComponentNames?: string[];
searchQuery?: string;
componentQuery?: string;
}) {
const hasFields = matchedFields && matchedFields.length > 0;
const hasComponents =
matchedComponentNames && matchedComponentNames.length > 0;

if (!hasFields && !hasComponents) return null;

return (
<InlineStack gap="1" className="mt-1" wrap="wrap">
{matchedFields?.map((field) => (
<Badge
key={field.label}
variant="secondary"
size="sm"
className="max-w-60 truncate"
>
{field.label}:{" "}
<HighlightText text={field.value} query={searchQuery} />
</Badge>
))}
{matchedComponentNames?.map((compName) => (
<Badge key={compName} variant="secondary" size="sm">
<Icon name="File" size="xs" />
<HighlightText text={compName} query={componentQuery} />
</Badge>
))}
</InlineStack>
);
}

function formatModificationTime(modificationTime: Date | undefined) {
return modificationTime ? formatDate(modificationTime) : "N/A";
}
Expand Down
6 changes: 5 additions & 1 deletion src/components/Home/PipelineSection/PipelineSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,18 @@ export const PipelineSection = withSuspenseWrapper(() => {
</TableCell>
</TableRow>
)}
{paginatedPipelines.map(([name, fileEntry]) => (
{paginatedPipelines.map(([name, fileEntry, matchMetadata]) => (
<PipelineRow
key={fileEntry.componentRef.digest}
name={name}
modificationTime={fileEntry.modificationTime}
onDelete={fetchUserPipelines}
isSelected={selectedPipelines.has(name)}
onSelect={(checked) => handleSelectPipeline(name, checked)}
searchQuery={matchMetadata.searchQuery}
matchedFields={matchMetadata.matchedFields}
componentQuery={matchMetadata.componentQuery}
matchedComponentNames={matchMetadata.matchedComponentNames}
/>
))}
</TableBody>
Expand Down
72 changes: 70 additions & 2 deletions src/components/Home/PipelineSection/usePipelineFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,19 @@ import type { ComponentFileEntry } from "@/utils/componentStore";
export type PipelineSortField = "modified_at" | "name";
export type PipelineSortDirection = "asc" | "desc";

type PipelineEntry = [string, ComponentFileEntry];
export interface MatchedField {
label: string;
value: string;
}

interface PipelineMatchMetadata {
searchQuery: string;
matchedFields: MatchedField[];
componentQuery: string;
matchedComponentNames: string[];
}

type PipelineEntry = [string, ComponentFileEntry, PipelineMatchMetadata];

export interface FilterBarProps {
searchQuery: string;
Expand Down Expand Up @@ -64,6 +76,55 @@ function matchesSearch(
);
}

function getMatchMetadata(
fileEntry: ComponentFileEntry,
searchQuery: string,
componentQuery: string,
): PipelineMatchMetadata {
const matchedFields: MatchedField[] = [];
if (searchQuery) {
const q = searchQuery.toLowerCase();
const spec = fileEntry.componentRef.spec;
const desc = spec.description ?? "";
if (desc.toLowerCase().includes(q)) {
matchedFields.push({ label: "Description", value: desc });
}
const author = spec.metadata?.annotations?.author ?? "";
if (author.toLowerCase().includes(q)) {
matchedFields.push({ label: "Author", value: author });
}
const rawNotes = spec.metadata?.annotations?.["notes"];
const notes = typeof rawNotes === "string" ? rawNotes : "";
if (notes.toLowerCase().includes(q)) {
matchedFields.push({ label: "Note", value: notes });
}
}

const matchedComponentNames: string[] = [];
const impl = fileEntry.componentRef.spec.implementation;
if (componentQuery && isGraphImplementation(impl)) {
const normalizedQuery = componentQuery.toLowerCase();
const seen = new Set<string>();
for (const task of Object.values(impl.graph.tasks)) {
const refName = task.componentRef.name ?? "";
const specName = task.componentRef.spec?.name ?? "";
for (const name of [refName, specName]) {
if (!name || seen.has(name)) continue;
if (!name.toLowerCase().includes(normalizedQuery)) continue;
seen.add(name);
matchedComponentNames.push(name);
}
}
}

return {
searchQuery,
matchedFields,
componentQuery,
matchedComponentNames,
};
}

function matchesDateRange(
fileEntry: ComponentFileEntry,
dateRange: DateRange | undefined,
Expand Down Expand Up @@ -117,7 +178,14 @@ export function usePipelineFilters(pipelines: Map<string, ComponentFileEntry>) {
(new Date(entryA.modificationTime).getTime() -
new Date(entryB.modificationTime).getTime())
);
});
})
.map(
([name, fileEntry]): PipelineEntry => [
name,
fileEntry,
getMatchMetadata(fileEntry, searchQuery, componentQuery),
],
);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As determining this filteredPipelines variable gets more complex, I'm just wondering if we should consider wrapping it in a useMemo that explicitly controls when it is calculated / processed (e.g. when the search text changes), or if the React compiler work we did will be smart enough to do that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

react compiler should handle this


const filterKey = [
searchQuery,
Expand Down
27 changes: 27 additions & 0 deletions src/components/shared/HighlightText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
interface HighlightTextProps {
text: string;
query: string | undefined;
className?: string;
}

function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export function HighlightText({ text, query, className }: HighlightTextProps) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: add to react-compiler config

if (!query) return <span className={className}>{text}</span>;

const parts = text.split(new RegExp(`(${escapeRegex(query)})`, "gi"));

return (
<span className={className}>
{parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? (
<strong key={i}>{part}</strong>
) : (
part
),
)}
</span>
);
}