add server side fetching

This commit is contained in:
miloschwartz
2026-06-30 21:15:03 -04:00
parent f0efa4203b
commit fed4ec42c4
8 changed files with 411 additions and 313 deletions

View File

@@ -0,0 +1,9 @@
import { Loader2 } from "lucide-react";
export default function OrgPageLoading() {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}

View File

@@ -2,8 +2,8 @@ import { Layout } from "@app/components/Layout";
import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher"; import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { fetchLauncherPageData } from "@app/lib/launcherServerData";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { pullEnv } from "@app/lib/pullEnv";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
@@ -13,8 +13,11 @@ import { cache } from "react";
type OrgPageProps = { type OrgPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
}; };
export const dynamic = "force-dynamic";
export default async function OrgPage(props: OrgPageProps) { export default async function OrgPage(props: OrgPageProps) {
const params = await props.params; const params = await props.params;
const orgId = params.orgId; const orgId = params.orgId;
@@ -55,6 +58,15 @@ export default async function OrgPage(props: OrgPageProps) {
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
const searchParams = new URLSearchParams(await props.searchParams);
const launcherData = overview
? await fetchLauncherPageData(
orgId,
searchParams,
await authCookieHeader()
)
: null;
return ( return (
<UserProvider user={user}> <UserProvider user={user}>
<Layout <Layout
@@ -65,8 +77,18 @@ export default async function OrgPage(props: OrgPageProps) {
launcherMode launcherMode
showViewAsAdmin={isAdminOrOwner} showViewAsAdmin={isAdminOrOwner}
> >
{overview ? ( {overview && launcherData ? (
<ResourceLauncher orgId={orgId} isAdmin={isAdminOrOwner} /> <ResourceLauncher
orgId={orgId}
isAdmin={isAdminOrOwner}
views={launcherData.views}
activeViewId={launcherData.activeViewId}
config={launcherData.config}
savedConfig={launcherData.savedConfig}
groups={launcherData.groups}
groupsPagination={launcherData.groupsPagination}
resourcesByGroupKey={launcherData.resourcesByGroupKey}
/>
) : null} ) : null}
</Layout> </Layout>
</UserProvider> </UserProvider>

View File

@@ -1,159 +1,80 @@
"use client"; "use client";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { readLauncherGroupOpen } from "@app/lib/launcherLocalStorage"; import type { LauncherGroupResources } from "@app/lib/launcherServerData";
import { launcherQueries } from "@app/lib/queries"; import { launcherQueries } from "@app/lib/queries";
import type { LauncherViewConfig } from "@server/routers/launcher/types"; import type {
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; LauncherGroup,
LauncherViewConfig
} from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef } from "react";
import { LauncherGroupSection } from "./LauncherGroupSection"; import { LauncherGroupSection } from "./LauncherGroupSection";
type LauncherGroupListProps = { type LauncherGroupListProps = {
orgId: string; orgId: string;
activeViewId: LauncherActiveViewId; activeViewId: LauncherActiveViewId;
config: LauncherViewConfig; config: LauncherViewConfig;
searchQuery: string; initialGroups: LauncherGroup[];
}; groupsPagination: {
total: number;
function buildResourceFilters( page: number;
config: LauncherViewConfig, pageSize: number;
searchQuery: string,
groupKey: string
) {
return {
query: searchQuery,
groupBy: config.groupBy,
groupKey,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order,
pageSize: 20
}; };
} resourcesByGroupKey: Record<string, LauncherGroupResources>;
};
export function LauncherGroupList({ export function LauncherGroupList({
orgId, orgId,
activeViewId, activeViewId,
config, config,
searchQuery initialGroups,
groupsPagination,
resourcesByGroupKey
}: LauncherGroupListProps) { }: LauncherGroupListProps) {
const queryClient = useQueryClient();
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(null);
const [isPrefetching, setIsPrefetching] = useState(false);
const prefetchBatchKeyRef = useRef<string | null>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = const groupFilters = useMemo(
() => ({
query: config.query,
groupBy: config.groupBy,
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 } =
useInfiniteQuery({ useInfiniteQuery({
...launcherQueries.groups(orgId, { ...launcherQueries.groups(orgId, groupFilters),
query: searchQuery, initialData: {
groupBy: config.groupBy, pages: [
siteIds: config.siteIds, {
labelIds: config.labelIds, groups: initialGroups,
sort_by: config.sortBy, pagination: groupsPagination
order: config.order, }
pageSize: 20 ],
}) pageParams: [1]
},
refetchOnMount: false
}); });
const groups = data?.pages.flatMap((page) => page.groups) ?? []; const groups = data?.pages.flatMap((page) => page.groups) ?? [];
const batchKey = useMemo(
() =>
JSON.stringify({
activeViewId,
searchQuery,
groupBy: config.groupBy,
siteIds: config.siteIds,
labelIds: config.labelIds,
sortBy: config.sortBy,
order: config.order
}),
[
activeViewId,
config.groupBy,
config.labelIds,
config.order,
config.siteIds,
config.sortBy,
searchQuery
]
);
const openGroupKeys = useMemo(
() =>
groups
.filter((group) =>
readLauncherGroupOpen(
orgId,
activeViewId,
config.groupBy,
group.groupKey,
true
)
)
.map((group) => group.groupKey),
[activeViewId, config.groupBy, groups, orgId]
);
useEffect(() => {
if (isLoading) {
return;
}
if (openGroupKeys.length === 0) {
prefetchBatchKeyRef.current = batchKey;
setIsPrefetching(false);
return;
}
if (prefetchBatchKeyRef.current === batchKey) {
return;
}
let cancelled = false;
setIsPrefetching(true);
void Promise.all(
openGroupKeys.map((groupKey) =>
queryClient.prefetchInfiniteQuery(
launcherQueries.resources(
orgId,
buildResourceFilters(config, searchQuery, groupKey)
)
)
)
).finally(() => {
if (!cancelled) {
prefetchBatchKeyRef.current = batchKey;
setIsPrefetching(false);
}
});
return () => {
cancelled = true;
};
}, [
batchKey,
config,
isLoading,
openGroupKeys,
orgId,
queryClient,
searchQuery
]);
const isBatchPending = prefetchBatchKeyRef.current !== batchKey;
const isBodyLoading =
isLoading ||
(isBatchPending &&
openGroupKeys.length > 0 &&
(isPrefetching || !isLoading));
useEffect(() => { useEffect(() => {
const node = loadMoreRef.current; const node = loadMoreRef.current;
if (!node || !hasNextPage || isBodyLoading) { if (!node || !hasNextPage) {
return; return;
} }
@@ -168,28 +89,25 @@ export function LauncherGroupList({
observer.observe(node); observer.observe(node);
return () => observer.disconnect(); return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isBodyLoading, isFetchingNextPage]); }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isBodyLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return ( return (
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{groups.map((group) => ( {groups.map((group) => {
<LauncherGroupSection const groupResources = resourcesByGroupKey[group.groupKey];
key={group.groupKey}
orgId={orgId} return (
activeViewId={activeViewId} <LauncherGroupSection
group={group} key={group.groupKey}
config={config} orgId={orgId}
searchQuery={searchQuery} activeViewId={activeViewId}
/> group={group}
))} config={config}
initialResources={groupResources?.resources}
initialResourcesPagination={groupResources?.pagination}
/>
);
})}
<div ref={loadMoreRef} className="h-4" /> <div ref={loadMoreRef} className="h-4" />
{isFetchingNextPage ? ( {isFetchingNextPage ? (
<div className="flex justify-center py-2"> <div className="flex justify-center py-2">

View File

@@ -13,6 +13,7 @@ import {
import { launcherQueries } from "@app/lib/queries"; import { launcherQueries } from "@app/lib/queries";
import type { import type {
LauncherGroup, LauncherGroup,
LauncherResource,
LauncherViewConfig LauncherViewConfig
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
@@ -28,7 +29,12 @@ type LauncherGroupSectionProps = {
activeViewId: LauncherActiveViewId; activeViewId: LauncherActiveViewId;
group: LauncherGroup; group: LauncherGroup;
config: LauncherViewConfig; config: LauncherViewConfig;
searchQuery: string; initialResources?: LauncherResource[];
initialResourcesPagination?: {
total: number;
page: number;
pageSize: number;
};
defaultOpen?: boolean; defaultOpen?: boolean;
}; };
@@ -37,7 +43,8 @@ export function LauncherGroupSection({
activeViewId, activeViewId,
group, group,
config, config,
searchQuery, initialResources,
initialResourcesPagination,
defaultOpen = true defaultOpen = true
}: LauncherGroupSectionProps) { }: LauncherGroupSectionProps) {
const t = useTranslations(); const t = useTranslations();
@@ -75,10 +82,12 @@ export function LauncherGroupSection({
); );
}; };
const hasInitialResources = initialResources !== undefined;
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
useInfiniteQuery({ useInfiniteQuery({
...launcherQueries.resources(orgId, { ...launcherQueries.resources(orgId, {
query: searchQuery, query: config.query,
groupBy: config.groupBy, groupBy: config.groupBy,
groupKey: group.groupKey, groupKey: group.groupKey,
siteIds: config.siteIds, siteIds: config.siteIds,
@@ -87,14 +96,33 @@ export function LauncherGroupSection({
order: config.order, order: config.order,
pageSize: 20 pageSize: 20
}), }),
enabled: isOpen enabled: isOpen,
refetchOnMount: false,
...(hasInitialResources
? {
initialData: {
pages: [
{
resources: initialResources,
pagination: initialResourcesPagination ?? {
total: initialResources.length,
page: 1,
pageSize: 20
}
}
],
pageParams: [1]
}
}
: {})
}); });
const resources = data?.pages.flatMap((page) => page.resources) ?? []; const resources = data?.pages.flatMap((page) => page.resources) ?? [];
const showInitialLoader = isLoading && resources.length === 0;
useEffect(() => { useEffect(() => {
const node = loadMoreRef.current; const node = loadMoreRef.current;
if (!node || !hasNextPage) { if (!node || !hasNextPage || !isOpen) {
return; return;
} }
@@ -109,7 +137,7 @@ export function LauncherGroupSection({
observer.observe(node); observer.observe(node);
return () => observer.disconnect(); return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]); }, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]);
const groupTitle = const groupTitle =
group.groupKey === "unlabeled" group.groupKey === "unlabeled"
@@ -129,7 +157,7 @@ export function LauncherGroupSection({
/> />
<CollapsibleContent className="w-full"> <CollapsibleContent className="w-full">
{isLoading ? ( {showInitialLoader ? (
<div className="flex items-center justify-center py-10 text-muted-foreground"> <div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="size-5 animate-spin" /> <Loader2 className="size-5 animate-spin" />
</div> </div>

View File

@@ -14,30 +14,31 @@ import { CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label"; import { Label } from "@app/components/ui/label";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { import {
readLauncherLastView, readLauncherLastView,
writeLauncherLastView, writeLauncherLastView,
type LauncherActiveViewId type LauncherActiveViewId
} from "@app/lib/launcherLocalStorage"; } from "@app/lib/launcherLocalStorage";
import type { LauncherGroupResources } from "@app/lib/launcherServerData";
import { import {
buildLauncherPath, buildLauncherPath,
getLauncherUrlBaseConfig, getLauncherUrlBaseConfig,
isLauncherConfigEqual, isLauncherConfigEqual,
resolveLauncherStateFromUrl, parseLauncherUrlState,
serializeLauncherUrlState serializeLauncherUrlState
} from "@app/lib/launcherUrlState"; } from "@app/lib/launcherUrlState";
import { launcherQueries } from "@app/lib/queries";
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 { import type {
defaultLauncherViewConfig, LauncherGroup,
type LauncherViewConfig, LauncherViewConfig,
type LauncherViewRecord LauncherViewRecord
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation } 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, useSearchParams } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import type { Selectedsite } from "@app/components/site-selector"; import type { Selectedsite } from "@app/components/site-selector";
@@ -52,33 +53,39 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type ResourceLauncherProps = { type ResourceLauncherProps = {
orgId: string; orgId: string;
isAdmin: boolean; isAdmin: boolean;
views: LauncherViewRecord[];
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
groups: LauncherGroup[];
groupsPagination: {
total: number;
page: number;
pageSize: number;
};
resourcesByGroupKey: Record<string, LauncherGroupResources>;
}; };
export default function ResourceLauncher({ export default function ResourceLauncher({
orgId, orgId,
isAdmin isAdmin,
views,
activeViewId,
config,
savedConfig,
groups,
groupsPagination,
resourcesByGroupKey
}: ResourceLauncherProps) { }: ResourceLauncherProps) {
const t = useTranslations(); const t = useTranslations();
const { toast } = useToast(); const { toast } = useToast();
const { env } = useEnvContext(); const { env } = useEnvContext();
const queryClient = useQueryClient();
const api = createApiClient({ env }); const api = createApiClient({ env });
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const { navigate, isNavigating, searchParams } = useNavigationContext();
const [activeViewId, setActiveViewId] =
useState<LauncherActiveViewId>("default");
const hasRestoredLastView = useRef(false); const hasRestoredLastView = useRef(false);
const isApplyingUrlRef = useRef(false);
const [config, setConfig] = useState<LauncherViewConfig>( const [searchInput, setSearchInput] = useState(config.query);
defaultLauncherViewConfig
);
const [savedConfig, setSavedConfig] = useState<LauncherViewConfig>(
defaultLauncherViewConfig
);
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [newViewName, setNewViewName] = useState(""); const [newViewName, setNewViewName] = useState("");
const [saveOrgWide, setSaveOrgWide] = useState(false); const [saveOrgWide, setSaveOrgWide] = useState(false);
@@ -90,68 +97,66 @@ export default function ResourceLauncher({
const activeViewIdRef = useRef(activeViewId); const activeViewIdRef = useRef(activeViewId);
activeViewIdRef.current = activeViewId; activeViewIdRef.current = activeViewId;
const { data: views = [], isLoading: viewsLoading } = useQuery( useEffect(() => {
launcherQueries.views(orgId) setSearchInput(config.query);
); }, [config.query]);
const syncUrl = useCallback( useEffect(() => {
if (hasRestoredLastView.current) {
return;
}
hasRestoredLastView.current = true;
const parsed = parseLauncherUrlState(searchParams);
if (parsed.hasAnyLauncherParams) {
return;
}
const lastView = readLauncherLastView(orgId);
if (lastView === null || lastView === activeViewId) {
return;
}
const isValid =
lastView === "default" ||
views.some((view) => view.viewId === lastView);
if (!isValid) {
return;
}
const baseConfig = getLauncherUrlBaseConfig(lastView, views);
const params = serializeLauncherUrlState({
viewId: lastView,
config: baseConfig
});
navigate({ searchParams: params, replace: true });
}, [activeViewId, navigate, orgId, searchParams, views]);
const navigateToConfig = useCallback(
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
if (isApplyingUrlRef.current) {
return;
}
const params = serializeLauncherUrlState({ const params = serializeLauncherUrlState({
viewId, viewId,
config: nextConfig config: nextConfig
}); });
const path = buildLauncherPath(orgId, params); navigate({ searchParams: params });
router.replace(path, { scroll: false });
}, },
[orgId, router] [navigate]
); );
const debouncedSyncSearch = useDebouncedCallback( const debouncedNavigateSearch = useDebouncedCallback(
(viewId: LauncherActiveViewId, query: string) => { (viewId: LauncherActiveViewId, query: string) => {
const nextConfig = { ...configRef.current, query }; navigateToConfig(viewId, { ...configRef.current, query });
setSearchQuery(query);
syncUrl(viewId, nextConfig);
}, },
300 300
); );
useEffect(() => {
if (viewsLoading) {
return;
}
let fallbackViewId: LauncherActiveViewId | null = null;
if (!hasRestoredLastView.current) {
hasRestoredLastView.current = true;
fallbackViewId = readLauncherLastView(orgId);
}
isApplyingUrlRef.current = true;
const resolved = resolveLauncherStateFromUrl(
new URLSearchParams(searchParams),
views,
fallbackViewId
);
setActiveViewId(resolved.activeViewId);
setConfig(resolved.config);
setSavedConfig(resolved.savedConfig);
setSearchInput(resolved.config.query);
setSearchQuery(resolved.config.query);
isApplyingUrlRef.current = false;
}, [orgId, searchParams, views, viewsLoading]);
const selectView = useCallback( const selectView = useCallback(
(viewId: LauncherActiveViewId) => { (viewId: LauncherActiveViewId) => {
writeLauncherLastView(orgId, viewId); writeLauncherLastView(orgId, viewId);
const baseConfig = getLauncherUrlBaseConfig(viewId, views); const baseConfig = getLauncherUrlBaseConfig(viewId, views);
syncUrl(viewId, baseConfig); navigateToConfig(viewId, baseConfig);
}, },
[orgId, syncUrl, views] [navigateToConfig, orgId, views]
); );
const activeSavedView = useMemo( const activeSavedView = useMemo(
@@ -186,12 +191,6 @@ export default function ResourceLauncher({
[config.labelIds] [config.labelIds]
); );
const invalidateLauncher = () => {
void queryClient.invalidateQueries({
queryKey: ["ORG", orgId, "LAUNCHER"]
});
};
const createViewMutation = useMutation({ const createViewMutation = useMutation({
mutationFn: async (payload: { mutationFn: async (payload: {
name: string; name: string;
@@ -202,23 +201,13 @@ export default function ResourceLauncher({
return res.data.data as LauncherViewRecord; return res.data.data as LauncherViewRecord;
}, },
onSuccess: (view) => { onSuccess: (view) => {
invalidateLauncher();
writeLauncherLastView(orgId, view.viewId); writeLauncherLastView(orgId, view.viewId);
isApplyingUrlRef.current = true;
setActiveViewId(view.viewId);
setConfig(view.config);
setSavedConfig(view.config);
setSearchInput(view.config.query);
setSearchQuery(view.config.query);
isApplyingUrlRef.current = false;
const params = serializeLauncherUrlState({ const params = serializeLauncherUrlState({
viewId: view.viewId, viewId: view.viewId,
config: view.config config: view.config
}); });
router.replace(buildLauncherPath(orgId, params), { scroll: false }); navigate({ searchParams: params, replace: true });
router.refresh();
setSaveDialogOpen(false); setSaveDialogOpen(false);
setNewViewName(""); setNewViewName("");
toast({ toast({
@@ -253,22 +242,12 @@ export default function ResourceLauncher({
return res.data.data as LauncherViewRecord; return res.data.data as LauncherViewRecord;
}, },
onSuccess: (view) => { onSuccess: (view) => {
invalidateLauncher();
isApplyingUrlRef.current = true;
setActiveViewId(view.viewId);
setConfig(view.config);
setSavedConfig(view.config);
setSearchInput(view.config.query);
setSearchQuery(view.config.query);
isApplyingUrlRef.current = false;
const params = serializeLauncherUrlState({ const params = serializeLauncherUrlState({
viewId: view.viewId, viewId: view.viewId,
config: view.config config: view.config
}); });
router.replace(buildLauncherPath(orgId, params), { scroll: false }); navigate({ searchParams: params, replace: true });
router.refresh();
toast({ toast({
title: t("resourceLauncherViewSaved"), title: t("resourceLauncherViewSaved"),
description: t("resourceLauncherViewSavedDescription") description: t("resourceLauncherViewSavedDescription")
@@ -291,8 +270,13 @@ export default function ResourceLauncher({
await api.delete(`/org/${orgId}/launcher/views/${viewId}`); await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
}, },
onSuccess: () => { onSuccess: () => {
invalidateLauncher(); writeLauncherLastView(orgId, "default");
selectView("default"); const params = serializeLauncherUrlState({
viewId: "default",
config: getLauncherUrlBaseConfig("default", views)
});
navigate({ searchParams: params, replace: true });
router.refresh();
toast({ toast({
title: t("resourceLauncherViewDeleted"), title: t("resourceLauncherViewDeleted"),
description: t("resourceLauncherViewDeletedDescription") description: t("resourceLauncherViewDeletedDescription")
@@ -317,9 +301,9 @@ export default function ResourceLauncher({
...patch, ...patch,
query: searchInputRef.current query: searchInputRef.current
}; };
syncUrl(activeViewIdRef.current, nextConfig); navigateToConfig(activeViewIdRef.current, nextConfig);
}, },
[syncUrl] [navigateToConfig]
); );
const handleSaveToCurrent = () => { const handleSaveToCurrent = () => {
@@ -370,7 +354,7 @@ export default function ResourceLauncher({
}; };
return ( return (
<div className="flex flex-col"> <div className="flex flex-col" aria-busy={isNavigating}>
<SettingsSectionTitle <SettingsSectionTitle
title={t("resourceLauncherTitle")} title={t("resourceLauncherTitle")}
description={t("resourceLauncherDescription")} description={t("resourceLauncherDescription")}
@@ -386,7 +370,7 @@ export default function ResourceLauncher({
onChange={(event) => { onChange={(event) => {
const value = event.target.value; const value = event.target.value;
setSearchInput(value); setSearchInput(value);
debouncedSyncSearch( debouncedNavigateSearch(
activeViewIdRef.current, activeViewIdRef.current,
value value
); );
@@ -397,16 +381,14 @@ export default function ResourceLauncher({
className="pl-8" className="pl-8"
/> />
</div> </div>
{!viewsLoading ? ( <LauncherViewTabs
<LauncherViewTabs activeViewId={activeViewId}
activeViewId={activeViewId} savedViews={views.map((view) => ({
savedViews={views.map((view) => ({ viewId: view.viewId,
viewId: view.viewId, name: view.name
name: view.name }))}
}))} onSelectView={selectView}
onSelectView={selectView} />
/>
) : null}
</div> </div>
<div className="flex items-center gap-2 shrink-0 justify-end"> <div className="flex items-center gap-2 shrink-0 justify-end">
<LauncherSaveViewMenu <LauncherSaveViewMenu
@@ -462,8 +444,10 @@ export default function ResourceLauncher({
<LauncherGroupList <LauncherGroupList
orgId={orgId} orgId={orgId}
activeViewId={activeViewId} activeViewId={activeViewId}
config={{ ...config, query: searchQuery }} config={config}
searchQuery={searchQuery} initialGroups={groups}
groupsPagination={groupsPagination}
resourcesByGroupKey={resourcesByGroupKey}
/> />
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}> <Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>

View File

@@ -0,0 +1,43 @@
import type { LauncherListQuery } from "@server/routers/launcher/types";
export type LauncherQueryFilters = {
query?: string;
groupBy?: LauncherListQuery["groupBy"];
groupKey?: string;
siteIds?: number[];
labelIds?: number[];
sort_by?: LauncherListQuery["sort_by"];
order?: LauncherListQuery["order"];
pageSize?: number;
};
export function buildLauncherSearchParams(
filters: LauncherQueryFilters,
page: number
) {
const sp = new URLSearchParams();
sp.set("page", String(page));
sp.set("pageSize", String(filters.pageSize ?? 20));
if (filters.query) {
sp.set("query", filters.query);
}
if (filters.groupBy) {
sp.set("groupBy", filters.groupBy);
}
if (filters.groupKey) {
sp.set("groupKey", filters.groupKey);
}
if (filters.siteIds?.length) {
sp.set("siteIds", filters.siteIds.join(","));
}
if (filters.labelIds?.length) {
sp.set("labelIds", filters.labelIds.join(","));
}
if (filters.sort_by) {
sp.set("sort_by", filters.sort_by);
}
if (filters.order) {
sp.set("order", filters.order);
}
return sp;
}

View File

@@ -0,0 +1,128 @@
import { internal } from "@app/lib/api";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type {
LauncherGroup,
LauncherResource,
LauncherViewConfig,
LauncherViewRecord,
ListLauncherGroupsResponse,
ListLauncherResourcesResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import { AxiosResponse } from "axios";
export type LauncherGroupResources = {
resources: LauncherResource[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export type LauncherPageData = {
views: LauncherViewRecord[];
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
groups: LauncherGroup[];
groupsPagination: {
total: number;
page: number;
pageSize: number;
};
resourcesByGroupKey: Record<string, LauncherGroupResources>;
};
const emptyResources: LauncherGroupResources = {
resources: [],
pagination: { total: 0, page: 1, pageSize: 20 }
};
export async function fetchLauncherPageData(
orgId: string,
searchParams: URLSearchParams,
cookieHeader: Awaited<
ReturnType<typeof import("@app/lib/api/cookies").authCookieHeader>
>
): Promise<LauncherPageData> {
let views: LauncherViewRecord[] = [];
try {
const viewsRes = await internal.get<
AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, cookieHeader);
views = viewsRes.data.data.views;
} catch (e) {}
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
searchParams,
views,
null
);
const groupFilters = {
query: config.query,
groupBy: config.groupBy,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order,
pageSize: 20
};
let groups: LauncherGroup[] = [];
let groupsPagination: LauncherPageData["groupsPagination"] = {
total: 0,
page: 1,
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) {}
const resourcesByGroupKey: Record<string, LauncherGroupResources> = {};
await Promise.all(
groups.map(async (group) => {
try {
const sp = buildLauncherSearchParams(
{
...groupFilters,
groupKey: group.groupKey
},
1
);
const res = await internal.get<
AxiosResponse<ListLauncherResourcesResponse>
>(
`/org/${orgId}/launcher/resources?${sp.toString()}`,
cookieHeader
);
resourcesByGroupKey[group.groupKey] = {
resources: res.data.data.resources,
pagination: res.data.data.pagination
};
} catch (e) {
resourcesByGroupKey[group.groupKey] = emptyResources;
}
})
);
return {
views,
activeViewId,
config,
savedConfig,
groups,
groupsPagination,
resourcesByGroupKey
};
}

View File

@@ -53,6 +53,11 @@ import type {
LauncherListQuery, LauncherListQuery,
LauncherViewConfig LauncherViewConfig
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
export type ProductUpdate = { export type ProductUpdate = {
link: string | null; link: string | null;
@@ -1174,45 +1179,6 @@ export const domainQueries = {
}) })
}; };
export type LauncherQueryFilters = {
query?: string;
groupBy?: LauncherListQuery["groupBy"];
groupKey?: string;
siteIds?: number[];
labelIds?: number[];
sort_by?: LauncherListQuery["sort_by"];
order?: LauncherListQuery["order"];
pageSize?: number;
};
function launcherSearchParams(filters: LauncherQueryFilters, page: number) {
const sp = new URLSearchParams();
sp.set("page", String(page));
sp.set("pageSize", String(filters.pageSize ?? 20));
if (filters.query) {
sp.set("query", filters.query);
}
if (filters.groupBy) {
sp.set("groupBy", filters.groupBy);
}
if (filters.groupKey) {
sp.set("groupKey", filters.groupKey);
}
if (filters.siteIds?.length) {
sp.set("siteIds", filters.siteIds.join(","));
}
if (filters.labelIds?.length) {
sp.set("labelIds", filters.labelIds.join(","));
}
if (filters.sort_by) {
sp.set("sort_by", filters.sort_by);
}
if (filters.order) {
sp.set("order", filters.order);
}
return sp;
}
export const launcherQueries = { export const launcherQueries = {
views: (orgId: string) => views: (orgId: string) =>
queryOptions({ queryOptions({
@@ -1228,7 +1194,7 @@ export const launcherQueries = {
infiniteQueryOptions({ infiniteQueryOptions({
queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const, queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const,
queryFn: async ({ pageParam = 1, signal, meta }) => { queryFn: async ({ pageParam = 1, signal, meta }) => {
const sp = launcherSearchParams(filters, pageParam); const sp = buildLauncherSearchParams(filters, pageParam);
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListLauncherGroupsResponse> AxiosResponse<ListLauncherGroupsResponse>
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal }); >(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal });
@@ -1249,7 +1215,7 @@ export const launcherQueries = {
infiniteQueryOptions({ infiniteQueryOptions({
queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const, queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const,
queryFn: async ({ pageParam = 1, signal, meta }) => { queryFn: async ({ pageParam = 1, signal, meta }) => {
const sp = launcherSearchParams(filters, pageParam); const sp = buildLauncherSearchParams(filters, pageParam);
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListLauncherResourcesResponse> AxiosResponse<ListLauncherResourcesResponse>
>(`/org/${orgId}/launcher/resources?${sp.toString()}`, { >(`/org/${orgId}/launcher/resources?${sp.toString()}`, {