mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-18 03:26:34 +02:00
add compact mode for large orgs
This commit is contained in:
@@ -3590,6 +3590,7 @@
|
|||||||
"resourceLauncherGroupBy": "Group By",
|
"resourceLauncherGroupBy": "Group By",
|
||||||
"resourceLauncherGroupBySite": "Site",
|
"resourceLauncherGroupBySite": "Site",
|
||||||
"resourceLauncherGroupByLabel": "Label",
|
"resourceLauncherGroupByLabel": "Label",
|
||||||
|
"resourceLauncherGroupByNone": "None",
|
||||||
"resourceLauncherLayout": "Layout",
|
"resourceLauncherLayout": "Layout",
|
||||||
"resourceLauncherLayoutGrid": "Grid",
|
"resourceLauncherLayoutGrid": "Grid",
|
||||||
"resourceLauncherLayoutList": "List",
|
"resourceLauncherLayoutList": "List",
|
||||||
@@ -3607,6 +3608,12 @@
|
|||||||
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
||||||
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
|
"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.",
|
"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",
|
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
||||||
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
||||||
"resourceLauncherViewNamePlaceholder": "View name",
|
"resourceLauncherViewNamePlaceholder": "View name",
|
||||||
|
|||||||
@@ -470,6 +470,12 @@ authenticated.get(
|
|||||||
launcher.listLauncherGroups
|
launcher.listLauncherGroups
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/launcher/scale",
|
||||||
|
verifyOrgAccess,
|
||||||
|
launcher.listLauncherScale
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/launcher/resources",
|
"/org/:orgId/launcher/resources",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./types";
|
export * from "./types";
|
||||||
export { listLauncherGroups } from "./listLauncherGroups";
|
export { listLauncherGroups } from "./listLauncherGroups";
|
||||||
|
export { listLauncherScale } from "./listLauncherScale";
|
||||||
export { listLauncherResources } from "./listLauncherResources";
|
export { listLauncherResources } from "./listLauncherResources";
|
||||||
export { listLauncherSites } from "./listLauncherSites";
|
export { listLauncherSites } from "./listLauncherSites";
|
||||||
export { listLauncherLabels } from "./listLauncherLabels";
|
export { listLauncherLabels } from "./listLauncherLabels";
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
formatSiteResourceAccess
|
formatSiteResourceAccess
|
||||||
} from "./formatLauncherAccess";
|
} from "./formatLauncherAccess";
|
||||||
import {
|
import {
|
||||||
|
LAUNCHER_FLAT_GROUP_KEY,
|
||||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
LAUNCHER_NO_SITE_GROUP_KEY,
|
||||||
LAUNCHER_UNLABELED_GROUP_KEY,
|
LAUNCHER_UNLABELED_GROUP_KEY,
|
||||||
type LauncherFilterListQuery,
|
type LauncherFilterListQuery,
|
||||||
@@ -1161,10 +1162,12 @@ export async function listLauncherResourcesForUser(
|
|||||||
let items = [...publicItems, ...siteItems];
|
let items = [...publicItems, ...siteItems];
|
||||||
items = filterResourcesBySearch(items, query.query);
|
items = filterResourcesBySearch(items, query.query);
|
||||||
|
|
||||||
if (query.groupBy === "label") {
|
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
|
||||||
items = filterResourcesByLabel(items, query.groupKey);
|
if (query.groupBy === "label") {
|
||||||
} else if (query.groupBy === "site") {
|
items = filterResourcesByLabel(items, query.groupKey);
|
||||||
items = filterResourcesBySite(items, query.groupKey);
|
} else if (query.groupBy === "site") {
|
||||||
|
items = filterResourcesBySite(items, query.groupKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items = sortLauncherResources(items, query.order);
|
items = sortLauncherResources(items, query.order);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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_UNLABELED_GROUP_KEY = "unlabeled";
|
||||||
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
||||||
|
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
|
||||||
|
|
||||||
export const launcherViewConfigSchema = z.object({
|
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"),
|
layout: z.enum(["grid", "list"]).default("grid"),
|
||||||
sortBy: z.literal("name").default("name"),
|
sortBy: z.literal("name").default("name"),
|
||||||
order: z.enum(["asc", "desc"]).default("asc"),
|
order: z.enum(["asc", "desc"]).default("asc"),
|
||||||
@@ -138,7 +139,7 @@ export const launcherListQuerySchema = z.strictObject({
|
|||||||
.default(20),
|
.default(20),
|
||||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||||
query: z.string().optional().default(""),
|
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(),
|
groupKey: z.string().optional(),
|
||||||
siteIds: z.string().optional(),
|
siteIds: z.string().optional(),
|
||||||
labelIds: z.string().optional(),
|
labelIds: z.string().optional(),
|
||||||
@@ -163,3 +164,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
|||||||
export type LauncherViewSelection =
|
export type LauncherViewSelection =
|
||||||
| { type: "default" }
|
| { type: "default" }
|
||||||
| { type: "saved"; viewId: number };
|
| { 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}
|
activeViewId={launcherData.activeViewId}
|
||||||
config={launcherData.config}
|
config={launcherData.config}
|
||||||
savedConfig={launcherData.savedConfig}
|
savedConfig={launcherData.savedConfig}
|
||||||
|
scale={launcherData.scale}
|
||||||
groups={launcherData.groups}
|
groups={launcherData.groups}
|
||||||
groupsPagination={launcherData.groupsPagination}
|
groupsPagination={launcherData.groupsPagination}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export type MultiSitesSelectorProps = {
|
|||||||
onSelectionChange: (sites: Selectedsite[]) => void;
|
onSelectionChange: (sites: Selectedsite[]) => void;
|
||||||
filterTypes?: string[];
|
filterTypes?: string[];
|
||||||
scope?: "org" | "launcher";
|
scope?: "org" | "launcher";
|
||||||
|
onClear?: () => void;
|
||||||
|
showClear?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatMultiSitesSelectorLabel(
|
export function formatMultiSitesSelectorLabel(
|
||||||
@@ -42,7 +44,9 @@ export function MultiSitesSelector({
|
|||||||
selectedSites,
|
selectedSites,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
filterTypes,
|
filterTypes,
|
||||||
scope = "org"
|
scope = "org",
|
||||||
|
onClear,
|
||||||
|
showClear = false
|
||||||
}: MultiSitesSelectorProps) {
|
}: MultiSitesSelectorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||||
@@ -107,6 +111,14 @@ export function MultiSitesSelector({
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
{showClear && onClear && (
|
||||||
|
<CommandItem
|
||||||
|
onSelect={onClear}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("accessFilterClear")}
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
{sitesShown.map((site) => (
|
{sitesShown.map((site) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={site.siteId}
|
key={site.siteId}
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ export function LauncherFilterPopover({
|
|||||||
selectedSites={resolvedSelectedSites}
|
selectedSites={resolvedSelectedSites}
|
||||||
onSelectionChange={onSitesChange}
|
onSelectionChange={onSitesChange}
|
||||||
scope="launcher"
|
scope="launcher"
|
||||||
|
showClear={selectedSites.length > 0}
|
||||||
|
onClear={() => {
|
||||||
|
onSitesChange([]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -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 } =
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
...launcherQueries.groups(orgId, groupFilters),
|
...launcherQueries.groups(orgId, groupFilters),
|
||||||
initialData: {
|
...(initialGroups.length > 0
|
||||||
pages: [
|
? {
|
||||||
{
|
initialData: {
|
||||||
groups: initialGroups,
|
pages: [
|
||||||
pagination: groupsPagination
|
{
|
||||||
}
|
groups: initialGroups,
|
||||||
],
|
pagination: groupsPagination
|
||||||
pageParams: [1]
|
}
|
||||||
},
|
],
|
||||||
refetchOnMount: false
|
pageParams: [1]
|
||||||
|
},
|
||||||
|
refetchOnMount: false as const
|
||||||
|
}
|
||||||
|
: {})
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
||||||
|
|||||||
@@ -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
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
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 { useTranslations } from "next-intl";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
type LauncherSettingsMenuProps = {
|
type LauncherSettingsMenuProps = {
|
||||||
config: LauncherViewConfig;
|
config: LauncherViewConfig;
|
||||||
isDefaultView: boolean;
|
isDefaultView: boolean;
|
||||||
|
capabilities: LauncherScaleCapabilities;
|
||||||
|
isCompactMode: boolean;
|
||||||
|
selectedGroupBy: LauncherViewConfig["groupBy"];
|
||||||
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
|
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
|
||||||
onDeleteView: () => void;
|
onDeleteView: () => void;
|
||||||
};
|
};
|
||||||
@@ -29,6 +35,9 @@ type LauncherSettingsMenuProps = {
|
|||||||
export function LauncherSettingsMenu({
|
export function LauncherSettingsMenu({
|
||||||
config,
|
config,
|
||||||
isDefaultView,
|
isDefaultView,
|
||||||
|
capabilities,
|
||||||
|
isCompactMode,
|
||||||
|
selectedGroupBy,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onDeleteView
|
onDeleteView
|
||||||
}: LauncherSettingsMenuProps) {
|
}: LauncherSettingsMenuProps) {
|
||||||
@@ -51,7 +60,7 @@ export function LauncherSettingsMenu({
|
|||||||
{t("resourceLauncherGroupBy")}
|
{t("resourceLauncherGroupBy")}
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
value={config.groupBy}
|
value={selectedGroupBy}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
onConfigChange({
|
onConfigChange({
|
||||||
groupBy:
|
groupBy:
|
||||||
@@ -63,14 +72,44 @@ export function LauncherSettingsMenu({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="site">
|
<SelectItem value="none">
|
||||||
|
{t("resourceLauncherGroupByNone")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="site"
|
||||||
|
disabled={
|
||||||
|
isCompactMode ||
|
||||||
|
!capabilities.allowSiteGrouping
|
||||||
|
}
|
||||||
|
>
|
||||||
{t("resourceLauncherGroupBySite")}
|
{t("resourceLauncherGroupBySite")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="label">
|
<SelectItem
|
||||||
|
value="label"
|
||||||
|
disabled={
|
||||||
|
isCompactMode ||
|
||||||
|
!capabilities.allowLabelGrouping
|
||||||
|
}
|
||||||
|
>
|
||||||
{t("resourceLauncherGroupByLabel")}
|
{t("resourceLauncherGroupByLabel")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -29,12 +29,20 @@ import {
|
|||||||
} from "@app/lib/launcherUrlState";
|
} from "@app/lib/launcherUrlState";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import {
|
||||||
|
getEffectiveLauncherConfig,
|
||||||
|
shouldShowFlatResourceList,
|
||||||
|
shouldShowLauncherGroupList,
|
||||||
|
shouldShowSearchFirstGate
|
||||||
|
} from "@app/lib/launcherScale";
|
||||||
|
import { launcherQueries } from "@app/lib/queries";
|
||||||
import type {
|
import type {
|
||||||
LauncherGroup,
|
LauncherGroup,
|
||||||
|
LauncherScaleInfo,
|
||||||
LauncherViewConfig,
|
LauncherViewConfig,
|
||||||
LauncherViewRecord
|
LauncherViewRecord
|
||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -52,7 +60,9 @@ import type { SelectedLabel } from "@app/components/labels-selector";
|
|||||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { LauncherFilterPopover } from "./LauncherFilterPopover";
|
import { LauncherFilterPopover } from "./LauncherFilterPopover";
|
||||||
|
import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
|
||||||
import { LauncherGroupList } from "./LauncherGroupList";
|
import { LauncherGroupList } from "./LauncherGroupList";
|
||||||
|
import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
|
||||||
import { LauncherRefreshButton } from "./LauncherRefreshButton";
|
import { LauncherRefreshButton } from "./LauncherRefreshButton";
|
||||||
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
||||||
import { LauncherSortButton } from "./LauncherSortButton";
|
import { LauncherSortButton } from "./LauncherSortButton";
|
||||||
@@ -66,6 +76,7 @@ type ResourceLauncherProps = {
|
|||||||
activeViewId: LauncherActiveViewId;
|
activeViewId: LauncherActiveViewId;
|
||||||
config: LauncherViewConfig;
|
config: LauncherViewConfig;
|
||||||
savedConfig: LauncherViewConfig;
|
savedConfig: LauncherViewConfig;
|
||||||
|
scale: LauncherScaleInfo;
|
||||||
groups: LauncherGroup[];
|
groups: LauncherGroup[];
|
||||||
groupsPagination: {
|
groupsPagination: {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -81,6 +92,7 @@ export default function ResourceLauncher({
|
|||||||
activeViewId,
|
activeViewId,
|
||||||
config,
|
config,
|
||||||
savedConfig,
|
savedConfig,
|
||||||
|
scale: initialScale,
|
||||||
groups,
|
groups,
|
||||||
groupsPagination
|
groupsPagination
|
||||||
}: ResourceLauncherProps) {
|
}: ResourceLauncherProps) {
|
||||||
@@ -100,6 +112,38 @@ export default function ResourceLauncher({
|
|||||||
|
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
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);
|
const configRef = useRef(config);
|
||||||
configRef.current = config;
|
configRef.current = config;
|
||||||
const searchInputRef = useRef(config.query);
|
const searchInputRef = useRef(config.query);
|
||||||
@@ -451,6 +495,9 @@ export default function ResourceLauncher({
|
|||||||
<LauncherSettingsMenu
|
<LauncherSettingsMenu
|
||||||
config={config}
|
config={config}
|
||||||
isDefaultView={isDefaultView}
|
isDefaultView={isDefaultView}
|
||||||
|
capabilities={scale.capabilities}
|
||||||
|
isCompactMode={scale.mode === "compact"}
|
||||||
|
selectedGroupBy={effectiveConfig.groupBy}
|
||||||
onConfigChange={applyConfigPatch}
|
onConfigChange={applyConfigPatch}
|
||||||
onDeleteView={() => {
|
onDeleteView={() => {
|
||||||
if (!isDefaultView) {
|
if (!isDefaultView) {
|
||||||
@@ -502,14 +549,31 @@ export default function ResourceLauncher({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LauncherGroupList
|
{scale.mode === "compact" && !showSearchFirstGate ? (
|
||||||
orgId={orgId}
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
activeViewId={activeViewId}
|
{t("resourceLauncherCompactModeHint")}
|
||||||
config={config}
|
</p>
|
||||||
initialGroups={groups}
|
) : null}
|
||||||
groupsPagination={groupsPagination}
|
|
||||||
onClearFilters={handleClearFilters}
|
{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}>
|
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
|
|||||||
function groupOpenKey(
|
function groupOpenKey(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
viewId: LauncherActiveViewId,
|
viewId: LauncherActiveViewId,
|
||||||
groupBy: "site" | "label"
|
groupBy: "site" | "label" | "none"
|
||||||
) {
|
) {
|
||||||
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
|
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
|
|||||||
export function readLauncherGroupOpenState(
|
export function readLauncherGroupOpenState(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
viewId: LauncherActiveViewId,
|
viewId: LauncherActiveViewId,
|
||||||
groupBy: "site" | "label"
|
groupBy: "site" | "label" | "none"
|
||||||
): Record<string, boolean> {
|
): Record<string, boolean> {
|
||||||
return readJson<Record<string, boolean>>(
|
return readJson<Record<string, boolean>>(
|
||||||
groupOpenKey(orgId, viewId, groupBy),
|
groupOpenKey(orgId, viewId, groupBy),
|
||||||
@@ -75,7 +75,7 @@ export function readLauncherGroupOpenState(
|
|||||||
export function readLauncherGroupOpen(
|
export function readLauncherGroupOpen(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
viewId: LauncherActiveViewId,
|
viewId: LauncherActiveViewId,
|
||||||
groupBy: "site" | "label",
|
groupBy: "site" | "label" | "none",
|
||||||
groupKey: string,
|
groupKey: string,
|
||||||
defaultOpen: boolean
|
defaultOpen: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
|
|||||||
export function writeLauncherGroupOpen(
|
export function writeLauncherGroupOpen(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
viewId: LauncherActiveViewId,
|
viewId: LauncherActiveViewId,
|
||||||
groupBy: "site" | "label",
|
groupBy: "site" | "label" | "none",
|
||||||
groupKey: string,
|
groupKey: string,
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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 { internal } from "@app/lib/api";
|
||||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||||
|
import { shouldFetchLauncherGroups } from "@app/lib/launcherScale";
|
||||||
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
|
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
|
||||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||||
import type {
|
import type {
|
||||||
LauncherGroup,
|
LauncherGroup,
|
||||||
|
LauncherScaleInfo,
|
||||||
LauncherViewConfig,
|
LauncherViewConfig,
|
||||||
LauncherViewRecord,
|
LauncherViewRecord,
|
||||||
ListLauncherGroupsResponse,
|
ListLauncherGroupsResponse,
|
||||||
|
ListLauncherScaleResponse,
|
||||||
ListLauncherViewsResponse
|
ListLauncherViewsResponse
|
||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
@@ -16,6 +19,7 @@ export type LauncherPageData = {
|
|||||||
activeViewId: LauncherActiveViewId;
|
activeViewId: LauncherActiveViewId;
|
||||||
config: LauncherViewConfig;
|
config: LauncherViewConfig;
|
||||||
savedConfig: LauncherViewConfig;
|
savedConfig: LauncherViewConfig;
|
||||||
|
scale: LauncherScaleInfo;
|
||||||
groups: LauncherGroup[];
|
groups: LauncherGroup[];
|
||||||
groupsPagination: {
|
groupsPagination: {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -45,6 +49,37 @@ export async function fetchLauncherPageData(
|
|||||||
null
|
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 = {
|
const groupFilters = {
|
||||||
query: config.query,
|
query: config.query,
|
||||||
groupBy: config.groupBy,
|
groupBy: config.groupBy,
|
||||||
@@ -62,20 +97,23 @@ export async function fetchLauncherPageData(
|
|||||||
pageSize: 20
|
pageSize: 20
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
if (shouldFetchLauncherGroups(scale, config)) {
|
||||||
const sp = buildLauncherSearchParams(groupFilters, 1);
|
try {
|
||||||
const groupsRes = await internal.get<
|
const sp = buildLauncherSearchParams(groupFilters, 1);
|
||||||
AxiosResponse<ListLauncherGroupsResponse>
|
const groupsRes = await internal.get<
|
||||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
|
AxiosResponse<ListLauncherGroupsResponse>
|
||||||
groups = groupsRes.data.data.groups;
|
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
|
||||||
groupsPagination = groupsRes.data.data.pagination;
|
groups = groupsRes.data.data.groups;
|
||||||
} catch (e) {}
|
groupsPagination = groupsRes.data.data.pagination;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
views,
|
views,
|
||||||
activeViewId,
|
activeViewId,
|
||||||
config,
|
config,
|
||||||
savedConfig,
|
savedConfig,
|
||||||
|
scale,
|
||||||
groups,
|
groups,
|
||||||
groupsPagination
|
groupsPagination
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ function parseConfigOverrides(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupBy = searchParams.get("groupBy");
|
const groupBy = searchParams.get("groupBy");
|
||||||
if (groupBy === "site" || groupBy === "label") {
|
if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
|
||||||
overrides.groupBy = groupBy;
|
overrides.groupBy = groupBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import type {
|
|||||||
ListLauncherGroupsResponse,
|
ListLauncherGroupsResponse,
|
||||||
ListLauncherLabelsResponse,
|
ListLauncherLabelsResponse,
|
||||||
ListLauncherResourcesResponse,
|
ListLauncherResourcesResponse,
|
||||||
|
ListLauncherScaleResponse,
|
||||||
ListLauncherSitesResponse,
|
ListLauncherSitesResponse,
|
||||||
ListLauncherViewsResponse,
|
ListLauncherViewsResponse,
|
||||||
LauncherListQuery,
|
LauncherListQuery,
|
||||||
@@ -1298,5 +1299,19 @@ export const launcherQueries = {
|
|||||||
const nextPage = page + 1;
|
const nextPage = page + 1;
|
||||||
return page * pageSize < total ? nextPage : undefined;
|
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