mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-07 04:39:52 +00:00
add compact mode for large orgs
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -470,6 +470,12 @@ authenticated.get(
|
||||
launcher.listLauncherGroups
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/scale",
|
||||
verifyOrgAccess,
|
||||
launcher.listLauncherScale
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/resources",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
124
server/routers/launcher/launcherScale.ts
Normal file
124
server/routers/launcher/launcherScale.ts
Normal file
@@ -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<LauncherScaleInfo> {
|
||||
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<LauncherScaleInfo> {
|
||||
const cacheKey = launcherScaleCacheKey(orgId, userId, userRoleIds, query);
|
||||
const cached = await cache.get<LauncherScaleInfo>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const result = await getLauncherScaleForUserUncached(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds,
|
||||
query
|
||||
);
|
||||
await cache.set(cacheKey, result, LAUNCHER_SCALE_TTL_SEC);
|
||||
return result;
|
||||
}
|
||||
60
server/routers/launcher/listLauncherScale.ts
Normal file
60
server/routers/launcher/listLauncherScale.ts
Normal file
@@ -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<any> {
|
||||
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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof launcherScaleQuerySchema>;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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({
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showClear && onClear && (
|
||||
<CommandItem
|
||||
onSelect={onClear}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{sitesShown.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
|
||||
@@ -139,6 +139,10 @@ export function LauncherFilterPopover({
|
||||
selectedSites={resolvedSelectedSites}
|
||||
onSelectionChange={onSitesChange}
|
||||
scope="launcher"
|
||||
showClear={selectedSites.length > 0}
|
||||
onClear={() => {
|
||||
onSitesChange([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
125
src/components/resource-launcher/LauncherFlatResourceList.tsx
Normal file
125
src/components/resource-launcher/LauncherFlatResourceList.tsx
Normal file
@@ -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<HTMLDivElement | null>(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 (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LauncherEmptyState
|
||||
variant={
|
||||
hasActiveLauncherFilters(config) ? "noResults" : "empty"
|
||||
}
|
||||
layout={config.layout}
|
||||
query={config.query}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{config.layout === "grid" ? (
|
||||
<LauncherResourceGrid
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
) : (
|
||||
<LauncherResourceList
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
)}
|
||||
<div ref={loadMoreRef} className="h-4" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) ?? [];
|
||||
|
||||
86
src/components/resource-launcher/LauncherSearchFirstGate.tsx
Normal file
86
src/components/resource-launcher/LauncherSearchFirstGate.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
|
||||
aria-hidden
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-2/5 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GhostResourceList() {
|
||||
return (
|
||||
<div className="flex w-full flex-col" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-4 py-3",
|
||||
index < 2 && "border-b border-border/60"
|
||||
)}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-1/4 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherSearchFirstGate({
|
||||
layout
|
||||
}: LauncherSearchFirstGateProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-50">
|
||||
{layout === "grid" ? (
|
||||
<GhostResourceGrid />
|
||||
) : (
|
||||
<GhostResourceList />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Search className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("resourceLauncherSearchFirstTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("resourceLauncherSearchFirstDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<LauncherViewConfig>) => 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")}
|
||||
</p>
|
||||
<Select
|
||||
value={config.groupBy}
|
||||
value={selectedGroupBy}
|
||||
onValueChange={(value) =>
|
||||
onConfigChange({
|
||||
groupBy:
|
||||
@@ -63,14 +72,44 @@ export function LauncherSettingsMenu({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="site">
|
||||
<SelectItem value="none">
|
||||
{t("resourceLauncherGroupByNone")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="site"
|
||||
disabled={
|
||||
isCompactMode ||
|
||||
!capabilities.allowSiteGrouping
|
||||
}
|
||||
>
|
||||
{t("resourceLauncherGroupBySite")}
|
||||
</SelectItem>
|
||||
<SelectItem value="label">
|
||||
<SelectItem
|
||||
value="label"
|
||||
disabled={
|
||||
isCompactMode ||
|
||||
!capabilities.allowLabelGrouping
|
||||
}
|
||||
>
|
||||
{t("resourceLauncherGroupByLabel")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isCompactMode ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("resourceLauncherCompactGroupingLocked")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isCompactMode && !capabilities.allowSiteGrouping ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("resourceLauncherSiteGroupingDisabled")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isCompactMode && !capabilities.allowLabelGrouping ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("resourceLauncherLabelGroupingDisabled")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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({
|
||||
<LauncherSettingsMenu
|
||||
config={config}
|
||||
isDefaultView={isDefaultView}
|
||||
capabilities={scale.capabilities}
|
||||
isCompactMode={scale.mode === "compact"}
|
||||
selectedGroupBy={effectiveConfig.groupBy}
|
||||
onConfigChange={applyConfigPatch}
|
||||
onDeleteView={() => {
|
||||
if (!isDefaultView) {
|
||||
@@ -502,14 +549,31 @@ export default function ResourceLauncher({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LauncherGroupList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={config}
|
||||
initialGroups={groups}
|
||||
groupsPagination={groupsPagination}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
{scale.mode === "compact" && !showSearchFirstGate ? (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{t("resourceLauncherCompactModeHint")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{showSearchFirstGate ? (
|
||||
<LauncherSearchFirstGate layout={config.layout} />
|
||||
) : showGroupList ? (
|
||||
<LauncherGroupList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={effectiveConfig}
|
||||
initialGroups={groups}
|
||||
groupsPagination={groupsPagination}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
) : showFlatResourceList ? (
|
||||
<LauncherFlatResourceList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={effectiveConfig}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
<CredenzaContent>
|
||||
|
||||
@@ -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<string, boolean> {
|
||||
return readJson<Record<string, boolean>>(
|
||||
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
|
||||
) {
|
||||
|
||||
95
src/lib/launcherScale.ts
Normal file
95
src/lib/launcherScale.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<ListLauncherScaleResponse>
|
||||
>(`/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<ListLauncherGroupsResponse>
|
||||
>(`/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<ListLauncherGroupsResponse>
|
||||
>(`/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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ListLauncherScaleResponse>
|
||||
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
|
||||
return res.data.data.scale;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user