Skip to content

Commit 5fa577f

Browse files
Add cross link from occupancy page to workflows, refactor for better code reuse
1 parent ebc1a42 commit 5fa577f

File tree

18 files changed

+343
-534
lines changed

18 files changed

+343
-534
lines changed

src/ui/src/app/(dashboard)/occupancy/occupancy-page-content.tsx

Lines changed: 2 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,6 @@
1414

1515
//SPDX-License-Identifier: Apache-2.0
1616

17-
/**
18-
* Occupancy Page Content (Client Component)
19-
*
20-
* Layout: Toolbar → Summary cards → Collapsible-row table
21-
*
22-
* Data source: GET /api/task?summary=true → aggregated by user or pool.
23-
* All aggregation is client-side (shim) until backend ships group_by pagination (Issue #23).
24-
*/
25-
2617
"use client";
2718

2819
import { useMemo, useCallback, useState } from "react";
@@ -39,24 +30,12 @@ import { useOccupancyData } from "@/features/occupancy/hooks/use-occupancy-data"
3930
import { useOccupancyTableStore } from "@/features/occupancy/stores/occupancy-table-store";
4031
import type { OccupancyGroupBy, OccupancySortBy } from "@/lib/api/adapter/occupancy";
4132

42-
// =============================================================================
43-
// GroupBy parser for URL state
44-
// =============================================================================
45-
4633
const GROUP_BY_VALUES = ["pool", "user"] as const;
4734
const parseAsGroupBy = parseAsStringLiteral(GROUP_BY_VALUES);
4835

49-
// =============================================================================
50-
// Component
51-
// =============================================================================
52-
5336
export function OccupancyPageContent() {
5437
usePage({ title: "Occupancy" });
5538

56-
// ==========================================================================
57-
// URL State
58-
// ==========================================================================
59-
6039
const [groupBy, setGroupBy] = useQueryState(
6140
"groupBy",
6241
parseAsGroupBy.withDefault("pool").withOptions({ shallow: true, history: "replace", clearOnDefault: true }),
@@ -67,29 +46,17 @@ export function OccupancyPageContent() {
6746
defaultValue: TaskGroupStatus.RUNNING,
6847
});
6948

70-
// ==========================================================================
71-
// Sort state from table store
72-
// ==========================================================================
73-
7449
const sortState = useOccupancyTableStore((s) => s.sort);
7550
const sortBy: OccupancySortBy = (sortState?.column as OccupancySortBy) ?? "gpu";
7651
const order: "asc" | "desc" = sortState?.direction ?? "desc";
7752

78-
// ==========================================================================
79-
// Data
80-
// ==========================================================================
81-
8253
const { groups, totals, isLoading, error, refetch, truncated } = useOccupancyData({
8354
groupBy,
8455
sortBy,
8556
order,
8657
searchChips,
8758
});
8859

89-
// ==========================================================================
90-
// Toolbar props
91-
// ==========================================================================
92-
9360
const resultsCount = useResultsCount({
9461
total: groups.length,
9562
filteredTotal: groups.length,
@@ -103,11 +70,7 @@ export function OccupancyPageContent() {
10370
[setGroupBy],
10471
);
10572

106-
// ==========================================================================
107-
// Expand/collapse state — lifted here so toolbar can drive expand-all/collapse-all.
108-
// Reset when groupBy changes (stale keys from old view are meaningless).
109-
// ==========================================================================
110-
73+
// Expand/collapse state resets when groupBy changes (stale keys are meaningless)
11174
const [expandedState, setExpandedState] = useState<{ groupBy: OccupancyGroupBy; keys: Set<string> }>({
11275
groupBy,
11376
keys: new Set(),
@@ -140,13 +103,8 @@ export function OccupancyPageContent() {
140103

141104
const allExpanded = groups.length > 0 && expandedKeys.size === groups.length;
142105

143-
// ==========================================================================
144-
// Render
145-
// ==========================================================================
146-
147106
return (
148107
<div className="flex h-full flex-col gap-4 p-6">
149-
{/* Toolbar */}
150108
<div className="shrink-0">
151109
<InlineErrorBoundary
152110
title="Toolbar error"
@@ -168,7 +126,6 @@ export function OccupancyPageContent() {
168126
</InlineErrorBoundary>
169127
</div>
170128

171-
{/* KPI summary cards */}
172129
<div className="shrink-0">
173130
<InlineErrorBoundary
174131
title="Summary cards error"
@@ -181,15 +138,13 @@ export function OccupancyPageContent() {
181138
</InlineErrorBoundary>
182139
</div>
183140

184-
{/* Scale limit warning */}
185141
{truncated && (
186142
<div className="shrink-0 rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
187143
Results may be incomplete — reached the 10,000 row fetch limit. Backend group_by pagination (Issue #23) is
188144
required for full data at this scale.
189145
</div>
190146
)}
191147

192-
{/* Main table */}
193148
<div className="min-h-0 flex-1">
194149
<InlineErrorBoundary
195150
title="Unable to display occupancy table"
@@ -199,6 +154,7 @@ export function OccupancyPageContent() {
199154
<OccupancyDataTable
200155
groups={groups}
201156
groupBy={groupBy}
157+
searchChips={searchChips}
202158
expandedKeys={expandedKeys}
203159
onToggleExpand={handleToggleExpand}
204160
isLoading={isLoading}

src/ui/src/components/filter-bar/hooks/use-default-filter.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export function useDefaultFilter({
6161

6262
const hasDefaultInUrl = useMemo(() => searchChips.some((c) => c.field === field), [searchChips, field]);
6363

64+
// When both optOut and explicit chips are present, explicit chips win.
65+
// Compute this synchronously so callers never see a transient contradictory state.
66+
const effectiveOptOut = optOut && !hasDefaultInUrl;
67+
6468
const shouldPrePopulate = !optOut && !hasDefaultInUrl && !!defaultValue;
6569

6670
const effectiveChips = useMemo((): SearchChip[] => {
@@ -69,6 +73,13 @@ export function useDefaultFilter({
6973
return [...searchChips, { field, value: defaultValue!, label: chipLabel }];
7074
}, [searchChips, shouldPrePopulate, field, defaultValue, label]);
7175

76+
// If the URL has the opt-out flag set but also has explicit chips for this field,
77+
// the two are contradictory. The explicit chips win — clear the opt-out so downstream
78+
// consumers (e.g. showAllUsers) don't ignore the manual filter.
79+
useEffect(() => {
80+
if (optOut && hasDefaultInUrl) void setOptOut(false);
81+
}, [optOut, hasDefaultInUrl, setOptOut]);
82+
7283
useEffect(() => {
7384
if (!shouldPrePopulate) return;
7485
const chipLabel = label ?? `${field}: ${defaultValue}`;
@@ -91,5 +102,5 @@ export function useDefaultFilter({
91102
[effectiveChips, field, optOut, setOptOut, setSearchChips],
92103
);
93104

94-
return { effectiveChips, handleChipsChange, optOut };
105+
return { effectiveChips, handleChipsChange, optOut: effectiveOptOut };
95106
}

src/ui/src/components/filter-bar/hooks/use-url-chips.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,32 @@
1717
"use client";
1818

1919
import { useMemo, useCallback } from "react";
20-
import { useQueryState, parseAsArrayOf, parseAsString } from "nuqs";
20+
import { useQueryState, createMultiParser } from "nuqs";
2121
import type { SearchChip } from "@/stores/types";
2222
import { parseUrlChips } from "@/lib/url-utils";
2323

2424
export interface SetSearchChipsOptions {
2525
history?: "push" | "replace";
2626
}
2727

28-
/**
29-
* URL-synced search chips. Parses "field:value" from repeated URL params (?f=field:value)
30-
* into SearchChip[] and writes changes back to the URL for shareable filtered views.
31-
*/
28+
// Uses repeated query params (?f=pool:X&f=user:Y) for filter chips.
29+
// type:"multi" makes nuqs call searchParams.getAll(), collecting repeated params.
30+
// Each param value is one chip string — no secondary separator that could corrupt
31+
// values containing commas.
32+
const parseAsChipStrings = createMultiParser({
33+
parse: (values: readonly string[]) => values.filter(Boolean),
34+
serialize: (values: readonly string[]) => Array.from(values),
35+
eq: (a: string[], b: string[]) => {
36+
if (a.length !== b.length) return false;
37+
const setA = new Set(a);
38+
return b.every((v) => setA.has(v));
39+
},
40+
});
41+
3242
export function useUrlChips({ paramName = "f" }: { paramName?: string } = {}) {
3343
const [filterStrings, setFilterStrings] = useQueryState(
3444
paramName,
35-
parseAsArrayOf(parseAsString).withOptions({
45+
parseAsChipStrings.withOptions({
3646
shallow: true,
3747
history: "push",
3848
clearOnDefault: true,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved.
2+
3+
//Licensed under the Apache License, Version 2.0 (the "License");
4+
//you may not use this file except in compliance with the License.
5+
//You may obtain a copy of the License at
6+
7+
//http://www.apache.org/licenses/LICENSE-2.0
8+
9+
//Unless required by applicable law or agreed to in writing, software
10+
//distributed under the License is distributed on an "AS IS" BASIS,
11+
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
//See the License for the specific language governing permissions and
13+
//limitations under the License.
14+
15+
//SPDX-License-Identifier: Apache-2.0
16+
17+
import { cn } from "@/lib/utils";
18+
19+
export function presetPillClasses(
20+
bgClass: string,
21+
active: boolean,
22+
activeRingClass = "ring-black/15 ring-inset dark:ring-white/20",
23+
): string {
24+
return cn(
25+
"inline-flex items-center gap-1.5 rounded px-2 py-0.5 transition-all",
26+
bgClass,
27+
active && `ring-2 ${activeRingClass}`,
28+
"group-data-[selected=true]:scale-105 group-data-[selected=true]:shadow-lg",
29+
!active && "opacity-70 group-data-[selected=true]:opacity-100 hover:opacity-100",
30+
);
31+
}

src/ui/src/features/datasets/list/components/toolbar/datasets-toolbar.tsx

Lines changed: 7 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,51 +18,25 @@
1818

1919
import { memo, useMemo } from "react";
2020
import { User } from "lucide-react";
21-
import { cn } from "@/lib/utils";
2221
import type { SearchChip } from "@/stores/types";
2322
import type { ResultsCount, SearchField, SearchPreset } from "@/components/filter-bar/lib/types";
23+
import { presetPillClasses } from "@/components/filter-bar/lib/preset-pill";
2424
import { TableToolbar } from "@/components/data-table/table-toolbar";
2525
import { useDatasetsTableStore } from "@/features/datasets/list/stores/datasets-table-store";
2626
import { OPTIONAL_COLUMNS } from "@/features/datasets/list/lib/dataset-columns";
2727
import { DATASET_STATIC_FIELDS, type Dataset } from "@/features/datasets/list/lib/dataset-search-fields";
2828
import { useDatasetsAsyncFields } from "@/features/datasets/list/hooks/use-datasets-async-fields";
2929

30-
// =============================================================================
31-
// Helpers
32-
// =============================================================================
33-
34-
function presetPillClasses(bgClass: string, active: boolean): string {
35-
return cn(
36-
"inline-flex items-center gap-1.5 rounded px-2 py-0.5 transition-all",
37-
bgClass,
38-
active && "ring-2 ring-black/15 ring-inset dark:ring-white/20",
39-
"group-data-[selected=true]:scale-105 group-data-[selected=true]:shadow-lg",
40-
!active && "opacity-70 group-data-[selected=true]:opacity-100 hover:opacity-100",
41-
);
42-
}
43-
44-
// =============================================================================
45-
// Props
46-
// =============================================================================
47-
4830
export interface DatasetsToolbarProps {
4931
datasets: Dataset[];
5032
searchChips: SearchChip[];
5133
onSearchChipsChange: (chips: SearchChip[]) => void;
52-
/** Results count for displaying "N results" or "M of N results" */
5334
resultsCount?: ResultsCount;
54-
/** Current username for "My Datasets" preset */
5535
currentUsername?: string | null;
56-
/** Manual refresh callback */
5736
onRefresh: () => void;
58-
/** Loading state for refresh button */
5937
isRefreshing: boolean;
6038
}
6139

62-
// =============================================================================
63-
// Component
64-
// =============================================================================
65-
6640
export const DatasetsToolbar = memo(function DatasetsToolbar({
6741
datasets,
6842
searchChips,
@@ -75,18 +49,16 @@ export const DatasetsToolbar = memo(function DatasetsToolbar({
7549
const visibleColumnIds = useDatasetsTableStore((s) => s.visibleColumnIds);
7650
const toggleColumn = useDatasetsTableStore((s) => s.toggleColumn);
7751

78-
// Async fields: user list from /api/users with lazy loading
7952
const { userField } = useDatasetsAsyncFields();
8053

81-
// Compose static + async fields
8254
const searchFields = useMemo(
8355
(): readonly SearchField<Dataset>[] => [
84-
DATASET_STATIC_FIELDS[0], // type
85-
DATASET_STATIC_FIELDS[1], // name
86-
DATASET_STATIC_FIELDS[2], // bucket
87-
userField, // async - complete user list
88-
DATASET_STATIC_FIELDS[3], // created_at
89-
DATASET_STATIC_FIELDS[4], // updated_at
56+
DATASET_STATIC_FIELDS[0],
57+
DATASET_STATIC_FIELDS[1],
58+
DATASET_STATIC_FIELDS[2],
59+
userField,
60+
DATASET_STATIC_FIELDS[3],
61+
DATASET_STATIC_FIELDS[4],
9062
],
9163
[userField],
9264
);
@@ -123,7 +95,6 @@ export const DatasetsToolbar = memo(function DatasetsToolbar({
12395
return [{ label: "User:", items: [myDatasetsPreset] }];
12496
}, [myDatasetsPreset]);
12597

126-
// Memoize autoRefreshProps to prevent unnecessary TableToolbar re-renders
12798
const autoRefreshProps = useMemo(
12899
() => ({
129100
onRefresh,

0 commit comments

Comments
 (0)