"use client"; import { Credenza, CredenzaBody, CredenzaContent, CredenzaDescription, CredenzaFooter, CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; 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 { buildLauncherPath, getLauncherUrlBaseConfig, isLauncherConfigEqual, parseLauncherUrlState, serializeLauncherUrlState } from "@app/lib/launcherUrlState"; import { useToast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { getEffectiveLauncherConfig, shouldShowFlatResourceList, shouldShowLauncherGroupList, shouldShowSearchFirstGate } from "@app/lib/launcherScale"; import { launcherQueries } from "@app/lib/queries"; import { getEffectiveDefaultLauncherConfig, type LauncherDefaultViewOverrides, type LauncherGroup, type LauncherResource, type LauncherScaleInfo, type LauncherViewConfig, type LauncherViewRecord } from "@server/routers/launcher/types"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import type { Selectedsite } from "@app/components/site-selector"; import type { SelectedLabel } from "@app/components/labels-selector"; import { useMediaQuery } from "@app/hooks/useMediaQuery"; import { cn } from "@app/lib/cn"; import { LauncherFilterPopover } from "./LauncherFilterPopover"; import { LauncherFlatResourceList } from "./LauncherFlatResourceList"; import { LauncherGroupList } from "./LauncherGroupList"; import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate"; import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherResourcePanel } from "./LauncherResourcePanel"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSortButton } from "./LauncherSortButton"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type ResourceLauncherProps = { orgId: string; isAdmin: boolean; views: LauncherViewRecord[]; defaultViewOverrides: LauncherDefaultViewOverrides; activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; scale: LauncherScaleInfo; groups: LauncherGroup[]; groupsPagination: { total: number; page: number; pageSize: number; }; }; export default function ResourceLauncher({ orgId, isAdmin, views, defaultViewOverrides, activeViewId, config, savedConfig, scale: initialScale, groups, groupsPagination }: ResourceLauncherProps) { const t = useTranslations(); const { toast } = useToast(); const { env } = useEnvContext(); const api = createApiClient({ env }); const queryClient = useQueryClient(); const router = useRouter(); const { navigate, isNavigating, searchParams } = useNavigationContext(); const [isRefreshing, startRefreshTransition] = useTransition(); const hasRestoredLastView = useRef(false); const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); const [selectedResource, setSelectedResource] = useState(null); const [panelOpen, setPanelOpen] = useState(false); const isDesktop = useMediaQuery("(min-width: 768px)"); const scaleFilters = useMemo( () => ({ query: config.query, groupBy: config.groupBy, siteIds: config.siteIds, labelIds: config.labelIds, sort_by: config.sortBy, order: config.order }), [ config.groupBy, config.labelIds, config.order, config.query, config.siteIds, config.sortBy ] ); const { data: scale = initialScale } = useQuery({ ...launcherQueries.scale(orgId, scaleFilters), initialData: initialScale }); const showGroupList = shouldShowLauncherGroupList(scale, config); const showSearchFirstGate = shouldShowSearchFirstGate(scale, config); const showFlatResourceList = shouldShowFlatResourceList(scale, config); const effectiveConfig = useMemo( () => getEffectiveLauncherConfig(scale, config), [config, scale] ); const configRef = useRef(config); configRef.current = config; const searchInputRef = useRef(config.query); const activeViewIdRef = useRef(activeViewId); activeViewIdRef.current = activeViewId; 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, defaultViewOverrides ); const params = serializeLauncherUrlState({ viewId: lastView, config: baseConfig }); navigate({ searchParams: params, replace: true }); }, [ activeViewId, defaultViewOverrides, navigate, orgId, searchParams, views ]); const navigateToConfig = useCallback( (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { const params = serializeLauncherUrlState({ viewId, config: nextConfig }); navigate({ searchParams: params }); }, [navigate] ); const debouncedNavigateSearch = useDebouncedCallback( (viewId: LauncherActiveViewId, query: string) => { navigateToConfig(viewId, { ...configRef.current, query }); }, 300 ); const selectView = useCallback( (viewId: LauncherActiveViewId) => { writeLauncherLastView(orgId, viewId); const baseConfig = getLauncherUrlBaseConfig( viewId, views, defaultViewOverrides ); navigateToConfig(viewId, baseConfig); }, [defaultViewOverrides, navigateToConfig, orgId, views] ); const activeSavedView = useMemo( () => activeViewId === "default" ? null : views.find((view) => view.viewId === activeViewId), [activeViewId, views] ); const isDefaultView = activeViewId === "default"; const isOrgWideView = Boolean(activeSavedView?.isOrgWide); const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig); const canResetSystemDefault = isDefaultView && (Boolean(defaultViewOverrides.personal) || (isAdmin && Boolean(defaultViewOverrides.orgWide))); const selectedSites: Selectedsite[] = useMemo( () => config.siteIds.map((siteId) => ({ siteId, name: String(siteId), type: "newt" })), [config.siteIds] ); const selectedLabels: SelectedLabel[] = useMemo( () => config.labelIds.map((labelId) => ({ labelId, name: String(labelId), color: "#a1a1aa" })), [config.labelIds] ); const createViewMutation = useMutation({ mutationFn: async (payload: { name: string; config: LauncherViewConfig; orgWide: boolean; }) => { const res = await api.post(`/org/${orgId}/launcher/views`, payload); return res.data.data as LauncherViewRecord; }, onSuccess: (view) => { writeLauncherLastView(orgId, view.viewId); const params = serializeLauncherUrlState({ viewId: view.viewId, config: view.config }); navigate({ searchParams: params, replace: true }); router.refresh(); setSaveDialogOpen(false); setNewViewName(""); toast({ title: t("resourceLauncherViewSaved"), description: t("resourceLauncherViewSavedDescription") }); }, onError: (error) => { toast({ variant: "destructive", title: t("resourceLauncherViewSaveFailed"), description: formatAxiosError( error, t("resourceLauncherViewSaveFailedDescription") ) }); } }); const updateViewMutation = useMutation({ mutationFn: async (payload: { viewId: number; name?: string; config?: LauncherViewConfig; orgWide?: boolean; }) => { const { viewId, ...body } = payload; const res = await api.put( `/org/${orgId}/launcher/views/${viewId}`, body ); return res.data.data as LauncherViewRecord; }, onSuccess: (view) => { const params = serializeLauncherUrlState({ viewId: view.viewId, config: view.config }); navigate({ searchParams: params, replace: true }); router.refresh(); toast({ title: t("resourceLauncherViewSaved"), description: t("resourceLauncherViewSavedDescription") }); }, onError: (error) => { toast({ variant: "destructive", title: t("resourceLauncherViewSaveFailed"), description: formatAxiosError( error, t("resourceLauncherViewSaveFailedDescription") ) }); } }); const saveDefaultViewMutation = useMutation({ mutationFn: async (payload: { config: LauncherViewConfig; orgWide: boolean; }) => { const res = await api.put( `/org/${orgId}/launcher/default-view`, payload ); return res.data.data as LauncherViewRecord; }, onSuccess: () => { writeLauncherLastView(orgId, "default"); const params = serializeLauncherUrlState({ viewId: "default", config }); navigate({ searchParams: params, replace: true }); router.refresh(); toast({ title: t("resourceLauncherViewSaved"), description: t("resourceLauncherViewSavedDescription") }); }, onError: (error) => { toast({ variant: "destructive", title: t("resourceLauncherViewSaveFailed"), description: formatAxiosError( error, t("resourceLauncherViewSaveFailedDescription") ) }); } }); const resetSystemDefaultMutation = useMutation({ mutationFn: async () => { const resetAll = isAdmin && Boolean(defaultViewOverrides.orgWide); await api.delete(`/org/${orgId}/launcher/default-view`, { data: resetAll ? { all: true } : { orgWide: false } }); }, onSuccess: () => { writeLauncherLastView(orgId, "default"); const nextOverrides: LauncherDefaultViewOverrides = { personal: null, orgWide: isAdmin && defaultViewOverrides.orgWide ? null : defaultViewOverrides.orgWide }; const targetConfig = getEffectiveDefaultLauncherConfig(nextOverrides); searchInputRef.current = targetConfig.query; setSearchInputResetKey((key) => key + 1); const params = serializeLauncherUrlState({ viewId: "default", config: targetConfig }); navigate({ searchParams: params, replace: true }); router.refresh(); toast({ title: t("resourceLauncherSystemDefaultRestored"), description: t( "resourceLauncherSystemDefaultRestoredDescription" ) }); }, onError: (error) => { toast({ variant: "destructive", title: t("resourceLauncherViewSaveFailed"), description: formatAxiosError( error, t("resourceLauncherViewSaveFailedDescription") ) }); } }); const deleteViewMutation = useMutation({ mutationFn: async (viewId: number) => { await api.delete(`/org/${orgId}/launcher/views/${viewId}`); }, onSuccess: () => { writeLauncherLastView(orgId, "default"); const params = serializeLauncherUrlState({ viewId: "default", config: getLauncherUrlBaseConfig( "default", views, defaultViewOverrides ) }); navigate({ searchParams: params, replace: true }); router.refresh(); toast({ title: t("resourceLauncherViewDeleted"), description: t("resourceLauncherViewDeletedDescription") }); }, onError: (error) => { toast({ variant: "destructive", title: t("resourceLauncherViewDeleteFailed"), description: formatAxiosError( error, t("resourceLauncherViewDeleteFailedDescription") ) }); } }); const applyConfigPatch = useCallback( (patch: Partial) => { const nextConfig = { ...configRef.current, ...patch, query: searchInputRef.current }; navigateToConfig(activeViewIdRef.current, nextConfig); }, [navigateToConfig] ); const handleClearFilters = useCallback(() => { searchInputRef.current = ""; setSearchInputResetKey((key) => key + 1); navigateToConfig(activeViewIdRef.current, { ...configRef.current, query: "", siteIds: [], labelIds: [] }); }, [navigateToConfig]); const handleResetView = useCallback(() => { searchInputRef.current = savedConfig.query; setSearchInputResetKey((key) => key + 1); navigateToConfig(activeViewIdRef.current, savedConfig); }, [navigateToConfig, savedConfig]); const handleResetSystemDefault = useCallback(() => { resetSystemDefaultMutation.mutate(); }, [resetSystemDefaultMutation]); const refreshData = () => { startRefreshTransition(async () => { try { await api.post(`/org/${orgId}/launcher/invalidate-cache`); await queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "LAUNCHER"] }); router.refresh(); } catch { toast({ title: t("error"), description: t("refreshError"), variant: "destructive" }); } }); }; const handleSaveToCurrent = () => { if (isDefaultView) { saveDefaultViewMutation.mutate({ config, orgWide: false }); return; } if (isOrgWideView && !isAdmin) { return; } updateViewMutation.mutate({ viewId: activeViewId, config }); }; const handleSaveAsNew = () => { setSaveOrgWide(false); setNewViewName(""); setSaveDialogOpen(true); }; const handleSaveForEveryone = () => { if (isDefaultView) { saveDefaultViewMutation.mutate({ config, orgWide: true }); return; } updateViewMutation.mutate({ viewId: activeViewId, orgWide: true }); }; const handleMakePersonal = () => { if (isDefaultView) { return; } updateViewMutation.mutate({ viewId: activeViewId, orgWide: false }); }; const handleCreateView = () => { if (!newViewName.trim()) { return; } createViewMutation.mutate({ name: newViewName.trim(), config, orgWide: saveOrgWide && isAdmin }); }; const handleResourceSelect = useCallback((resource: LauncherResource) => { setSelectedResource(resource); setPanelOpen(true); }, []); const handlePanelOpenChange = useCallback((open: boolean) => { setPanelOpen(open); if (!open) { setSelectedResource(null); } }, []); const savedViewTabs = views.map((view) => ({ viewId: view.viewId, name: view.name })); const renderToolbarSearch = (searchClassName: string) => (
{ const value = event.currentTarget.value; searchInputRef.current = value; debouncedNavigateSearch(activeViewIdRef.current, value); }} placeholder={t("resourceLauncherSearchPlaceholder")} className="pl-8" type="search" />
); const renderToolbarFilterSort = () => ( <> applyConfigPatch({ siteIds: sites.map((site) => site.siteId) }) } onLabelsChange={(labels) => applyConfigPatch({ labelIds: labels.map((label) => label.labelId) }) } /> applyConfigPatch({ order: config.order === "asc" ? "desc" : "asc" }) } /> ); const renderToolbarActions = () => ( <> setDeleteDialogOpen(true)} /> ); const renderToolbarViews = () => ( ); return (
{isDesktop ? (
{renderToolbarSearch("w-64")}
{renderToolbarFilterSort()}
{renderToolbarViews()}
{renderToolbarActions()}
) : (
{renderToolbarActions()}
{renderToolbarSearch("w-full")}
{renderToolbarFilterSort()}
{renderToolbarViews()}
)} {showSearchFirstGate ? ( ) : showGroupList ? ( ) : showFlatResourceList ? ( ) : null} {activeSavedView ? ( {t("resourceLauncherDeleteViewQuestion")}

} onConfirm={async () => { await deleteViewMutation.mutateAsync( activeSavedView.viewId ); }} /> ) : null} {t("resourceLauncherSaveAsNewView")} {t("resourceLauncherSaveAsNewViewDescription")}
setNewViewName(event.target.value) } />
{isAdmin ? (
setSaveOrgWide(checked === true) } />

{t( "resourceLauncherSaveForEveryoneDescription" )}

) : null}
); }