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