Files
pangolin/src/components/resource-launcher/ResourceLauncher.tsx
2026-07-01 11:09:26 -04:00

528 lines
19 KiB
TypeScript

"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 type { LauncherGroupResources } from "@app/lib/launcherServerData";
import {
buildLauncherPath,
getLauncherUrlBaseConfig,
isLauncherConfigEqual,
parseLauncherUrlState,
serializeLauncherUrlState
} from "@app/lib/launcherUrlState";
import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
import type {
LauncherGroup,
LauncherViewConfig,
LauncherViewRecord
} from "@server/routers/launcher/types";
import { useMutation } 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 } from "react";
import { useDebouncedCallback } from "use-debounce";
import type { Selectedsite } from "@app/components/site-selector";
import type { SelectedLabel } from "@app/components/labels-selector";
import { LauncherFilterPopover } from "./LauncherFilterPopover";
import { LauncherGroupList } from "./LauncherGroupList";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton";
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
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,
views,
activeViewId,
config,
savedConfig,
groups,
groupsPagination,
resourcesByGroupKey
}: ResourceLauncherProps) {
const t = useTranslations();
const { toast } = useToast();
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const { navigate, isNavigating, searchParams } = useNavigationContext();
const hasRestoredLastView = useRef(false);
const [searchInput, setSearchInput] = useState(config.query);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [newViewName, setNewViewName] = useState("");
const [saveOrgWide, setSaveOrgWide] = useState(false);
const configRef = useRef(config);
configRef.current = config;
const searchInputRef = useRef(searchInput);
searchInputRef.current = searchInput;
const activeViewIdRef = useRef(activeViewId);
activeViewIdRef.current = activeViewId;
useEffect(() => {
setSearchInput(config.query);
}, [config.query]);
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) => {
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);
navigateToConfig(viewId, baseConfig);
},
[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 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 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)
});
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<LauncherViewConfig>) => {
const nextConfig = {
...configRef.current,
...patch,
query: searchInputRef.current
};
navigateToConfig(activeViewIdRef.current, nextConfig);
},
[navigateToConfig]
);
const handleClearFilters = useCallback(() => {
setSearchInput("");
navigateToConfig(activeViewIdRef.current, {
...configRef.current,
query: "",
siteIds: [],
labelIds: []
});
}, [navigateToConfig]);
const handleSaveToCurrent = () => {
if (isDefaultView) {
return;
}
updateViewMutation.mutate({
viewId: activeViewId,
config
});
};
const handleSaveAsNew = () => {
setSaveOrgWide(false);
setNewViewName("");
setSaveDialogOpen(true);
};
const handleSaveForEveryone = () => {
if (isDefaultView) {
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
});
};
return (
<div className="flex flex-col" aria-busy={isNavigating}>
<SettingsSectionTitle
title={t("resourceLauncherTitle")}
description={t("resourceLauncherDescription")}
/>
<div className="flex flex-col gap-3 mb-6">
<div className="flex flex-row items-center gap-3 justify-between max-md:flex-col max-md:items-stretch">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 min-w-0 flex-1 max-md:order-2">
<div className="relative w-full sm:max-w-sm shrink-0">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
value={searchInput}
onChange={(event) => {
const value = event.target.value;
setSearchInput(value);
debouncedNavigateSearch(
activeViewIdRef.current,
value
);
}}
placeholder={t(
"resourceLauncherSearchPlaceholder"
)}
className="pl-8"
/>
</div>
<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 max-md:order-1 max-md:justify-start">
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
/>
<LauncherFilterPopover
orgId={orgId}
selectedSites={selectedSites}
selectedLabels={selectedLabels}
onSitesChange={(sites) =>
applyConfigPatch({
siteIds: sites.map((site) => site.siteId)
})
}
onLabelsChange={(labels) =>
applyConfigPatch({
labelIds: labels.map(
(label) => label.labelId
)
})
}
/>
<LauncherSortButton
order={config.order}
onToggle={() =>
applyConfigPatch({
order:
config.order === "asc" ? "desc" : "asc"
})
}
/>
<LauncherSettingsMenu
config={config}
isDefaultView={isDefaultView}
onConfigChange={applyConfigPatch}
onDeleteView={() => {
if (!isDefaultView) {
deleteViewMutation.mutate(activeViewId);
}
}}
/>
</div>
</div>
</div>
<LauncherGroupList
orgId={orgId}
activeViewId={activeViewId}
config={config}
initialGroups={groups}
groupsPagination={groupsPagination}
resourcesByGroupKey={resourcesByGroupKey}
onClearFilters={handleClearFilters}
/>
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("resourceLauncherSaveAsNewView")}
</CredenzaTitle>
<CredenzaDescription>
{t("resourceLauncherSaveAsNewViewDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-2">
<Label htmlFor="new-view-name">
{t("resourceLauncherViewNameLabel")}
</Label>
<Input
id="new-view-name"
value={newViewName}
onChange={(event) =>
setNewViewName(event.target.value)
}
/>
</div>
{isAdmin ? (
<div className="mt-4">
<CheckboxWithLabel
id="save-org-wide"
aria-describedby="save-org-wide-desc"
label={t("resourceLauncherSaveForEveryone")}
checked={saveOrgWide}
onCheckedChange={(checked) =>
setSaveOrgWide(checked === true)
}
/>
<p
id="save-org-wide-desc"
className="text-sm text-muted-foreground mt-2"
>
{t(
"resourceLauncherSaveForEveryoneDescription"
)}
</p>
</div>
) : null}
</CredenzaBody>
<CredenzaFooter>
<Button
variant="outline"
onClick={() => setSaveDialogOpen(false)}
>
{t("cancel")}
</Button>
<Button
onClick={handleCreateView}
loading={createViewMutation.isPending}
>
{t("save")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</div>
);
}