mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-02 10:34:55 +00:00
add server side fetching
This commit is contained in:
9
src/app/[orgId]/loading.tsx
Normal file
9
src/app/[orgId]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { Layout } from "@app/components/Layout";
|
||||
import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { fetchLauncherPageData } from "@app/lib/launcherServerData";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
@@ -13,8 +13,11 @@ import { cache } from "react";
|
||||
|
||||
type OrgPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OrgPage(props: OrgPageProps) {
|
||||
const params = await props.params;
|
||||
const orgId = params.orgId;
|
||||
@@ -55,6 +58,15 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
|
||||
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 (
|
||||
<UserProvider user={user}>
|
||||
<Layout
|
||||
@@ -65,8 +77,18 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
launcherMode
|
||||
showViewAsAdmin={isAdminOrOwner}
|
||||
>
|
||||
{overview ? (
|
||||
<ResourceLauncher orgId={orgId} isAdmin={isAdminOrOwner} />
|
||||
{overview && launcherData ? (
|
||||
<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}
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
|
||||
@@ -1,159 +1,80 @@
|
||||
"use client";
|
||||
|
||||
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 type { LauncherViewConfig } from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { LauncherGroupSection } from "./LauncherGroupSection";
|
||||
|
||||
type LauncherGroupListProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
function buildResourceFilters(
|
||||
config: LauncherViewConfig,
|
||||
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
|
||||
initialGroups: LauncherGroup[];
|
||||
groupsPagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
}
|
||||
resourcesByGroupKey: Record<string, LauncherGroupResources>;
|
||||
};
|
||||
|
||||
export function LauncherGroupList({
|
||||
orgId,
|
||||
activeViewId,
|
||||
config,
|
||||
searchQuery
|
||||
initialGroups,
|
||||
groupsPagination,
|
||||
resourcesByGroupKey
|
||||
}: LauncherGroupListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
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({
|
||||
...launcherQueries.groups(orgId, {
|
||||
query: searchQuery,
|
||||
groupBy: config.groupBy,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
})
|
||||
...launcherQueries.groups(orgId, groupFilters),
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
groups: initialGroups,
|
||||
pagination: groupsPagination
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
},
|
||||
refetchOnMount: false
|
||||
});
|
||||
|
||||
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(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage || isBodyLoading) {
|
||||
if (!node || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,28 +89,25 @@ export function LauncherGroupList({
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isBodyLoading, isFetchingNextPage]);
|
||||
|
||||
if (isBodyLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{groups.map((group) => (
|
||||
<LauncherGroupSection
|
||||
key={group.groupKey}
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
group={group}
|
||||
config={config}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
))}
|
||||
{groups.map((group) => {
|
||||
const groupResources = resourcesByGroupKey[group.groupKey];
|
||||
|
||||
return (
|
||||
<LauncherGroupSection
|
||||
key={group.groupKey}
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
group={group}
|
||||
config={config}
|
||||
initialResources={groupResources?.resources}
|
||||
initialResourcesPagination={groupResources?.pagination}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div ref={loadMoreRef} className="h-4" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherResource,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
@@ -28,7 +29,12 @@ type LauncherGroupSectionProps = {
|
||||
activeViewId: LauncherActiveViewId;
|
||||
group: LauncherGroup;
|
||||
config: LauncherViewConfig;
|
||||
searchQuery: string;
|
||||
initialResources?: LauncherResource[];
|
||||
initialResourcesPagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
@@ -37,7 +43,8 @@ export function LauncherGroupSection({
|
||||
activeViewId,
|
||||
group,
|
||||
config,
|
||||
searchQuery,
|
||||
initialResources,
|
||||
initialResourcesPagination,
|
||||
defaultOpen = true
|
||||
}: LauncherGroupSectionProps) {
|
||||
const t = useTranslations();
|
||||
@@ -75,10 +82,12 @@ export function LauncherGroupSection({
|
||||
);
|
||||
};
|
||||
|
||||
const hasInitialResources = initialResources !== undefined;
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.resources(orgId, {
|
||||
query: searchQuery,
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
groupKey: group.groupKey,
|
||||
siteIds: config.siteIds,
|
||||
@@ -87,14 +96,33 @@ export function LauncherGroupSection({
|
||||
order: config.order,
|
||||
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 showInitialLoader = isLoading && resources.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage) {
|
||||
if (!node || !hasNextPage || !isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,7 +137,7 @@ export function LauncherGroupSection({
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]);
|
||||
|
||||
const groupTitle =
|
||||
group.groupKey === "unlabeled"
|
||||
@@ -129,7 +157,7 @@ export function LauncherGroupSection({
|
||||
/>
|
||||
|
||||
<CollapsibleContent className="w-full">
|
||||
{isLoading ? (
|
||||
{showInitialLoader ? (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
|
||||
@@ -14,30 +14,31 @@ import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import {
|
||||
readLauncherLastView,
|
||||
writeLauncherLastView,
|
||||
type LauncherActiveViewId
|
||||
} from "@app/lib/launcherLocalStorage";
|
||||
import type { LauncherGroupResources } from "@app/lib/launcherServerData";
|
||||
import {
|
||||
buildLauncherPath,
|
||||
getLauncherUrlBaseConfig,
|
||||
isLauncherConfigEqual,
|
||||
resolveLauncherStateFromUrl,
|
||||
parseLauncherUrlState,
|
||||
serializeLauncherUrlState
|
||||
} from "@app/lib/launcherUrlState";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
defaultLauncherViewConfig,
|
||||
type LauncherViewConfig,
|
||||
type LauncherViewRecord
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherViewConfig,
|
||||
LauncherViewRecord
|
||||
} 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 { useTranslations } from "next-intl";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
@@ -52,33 +53,39 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
type ResourceLauncherProps = {
|
||||
orgId: string;
|
||||
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({
|
||||
orgId,
|
||||
isAdmin
|
||||
isAdmin,
|
||||
views,
|
||||
activeViewId,
|
||||
config,
|
||||
savedConfig,
|
||||
groups,
|
||||
groupsPagination,
|
||||
resourcesByGroupKey
|
||||
}: ResourceLauncherProps) {
|
||||
const t = useTranslations();
|
||||
const { toast } = useToast();
|
||||
const { env } = useEnvContext();
|
||||
const queryClient = useQueryClient();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [activeViewId, setActiveViewId] =
|
||||
useState<LauncherActiveViewId>("default");
|
||||
const { navigate, isNavigating, searchParams } = useNavigationContext();
|
||||
const hasRestoredLastView = useRef(false);
|
||||
const isApplyingUrlRef = useRef(false);
|
||||
|
||||
const [config, setConfig] = useState<LauncherViewConfig>(
|
||||
defaultLauncherViewConfig
|
||||
);
|
||||
const [savedConfig, setSavedConfig] = useState<LauncherViewConfig>(
|
||||
defaultLauncherViewConfig
|
||||
);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchInput, setSearchInput] = useState(config.query);
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [newViewName, setNewViewName] = useState("");
|
||||
const [saveOrgWide, setSaveOrgWide] = useState(false);
|
||||
@@ -90,68 +97,66 @@ export default function ResourceLauncher({
|
||||
const activeViewIdRef = useRef(activeViewId);
|
||||
activeViewIdRef.current = activeViewId;
|
||||
|
||||
const { data: views = [], isLoading: viewsLoading } = useQuery(
|
||||
launcherQueries.views(orgId)
|
||||
);
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
if (isApplyingUrlRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId,
|
||||
config: nextConfig
|
||||
});
|
||||
const path = buildLauncherPath(orgId, params);
|
||||
router.replace(path, { scroll: false });
|
||||
navigate({ searchParams: params });
|
||||
},
|
||||
[orgId, router]
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const debouncedSyncSearch = useDebouncedCallback(
|
||||
const debouncedNavigateSearch = useDebouncedCallback(
|
||||
(viewId: LauncherActiveViewId, query: string) => {
|
||||
const nextConfig = { ...configRef.current, query };
|
||||
setSearchQuery(query);
|
||||
syncUrl(viewId, nextConfig);
|
||||
navigateToConfig(viewId, { ...configRef.current, query });
|
||||
},
|
||||
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(
|
||||
(viewId: LauncherActiveViewId) => {
|
||||
writeLauncherLastView(orgId, viewId);
|
||||
const baseConfig = getLauncherUrlBaseConfig(viewId, views);
|
||||
syncUrl(viewId, baseConfig);
|
||||
navigateToConfig(viewId, baseConfig);
|
||||
},
|
||||
[orgId, syncUrl, views]
|
||||
[navigateToConfig, orgId, views]
|
||||
);
|
||||
|
||||
const activeSavedView = useMemo(
|
||||
@@ -186,12 +191,6 @@ export default function ResourceLauncher({
|
||||
[config.labelIds]
|
||||
);
|
||||
|
||||
const invalidateLauncher = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER"]
|
||||
});
|
||||
};
|
||||
|
||||
const createViewMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
name: string;
|
||||
@@ -202,23 +201,13 @@ export default function ResourceLauncher({
|
||||
return res.data.data as LauncherViewRecord;
|
||||
},
|
||||
onSuccess: (view) => {
|
||||
invalidateLauncher();
|
||||
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({
|
||||
viewId: view.viewId,
|
||||
config: view.config
|
||||
});
|
||||
router.replace(buildLauncherPath(orgId, params), { scroll: false });
|
||||
|
||||
navigate({ searchParams: params, replace: true });
|
||||
router.refresh();
|
||||
setSaveDialogOpen(false);
|
||||
setNewViewName("");
|
||||
toast({
|
||||
@@ -253,22 +242,12 @@ export default function ResourceLauncher({
|
||||
return res.data.data as LauncherViewRecord;
|
||||
},
|
||||
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({
|
||||
viewId: view.viewId,
|
||||
config: view.config
|
||||
});
|
||||
router.replace(buildLauncherPath(orgId, params), { scroll: false });
|
||||
|
||||
navigate({ searchParams: params, replace: true });
|
||||
router.refresh();
|
||||
toast({
|
||||
title: t("resourceLauncherViewSaved"),
|
||||
description: t("resourceLauncherViewSavedDescription")
|
||||
@@ -291,8 +270,13 @@ export default function ResourceLauncher({
|
||||
await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateLauncher();
|
||||
selectView("default");
|
||||
writeLauncherLastView(orgId, "default");
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: "default",
|
||||
config: getLauncherUrlBaseConfig("default", views)
|
||||
});
|
||||
navigate({ searchParams: params, replace: true });
|
||||
router.refresh();
|
||||
toast({
|
||||
title: t("resourceLauncherViewDeleted"),
|
||||
description: t("resourceLauncherViewDeletedDescription")
|
||||
@@ -317,9 +301,9 @@ export default function ResourceLauncher({
|
||||
...patch,
|
||||
query: searchInputRef.current
|
||||
};
|
||||
syncUrl(activeViewIdRef.current, nextConfig);
|
||||
navigateToConfig(activeViewIdRef.current, nextConfig);
|
||||
},
|
||||
[syncUrl]
|
||||
[navigateToConfig]
|
||||
);
|
||||
|
||||
const handleSaveToCurrent = () => {
|
||||
@@ -370,7 +354,7 @@ export default function ResourceLauncher({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col" aria-busy={isNavigating}>
|
||||
<SettingsSectionTitle
|
||||
title={t("resourceLauncherTitle")}
|
||||
description={t("resourceLauncherDescription")}
|
||||
@@ -386,7 +370,7 @@ export default function ResourceLauncher({
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setSearchInput(value);
|
||||
debouncedSyncSearch(
|
||||
debouncedNavigateSearch(
|
||||
activeViewIdRef.current,
|
||||
value
|
||||
);
|
||||
@@ -397,16 +381,14 @@ export default function ResourceLauncher({
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{!viewsLoading ? (
|
||||
<LauncherViewTabs
|
||||
activeViewId={activeViewId}
|
||||
savedViews={views.map((view) => ({
|
||||
viewId: view.viewId,
|
||||
name: view.name
|
||||
}))}
|
||||
onSelectView={selectView}
|
||||
/>
|
||||
) : null}
|
||||
<LauncherViewTabs
|
||||
activeViewId={activeViewId}
|
||||
savedViews={views.map((view) => ({
|
||||
viewId: view.viewId,
|
||||
name: view.name
|
||||
}))}
|
||||
onSelectView={selectView}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 justify-end">
|
||||
<LauncherSaveViewMenu
|
||||
@@ -462,8 +444,10 @@ export default function ResourceLauncher({
|
||||
<LauncherGroupList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={{ ...config, query: searchQuery }}
|
||||
searchQuery={searchQuery}
|
||||
config={config}
|
||||
initialGroups={groups}
|
||||
groupsPagination={groupsPagination}
|
||||
resourcesByGroupKey={resourcesByGroupKey}
|
||||
/>
|
||||
|
||||
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
|
||||
43
src/lib/launcherSearchParams.ts
Normal file
43
src/lib/launcherSearchParams.ts
Normal 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;
|
||||
}
|
||||
128
src/lib/launcherServerData.ts
Normal file
128
src/lib/launcherServerData.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,11 @@ import type {
|
||||
LauncherListQuery,
|
||||
LauncherViewConfig
|
||||
} 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 = {
|
||||
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 = {
|
||||
views: (orgId: string) =>
|
||||
queryOptions({
|
||||
@@ -1228,7 +1194,7 @@ export const launcherQueries = {
|
||||
infiniteQueryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const,
|
||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
||||
const sp = launcherSearchParams(filters, pageParam);
|
||||
const sp = buildLauncherSearchParams(filters, pageParam);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherGroupsResponse>
|
||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal });
|
||||
@@ -1249,7 +1215,7 @@ export const launcherQueries = {
|
||||
infiniteQueryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const,
|
||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
||||
const sp = launcherSearchParams(filters, pageParam);
|
||||
const sp = buildLauncherSearchParams(filters, pageParam);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherResourcesResponse>
|
||||
>(`/org/${orgId}/launcher/resources?${sp.toString()}`, {
|
||||
|
||||
Reference in New Issue
Block a user