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
42 changes: 30 additions & 12 deletions dashboard/src/components/features/models/Models/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ import { SupportRequestModal, CreateVirtualModelModal } from "../../../modals";
import { useEndpoints, useGroups } from "@/api/control-layer/hooks";
import { useDebounce } from "@/hooks/useDebounce";
import { useServerPagination } from "@/hooks/useServerPagination";
import {
usePersistedFilter,
clearPersistedFilters,
} from "@/hooks/usePersistedFilter";

const EMPTY_GROUPS: string[] = [];
const MODEL_TYPES = ["all", "virtual", "hosted"] as const;
type ModelType = (typeof MODEL_TYPES)[number];
const FILTER_PARAM_NAMES = ["endpoint", "groups", "type", "accessible"];

const Models: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
Expand All @@ -49,16 +58,19 @@ const Models: React.FC = () => {
const showPricing = true;
const canManageModels = hasPermission("manage-models");

const [filterProvider, setFilterProvider] = useState("all");
const [filterGroups, setFilterGroups] = useState<string[]>([]);
const [filterModelType, setFilterModelType] = useState<
"all" | "virtual" | "hosted"
>("all");
const [filterProvider, setFilterProvider] = usePersistedFilter("endpoint", "all");
const [filterGroups, setFilterGroups] = usePersistedFilter("groups", EMPTY_GROUPS);
const [rawModelType, setFilterModelType] = usePersistedFilter("type", "all");
const filterModelType: ModelType = (MODEL_TYPES as readonly string[]).includes(rawModelType)
? (rawModelType as ModelType)
: "all";
const [accessibleOnly, setAccessibleOnly] = usePersistedFilter("accessible", "false");
const showAccessibleOnly = accessibleOnly === "true";

const [searchQuery, setSearchQuery] = useState(
searchParams.get("search") || "",
);
const debouncedSearch = useDebounce(searchQuery, 300);
const [showAccessibleOnly, setShowAccessibleOnly] = useState(false);

// Use pagination hook for URL-based pagination state
const pagination = useServerPagination({ defaultPageSize: 12 });
Expand Down Expand Up @@ -107,15 +119,19 @@ const Models: React.FC = () => {
const isStatusMode = viewMode === "status";

const handleTabChange = (value: string) => {
setSearchParams({ view: value }, { replace: true });
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("view", value);
return next;
},
{ replace: true },
);
Comment thread
fergusfinn marked this conversation as resolved.
};

const handleClearFilters = () => {
setSearchQuery("");
setFilterProvider("all");
setFilterGroups([]);
setFilterModelType("all");
setShowAccessibleOnly(false);
clearPersistedFilters(setSearchParams, FILTER_PARAM_NAMES);
};

return (
Expand Down Expand Up @@ -348,7 +364,9 @@ const Models: React.FC = () => {
<Switch
id="access-toggle"
checked={showAccessibleOnly}
onCheckedChange={setShowAccessibleOnly}
onCheckedChange={(checked) =>
setAccessibleOnly(checked ? "true" : "false")
}
aria-label="Show only my accessible models"
/>
</div>
Expand Down
140 changes: 140 additions & 0 deletions dashboard/src/hooks/usePersistedFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useCallback } from "react";
import { useSearchParams } from "react-router-dom";

const STORAGE_KEY = "models-filters";

type FilterValue = string | string[];

/**
* Read all persisted filter defaults from localStorage.
*/
function loadDefaults(): Record<string, FilterValue> {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed && typeof parsed === "object") return parsed;
}
} catch {
// ignore corrupt data
}
return {};
}

/**
* Save a single filter key to the persisted defaults.
* Removes the key when value equals the provided default.
*/
function saveDefault(key: string, value: FilterValue, fallback: FilterValue) {
const defaults = loadDefaults();
const isDefault = JSON.stringify(value) === JSON.stringify(fallback);
if (isDefault) {
delete defaults[key];
} else {
defaults[key] = value;
}
if (Object.keys(defaults).length === 0) {
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
}
}

function serialize(value: FilterValue): string {
return Array.isArray(value) ? value.join(",") : value;
}

/**
* Clear all persisted filter params from URL and localStorage.
* Useful for a "clear all filters" action that avoids the race condition
* of calling multiple individual setters back-to-back.
*/
export function clearPersistedFilters(
setSearchParams: ReturnType<typeof useSearchParams>[1],
paramNames: string[],
) {
// Clear localStorage
localStorage.removeItem(STORAGE_KEY);

// Clear URL params in one batch
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const name of paramNames) {
next.delete(name);
}
return next;
},
{ replace: true },
);
}

/**
* Hook for filter state that uses URL search params as source of truth,
* falling back to localStorage-persisted defaults when a param is absent.
*
* - URL params present -> use them (shareable links work)
* - URL params absent -> fall back to localStorage defaults
* - Changes are written to both URL params and localStorage
*
* Supports both single-value (string) and multi-value (string[]) filters.
*
* @example
* ```tsx
* const [provider, setProvider] = usePersistedFilter("endpoint", "all");
* const [groups, setGroups] = usePersistedFilter("groups", EMPTY_GROUPS);
* ```
*/
export function usePersistedFilter(
paramName: string,
fallback: string,
): [string, (value: string) => void];
export function usePersistedFilter(
paramName: string,
fallback: string[],
): [string[], (value: string[]) => void];
export function usePersistedFilter(paramName: string, fallback: any): any {
const [searchParams, setSearchParams] = useSearchParams();
const isArray = Array.isArray(fallback);

const defaults = loadDefaults();
const urlValue = searchParams.get(paramName);

let value: FilterValue;
if (urlValue !== null) {
if (isArray) {
value = urlValue === "" ? [] : urlValue.split(",");
} else {
value = urlValue;
}
} else if (paramName in defaults) {
value = defaults[paramName];
} else {
value = fallback;
}

const setValue = useCallback(
(newValue: string | string[]) => {
saveDefault(paramName, newValue, fallback);

setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
const serialized = serialize(newValue);
const fallbackSerialized = serialize(fallback);

if (serialized === fallbackSerialized) {
next.delete(paramName);
} else {
next.set(paramName, serialized);
}
return next;
},
{ replace: true },
);
},
[paramName, fallback, setSearchParams],
);

return [value, setValue];
}