mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 20:29:50 +00:00
528 lines
19 KiB
TypeScript
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-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
|
<div className="flex items-center gap-2 shrink-0 justify-start xl:justify-end order-1 sm:order-2 xl:order-2">
|
|
<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 className="flex flex-col sm:flex-row sm:items-center gap-3 min-w-0 flex-1 order-2 sm:order-1 xl:order-1">
|
|
<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>
|
|
</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>
|
|
);
|
|
}
|