From 1717a99cee573a60f02a5ebf1eb4145c5024855a Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 2 Jul 2026 21:53:52 -0400
Subject: [PATCH] add compact mode for large orgs
---
messages/en-US.json | 7 +
server/routers/external.ts | 6 +
server/routers/launcher/index.ts | 1 +
.../launcher/launcherResourceAccess.ts | 11 +-
server/routers/launcher/launcherScale.ts | 124 +++++++++++++++++
server/routers/launcher/listLauncherScale.ts | 60 +++++++++
server/routers/launcher/types.ts | 34 ++++-
src/app/[orgId]/page.tsx | 1 +
src/components/multi-site-selector.tsx | 14 +-
.../LauncherFilterPopover.tsx | 4 +
.../LauncherFlatResourceList.tsx | 125 ++++++++++++++++++
.../resource-launcher/LauncherGroupList.tsx | 24 ++--
.../LauncherSearchFirstGate.tsx | 86 ++++++++++++
.../LauncherSettingsMenu.tsx | 47 ++++++-
.../resource-launcher/ResourceLauncher.tsx | 82 ++++++++++--
src/lib/launcherLocalStorage.ts | 8 +-
src/lib/launcherScale.ts | 95 +++++++++++++
src/lib/launcherServerData.ts | 54 ++++++--
src/lib/launcherUrlState.ts | 2 +-
src/lib/queries.ts | 15 +++
20 files changed, 757 insertions(+), 43 deletions(-)
create mode 100644 server/routers/launcher/launcherScale.ts
create mode 100644 server/routers/launcher/listLauncherScale.ts
create mode 100644 src/components/resource-launcher/LauncherFlatResourceList.tsx
create mode 100644 src/components/resource-launcher/LauncherSearchFirstGate.tsx
create mode 100644 src/lib/launcherScale.ts
diff --git a/messages/en-US.json b/messages/en-US.json
index 0129c1159..e699aba9f 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -3590,6 +3590,7 @@
"resourceLauncherGroupBy": "Group By",
"resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Label",
+ "resourceLauncherGroupByNone": "None",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Grid",
"resourceLauncherLayoutList": "List",
@@ -3607,6 +3608,12 @@
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
+ "resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
+ "resourceLauncherSearchFirstDescription": "This organization has many resources. Use search or filter by site or label to find what you need.",
+ "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
+ "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
+ "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
+ "resourceLauncherCompactGroupingLocked": "Grouping is set to None in compact mode.",
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name",
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 5d770c064..43cf88a43 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -470,6 +470,12 @@ authenticated.get(
launcher.listLauncherGroups
);
+authenticated.get(
+ "/org/:orgId/launcher/scale",
+ verifyOrgAccess,
+ launcher.listLauncherScale
+);
+
authenticated.get(
"/org/:orgId/launcher/resources",
verifyOrgAccess,
diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts
index 939bf74bc..e941bbe10 100644
--- a/server/routers/launcher/index.ts
+++ b/server/routers/launcher/index.ts
@@ -1,5 +1,6 @@
export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups";
+export { listLauncherScale } from "./listLauncherScale";
export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels";
diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts
index 8fe5e8ef8..2ce6d87d4 100644
--- a/server/routers/launcher/launcherResourceAccess.ts
+++ b/server/routers/launcher/launcherResourceAccess.ts
@@ -38,6 +38,7 @@ import {
formatSiteResourceAccess
} from "./formatLauncherAccess";
import {
+ LAUNCHER_FLAT_GROUP_KEY,
LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY,
type LauncherFilterListQuery,
@@ -1161,10 +1162,12 @@ export async function listLauncherResourcesForUser(
let items = [...publicItems, ...siteItems];
items = filterResourcesBySearch(items, query.query);
- if (query.groupBy === "label") {
- items = filterResourcesByLabel(items, query.groupKey);
- } else if (query.groupBy === "site") {
- items = filterResourcesBySite(items, query.groupKey);
+ if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
+ if (query.groupBy === "label") {
+ items = filterResourcesByLabel(items, query.groupKey);
+ } else if (query.groupBy === "site") {
+ items = filterResourcesBySite(items, query.groupKey);
+ }
}
items = sortLauncherResources(items, query.order);
diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts
new file mode 100644
index 000000000..ec7750cfb
--- /dev/null
+++ b/server/routers/launcher/launcherScale.ts
@@ -0,0 +1,124 @@
+import { regionalCache as cache } from "#dynamic/lib/cache";
+import {
+ listLauncherGroupsForUser,
+ resolveAccessibleIds
+} from "./launcherResourceAccess";
+import {
+ parseIdListParam,
+ type LauncherScaleInfo,
+ type LauncherScaleQuery
+} from "./types";
+
+export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
+export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
+export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
+export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
+
+const LAUNCHER_SCALE_TTL_SEC = 60;
+
+function launcherScaleCacheKey(
+ orgId: string,
+ userId: string,
+ roleIds: number[],
+ query: LauncherScaleQuery
+) {
+ const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
+ const filterKey = [
+ query.query,
+ query.groupBy,
+ query.siteIds ?? "",
+ query.labelIds ?? "",
+ query.sort_by,
+ query.order
+ ].join("|");
+ return `launcherScale:${orgId}:${userId}:${rolesKey}:${filterKey}`;
+}
+
+async function getLauncherScaleForUserUncached(
+ orgId: string,
+ userId: string,
+ userRoleIds: number[],
+ query: LauncherScaleQuery
+): Promise {
+ const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
+ const resourceCount =
+ accessible.resourceIds.length + accessible.siteResourceIds.length;
+
+ const baselineQuery = {
+ query: "",
+ groupBy: "site" as const,
+ siteIds: undefined,
+ labelIds: undefined,
+ sort_by: "name" as const,
+ order: "asc" as const,
+ page: 1,
+ pageSize: 1
+ };
+
+ const [{ total: siteGroupCount }, { total: labelGroupCount }] =
+ await Promise.all([
+ listLauncherGroupsForUser(
+ orgId,
+ userId,
+ userRoleIds,
+ baselineQuery
+ ),
+ listLauncherGroupsForUser(orgId, userId, userRoleIds, {
+ ...baselineQuery,
+ groupBy: "label"
+ })
+ ]);
+
+ const mode =
+ resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
+ siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
+ ? "full"
+ : "compact";
+
+ const siteFilterIds = parseIdListParam(query.siteIds);
+
+ const allowSiteGrouping =
+ siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
+ (siteFilterIds.length > 0 &&
+ siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX);
+
+ const allowLabelGrouping =
+ labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS;
+
+ const requireSearchOrFilter =
+ mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES;
+
+ return {
+ mode,
+ resourceCount,
+ siteGroupCount,
+ labelGroupCount,
+ capabilities: {
+ allowSiteGrouping,
+ allowLabelGrouping,
+ requireSearchOrFilter
+ }
+ };
+}
+
+export async function getLauncherScaleForUser(
+ orgId: string,
+ userId: string,
+ userRoleIds: number[],
+ query: LauncherScaleQuery
+): Promise {
+ const cacheKey = launcherScaleCacheKey(orgId, userId, userRoleIds, query);
+ const cached = await cache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ const result = await getLauncherScaleForUserUncached(
+ orgId,
+ userId,
+ userRoleIds,
+ query
+ );
+ await cache.set(cacheKey, result, LAUNCHER_SCALE_TTL_SEC);
+ return result;
+}
diff --git a/server/routers/launcher/listLauncherScale.ts b/server/routers/launcher/listLauncherScale.ts
new file mode 100644
index 000000000..2fc907b17
--- /dev/null
+++ b/server/routers/launcher/listLauncherScale.ts
@@ -0,0 +1,60 @@
+import { response } from "@server/lib/response";
+import HttpCode from "@server/types/HttpCode";
+import { NextFunction, Request, Response } from "express";
+import createHttpError from "http-errors";
+import { fromZodError } from "zod-validation-error";
+import { getLauncherScaleForUser } from "./launcherScale";
+import { launcherScaleQuerySchema } from "./types";
+
+export async function listLauncherScale(
+ req: Request,
+ res: Response,
+ next: NextFunction
+): Promise {
+ try {
+ const orgId = req.userOrgId;
+ const userId = req.user!.userId;
+
+ if (!orgId) {
+ return next(
+ createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
+ );
+ }
+
+ const parsed = launcherScaleQuerySchema.safeParse(req.query);
+ if (!parsed.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromZodError(parsed.error)
+ )
+ );
+ }
+
+ const scale = await getLauncherScaleForUser(
+ orgId,
+ userId,
+ req.userOrgRoleIds ?? [],
+ parsed.data
+ );
+
+ return response(res, {
+ data: { scale },
+ success: true,
+ error: false,
+ message: "Launcher scale retrieved successfully",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ if (createHttpError.isHttpError(error)) {
+ return next(error);
+ }
+ console.error("Error listing launcher scale:", error);
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ "Internal server error"
+ )
+ );
+ }
+}
diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts
index 0a252d74d..2b7ee7f6e 100644
--- a/server/routers/launcher/types.ts
+++ b/server/routers/launcher/types.ts
@@ -2,9 +2,10 @@ import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
+export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
export const launcherViewConfigSchema = z.object({
- groupBy: z.enum(["site", "label"]).default("site"),
+ groupBy: z.enum(["site", "label", "none"]).default("site"),
layout: z.enum(["grid", "list"]).default("grid"),
sortBy: z.literal("name").default("name"),
order: z.enum(["asc", "desc"]).default("asc"),
@@ -138,7 +139,7 @@ export const launcherListQuerySchema = z.strictObject({
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default(""),
- groupBy: z.enum(["site", "label"]).optional().default("site"),
+ groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
groupKey: z.string().optional(),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
@@ -163,3 +164,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection =
| { type: "default" }
| { type: "saved"; viewId: number };
+
+export type LauncherScaleCapabilities = {
+ allowSiteGrouping: boolean;
+ allowLabelGrouping: boolean;
+ requireSearchOrFilter: boolean;
+};
+
+export type LauncherScaleInfo = {
+ mode: "full" | "compact";
+ resourceCount: number;
+ siteGroupCount: number;
+ labelGroupCount: number;
+ capabilities: LauncherScaleCapabilities;
+};
+
+export type ListLauncherScaleResponse = {
+ scale: LauncherScaleInfo;
+};
+
+export const launcherScaleQuerySchema = z.strictObject({
+ query: z.string().optional().default(""),
+ groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
+ siteIds: z.string().optional(),
+ labelIds: z.string().optional(),
+ sort_by: z.literal("name").optional().default("name"),
+ order: z.enum(["asc", "desc"]).optional().default("asc")
+});
+
+export type LauncherScaleQuery = z.infer;
diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx
index 7abb1b317..0197a5502 100644
--- a/src/app/[orgId]/page.tsx
+++ b/src/app/[orgId]/page.tsx
@@ -85,6 +85,7 @@ export default async function OrgPage(props: OrgPageProps) {
activeViewId={launcherData.activeViewId}
config={launcherData.config}
savedConfig={launcherData.savedConfig}
+ scale={launcherData.scale}
groups={launcherData.groups}
groupsPagination={launcherData.groupsPagination}
/>
diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx
index fe81dc697..15db6ceea 100644
--- a/src/components/multi-site-selector.tsx
+++ b/src/components/multi-site-selector.tsx
@@ -20,6 +20,8 @@ export type MultiSitesSelectorProps = {
onSelectionChange: (sites: Selectedsite[]) => void;
filterTypes?: string[];
scope?: "org" | "launcher";
+ onClear?: () => void;
+ showClear?: boolean;
};
export function formatMultiSitesSelectorLabel(
@@ -42,7 +44,9 @@ export function MultiSitesSelector({
selectedSites,
onSelectionChange,
filterTypes,
- scope = "org"
+ scope = "org",
+ onClear,
+ showClear = false
}: MultiSitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
@@ -107,6 +111,14 @@ export function MultiSitesSelector({
{t("siteNotFound")}
+ {showClear && onClear && (
+
+ {t("accessFilterClear")}
+
+ )}
{sitesShown.map((site) => (
0}
+ onClear={() => {
+ onSitesChange([]);
+ }}
/>
diff --git a/src/components/resource-launcher/LauncherFlatResourceList.tsx b/src/components/resource-launcher/LauncherFlatResourceList.tsx
new file mode 100644
index 000000000..7bbc01ccb
--- /dev/null
+++ b/src/components/resource-launcher/LauncherFlatResourceList.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
+import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
+import { launcherQueries } from "@app/lib/queries";
+import type {
+ LauncherResource,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
+import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { useEffect, useMemo, useRef } from "react";
+import { LauncherEmptyState } from "./LauncherEmptyState";
+import { LauncherResourceGrid } from "./LauncherResourceGrid";
+import { LauncherResourceList } from "./LauncherResourceList";
+
+type LauncherFlatResourceListProps = {
+ orgId: string;
+ activeViewId: LauncherActiveViewId;
+ config: LauncherViewConfig;
+ onClearFilters?: () => void;
+ onResourceSelect?: (resource: LauncherResource) => void;
+};
+
+export function LauncherFlatResourceList({
+ orgId,
+ config,
+ onClearFilters,
+ onResourceSelect
+}: LauncherFlatResourceListProps) {
+ const loadMoreRef = useRef(null);
+
+ const resourceFilters = useMemo(
+ () => ({
+ query: config.query,
+ groupBy: config.groupBy,
+ groupKey: LAUNCHER_FLAT_GROUP_KEY,
+ siteIds: config.siteIds,
+ labelIds: config.labelIds,
+ sort_by: config.sortBy,
+ order: config.order,
+ pageSize: 20
+ }),
+ [
+ config.groupBy,
+ config.labelIds,
+ config.order,
+ config.query,
+ config.siteIds,
+ config.sortBy
+ ]
+ );
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
+ useInfiniteQuery({
+ ...launcherQueries.resources(orgId, resourceFilters)
+ });
+
+ const resources = data?.pages.flatMap((page) => page.resources) ?? [];
+
+ useEffect(() => {
+ const node = loadMoreRef.current;
+ if (!node || !hasNextPage) {
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ },
+ { rootMargin: "200px" }
+ );
+
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
+
+ if (resources.length === 0) {
+ if (isFetching) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ return (
+
+ {config.layout === "grid" ? (
+
+ ) : (
+
+ )}
+
+ {isFetchingNextPage ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx
index c7fe6b905..ecf60a773 100644
--- a/src/components/resource-launcher/LauncherGroupList.tsx
+++ b/src/components/resource-launcher/LauncherGroupList.tsx
@@ -69,16 +69,20 @@ export function LauncherGroupList({
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({
...launcherQueries.groups(orgId, groupFilters),
- initialData: {
- pages: [
- {
- groups: initialGroups,
- pagination: groupsPagination
- }
- ],
- pageParams: [1]
- },
- refetchOnMount: false
+ ...(initialGroups.length > 0
+ ? {
+ initialData: {
+ pages: [
+ {
+ groups: initialGroups,
+ pagination: groupsPagination
+ }
+ ],
+ pageParams: [1]
+ },
+ refetchOnMount: false as const
+ }
+ : {})
});
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
diff --git a/src/components/resource-launcher/LauncherSearchFirstGate.tsx b/src/components/resource-launcher/LauncherSearchFirstGate.tsx
new file mode 100644
index 000000000..ab9ea1226
--- /dev/null
+++ b/src/components/resource-launcher/LauncherSearchFirstGate.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { cn } from "@app/lib/cn";
+import { Search } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+type LauncherSearchFirstGateProps = {
+ layout: "grid" | "list";
+};
+
+function GhostResourceGrid() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function GhostResourceList() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+export function LauncherSearchFirstGate({
+ layout
+}: LauncherSearchFirstGateProps) {
+ const t = useTranslations();
+
+ return (
+
+
+ {layout === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {t("resourceLauncherSearchFirstTitle")}
+
+
+ {t("resourceLauncherSearchFirstDescription")}
+
+
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx
index 41143cd10..abce2e5e6 100644
--- a/src/components/resource-launcher/LauncherSettingsMenu.tsx
+++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx
@@ -15,13 +15,19 @@ import {
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
-import type { LauncherViewConfig } from "@server/routers/launcher/types";
+import type {
+ LauncherScaleCapabilities,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
import { useTranslations } from "next-intl";
import { Settings } from "lucide-react";
type LauncherSettingsMenuProps = {
config: LauncherViewConfig;
isDefaultView: boolean;
+ capabilities: LauncherScaleCapabilities;
+ isCompactMode: boolean;
+ selectedGroupBy: LauncherViewConfig["groupBy"];
onConfigChange: (patch: Partial) => void;
onDeleteView: () => void;
};
@@ -29,6 +35,9 @@ type LauncherSettingsMenuProps = {
export function LauncherSettingsMenu({
config,
isDefaultView,
+ capabilities,
+ isCompactMode,
+ selectedGroupBy,
onConfigChange,
onDeleteView
}: LauncherSettingsMenuProps) {
@@ -51,7 +60,7 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupBy")}
+ {isCompactMode ? (
+
+ {t("resourceLauncherCompactGroupingLocked")}
+
+ ) : null}
+ {!isCompactMode && !capabilities.allowSiteGrouping ? (
+
+ {t("resourceLauncherSiteGroupingDisabled")}
+
+ ) : null}
+ {!isCompactMode && !capabilities.allowLabelGrouping ? (
+
+ {t("resourceLauncherLabelGroupingDisabled")}
+
+ ) : null}
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx
index be5620d25..2cc9d50a2 100644
--- a/src/components/resource-launcher/ResourceLauncher.tsx
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -29,12 +29,20 @@ import {
} from "@app/lib/launcherUrlState";
import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
+import {
+ getEffectiveLauncherConfig,
+ shouldShowFlatResourceList,
+ shouldShowLauncherGroupList,
+ shouldShowSearchFirstGate
+} from "@app/lib/launcherScale";
+import { launcherQueries } from "@app/lib/queries";
import type {
LauncherGroup,
+ LauncherScaleInfo,
LauncherViewConfig,
LauncherViewRecord
} from "@server/routers/launcher/types";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -52,7 +60,9 @@ import type { SelectedLabel } from "@app/components/labels-selector";
import { useMediaQuery } from "@app/hooks/useMediaQuery";
import { cn } from "@app/lib/cn";
import { LauncherFilterPopover } from "./LauncherFilterPopover";
+import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
import { LauncherGroupList } from "./LauncherGroupList";
+import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
import { LauncherRefreshButton } from "./LauncherRefreshButton";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton";
@@ -66,6 +76,7 @@ type ResourceLauncherProps = {
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
+ scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -81,6 +92,7 @@ export default function ResourceLauncher({
activeViewId,
config,
savedConfig,
+ scale: initialScale,
groups,
groupsPagination
}: ResourceLauncherProps) {
@@ -100,6 +112,38 @@ export default function ResourceLauncher({
const isDesktop = useMediaQuery("(min-width: 768px)");
+ const scaleFilters = useMemo(
+ () => ({
+ query: config.query,
+ groupBy: config.groupBy,
+ siteIds: config.siteIds,
+ labelIds: config.labelIds,
+ sort_by: config.sortBy,
+ order: config.order
+ }),
+ [
+ config.groupBy,
+ config.labelIds,
+ config.order,
+ config.query,
+ config.siteIds,
+ config.sortBy
+ ]
+ );
+
+ const { data: scale = initialScale } = useQuery({
+ ...launcherQueries.scale(orgId, scaleFilters),
+ initialData: initialScale
+ });
+
+ const showGroupList = shouldShowLauncherGroupList(scale, config);
+ const showSearchFirstGate = shouldShowSearchFirstGate(scale, config);
+ const showFlatResourceList = shouldShowFlatResourceList(scale, config);
+ const effectiveConfig = useMemo(
+ () => getEffectiveLauncherConfig(scale, config),
+ [config, scale]
+ );
+
const configRef = useRef(config);
configRef.current = config;
const searchInputRef = useRef(config.query);
@@ -451,6 +495,9 @@ export default function ResourceLauncher({
{
if (!isDefaultView) {
@@ -502,14 +549,31 @@ export default function ResourceLauncher({
)}
-
+ {scale.mode === "compact" && !showSearchFirstGate ? (
+
+ {t("resourceLauncherCompactModeHint")}
+
+ ) : null}
+
+ {showSearchFirstGate ? (
+
+ ) : showGroupList ? (
+
+ ) : showFlatResourceList ? (
+
+ ) : null}
diff --git a/src/lib/launcherLocalStorage.ts b/src/lib/launcherLocalStorage.ts
index 6e1fb60a5..1d2b64219 100644
--- a/src/lib/launcherLocalStorage.ts
+++ b/src/lib/launcherLocalStorage.ts
@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
function groupOpenKey(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label"
+ groupBy: "site" | "label" | "none"
) {
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
}
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
export function readLauncherGroupOpenState(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label"
+ groupBy: "site" | "label" | "none"
): Record {
return readJson>(
groupOpenKey(orgId, viewId, groupBy),
@@ -75,7 +75,7 @@ export function readLauncherGroupOpenState(
export function readLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label",
+ groupBy: "site" | "label" | "none",
groupKey: string,
defaultOpen: boolean
): boolean {
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
export function writeLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label",
+ groupBy: "site" | "label" | "none",
groupKey: string,
isOpen: boolean
) {
diff --git a/src/lib/launcherScale.ts b/src/lib/launcherScale.ts
new file mode 100644
index 000000000..6b086bab9
--- /dev/null
+++ b/src/lib/launcherScale.ts
@@ -0,0 +1,95 @@
+import type {
+ LauncherScaleInfo,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
+
+export function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
+ return (
+ config.query.trim().length > 0 ||
+ config.siteIds.length > 0 ||
+ config.labelIds.length > 0
+ );
+}
+
+export function getEffectiveGroupBy(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): LauncherViewConfig["groupBy"] {
+ if (scale.mode === "compact") {
+ return "none";
+ }
+
+ return config.groupBy;
+}
+
+export function getEffectiveLauncherConfig(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): LauncherViewConfig {
+ const groupBy = getEffectiveGroupBy(scale, config);
+ if (groupBy === config.groupBy) {
+ return config;
+ }
+
+ return { ...config, groupBy };
+}
+
+export function shouldFetchLauncherGroups(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): boolean {
+ const groupBy = getEffectiveGroupBy(scale, config);
+
+ if (groupBy === "none") {
+ return false;
+ }
+
+ if (scale.mode === "full") {
+ return true;
+ }
+
+ return (
+ scale.capabilities.allowSiteGrouping &&
+ groupBy === "site" &&
+ config.siteIds.length > 0
+ );
+}
+
+export function shouldShowLauncherGroupList(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): boolean {
+ return shouldFetchLauncherGroups(scale, config);
+}
+
+export function shouldShowSearchFirstGate(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): boolean {
+ return (
+ scale.mode === "compact" &&
+ scale.capabilities.requireSearchOrFilter &&
+ !hasActiveLauncherFilters(config)
+ );
+}
+
+export function shouldShowFlatResourceList(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): boolean {
+ if (shouldShowSearchFirstGate(scale, config)) {
+ return false;
+ }
+
+ const groupBy = getEffectiveGroupBy(scale, config);
+
+ if (groupBy === "none") {
+ return true;
+ }
+
+ if (scale.mode === "full") {
+ return false;
+ }
+
+ return !shouldShowLauncherGroupList(scale, config);
+}
diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts
index 2c542c5fc..994ca2ff4 100644
--- a/src/lib/launcherServerData.ts
+++ b/src/lib/launcherServerData.ts
@@ -1,12 +1,15 @@
import { internal } from "@app/lib/api";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
+import { shouldFetchLauncherGroups } from "@app/lib/launcherScale";
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type {
LauncherGroup,
+ LauncherScaleInfo,
LauncherViewConfig,
LauncherViewRecord,
ListLauncherGroupsResponse,
+ ListLauncherScaleResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import { AxiosResponse } from "axios";
@@ -16,6 +19,7 @@ export type LauncherPageData = {
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
+ scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -45,6 +49,37 @@ export async function fetchLauncherPageData(
null
);
+ const scaleFilters = {
+ query: config.query,
+ groupBy: config.groupBy,
+ siteIds: config.siteIds,
+ labelIds: config.labelIds,
+ sort_by: config.sortBy,
+ order: config.order
+ };
+
+ let scale: LauncherScaleInfo = {
+ mode: "full",
+ resourceCount: 0,
+ siteGroupCount: 0,
+ labelGroupCount: 0,
+ capabilities: {
+ allowSiteGrouping: true,
+ allowLabelGrouping: true,
+ requireSearchOrFilter: false
+ }
+ };
+
+ try {
+ const sp = buildLauncherSearchParams(scaleFilters, 1);
+ sp.delete("page");
+ sp.delete("pageSize");
+ const scaleRes = await internal.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader);
+ scale = scaleRes.data.data.scale;
+ } catch (e) {}
+
const groupFilters = {
query: config.query,
groupBy: config.groupBy,
@@ -62,20 +97,23 @@ export async function fetchLauncherPageData(
pageSize: 20
};
- try {
- const sp = buildLauncherSearchParams(groupFilters, 1);
- const groupsRes = await internal.get<
- AxiosResponse
- >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
- groups = groupsRes.data.data.groups;
- groupsPagination = groupsRes.data.data.pagination;
- } catch (e) {}
+ if (shouldFetchLauncherGroups(scale, config)) {
+ try {
+ const sp = buildLauncherSearchParams(groupFilters, 1);
+ const groupsRes = await internal.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
+ groups = groupsRes.data.data.groups;
+ groupsPagination = groupsRes.data.data.pagination;
+ } catch (e) {}
+ }
return {
views,
activeViewId,
config,
savedConfig,
+ scale,
groups,
groupsPagination
};
diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts
index cfe8e26c4..c3b2da568 100644
--- a/src/lib/launcherUrlState.ts
+++ b/src/lib/launcherUrlState.ts
@@ -113,7 +113,7 @@ function parseConfigOverrides(
}
const groupBy = searchParams.get("groupBy");
- if (groupBy === "site" || groupBy === "label") {
+ if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
overrides.groupBy = groupBy;
}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 14fa1d3da..5ca6624d6 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -50,6 +50,7 @@ import type {
ListLauncherGroupsResponse,
ListLauncherLabelsResponse,
ListLauncherResourcesResponse,
+ ListLauncherScaleResponse,
ListLauncherSitesResponse,
ListLauncherViewsResponse,
LauncherListQuery,
@@ -1298,5 +1299,19 @@ export const launcherQueries = {
const nextPage = page + 1;
return page * pageSize < total ? nextPage : undefined;
}
+ }),
+ scale: (orgId: string, filters: LauncherQueryFilters) =>
+ queryOptions({
+ queryKey: ["ORG", orgId, "LAUNCHER", "SCALE", filters] as const,
+ queryFn: async ({ signal, meta }) => {
+ const sp = buildLauncherSearchParams(filters, 1);
+ sp.delete("page");
+ sp.delete("pageSize");
+ sp.delete("groupKey");
+ const res = await meta!.api.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
+ return res.data.data.scale;
+ }
})
};