From f0546eb6224eb17a15a1a1bbb1a12d093bbea1dd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 2 Jul 2026 22:27:51 -0400 Subject: [PATCH] add save default view --- messages/en-US.json | 13 +- server/routers/external.ts | 12 + server/routers/launcher/createLauncherView.ts | 10 + .../launcher/deleteLauncherDefaultView.ts | 104 +++++++++ server/routers/launcher/deleteLauncherView.ts | 10 + server/routers/launcher/index.ts | 2 + .../routers/launcher/launcherDefaultView.ts | 144 ++++++++++++ server/routers/launcher/listLauncherViews.ts | 23 +- server/routers/launcher/types.ts | 22 ++ server/routers/launcher/updateLauncherView.ts | 10 + .../launcher/upsertLauncherDefaultView.ts | 82 +++++++ src/app/[orgId]/page.tsx | 1 + .../LauncherSettingsMenu.tsx | 16 +- .../resource-launcher/LauncherViewTabs.tsx | 41 +++- .../resource-launcher/ResourceLauncher.tsx | 211 +++++++++++++++--- src/lib/launcherServerData.ts | 11 +- src/lib/launcherUrlState.ts | 26 ++- src/lib/queries.ts | 2 +- 18 files changed, 665 insertions(+), 75 deletions(-) create mode 100644 server/routers/launcher/deleteLauncherDefaultView.ts create mode 100644 server/routers/launcher/launcherDefaultView.ts create mode 100644 server/routers/launcher/upsertLauncherDefaultView.ts diff --git a/messages/en-US.json b/messages/en-US.json index fede28c11..a4e99d5c5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3571,12 +3571,16 @@ "memberPortalResourceDisabled": "Resource Disabled", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "resourceLauncherTitle": "Resource Launcher", - "resourceLauncherDescription": "View resource details and launch them from one place", - "resourceLauncherSearchPlaceholder": "Search all sites...", + "resourceLauncherDescription": "View all available resources and launch them from one central hub", + "resourceLauncherSearchPlaceholder": "Search your resources...", "resourceLauncherDefaultView": "Default", "resourceLauncherSaveView": "Save View", "resourceLauncherSaveToCurrentView": "Save to Current View", + "resourceLauncherSaveDefaultPersonal": "Save for Me", "resourceLauncherResetView": "Reset View", + "resourceLauncherResetSystemDefault": "Reset to System Default", + "resourceLauncherSystemDefaultRestored": "System default restored", + "resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.", "resourceLauncherSaveAsNewView": "Save as New View", "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", "resourceLauncherSaveForEveryone": "Save for Everyone", @@ -3598,6 +3602,9 @@ "resourceLauncherShowSiteTags": "Show Site Tags", "resourceLauncherShowRecents": "Show Recents", "resourceLauncherDeleteView": "Delete View", + "resourceLauncherDeleteViewTitle": "Delete View", + "resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?", + "resourceLauncherDeleteViewConfirm": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherResourceDetailsDescription": "View details for this resource.", "resourceLauncherUnlabeled": "Unlabeled", @@ -3609,7 +3616,7 @@ "resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.", "resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.", "resourceLauncherSearchFirstTitle": "Search or Filter to Browse", - "resourceLauncherSearchFirstDescription": "This organization has many resources. Use search or filter by site or label to find what you need.", + "resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.", "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", diff --git a/server/routers/external.ts b/server/routers/external.ts index 43cf88a43..2b95c2bfc 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -512,6 +512,18 @@ authenticated.put( launcher.updateLauncherView ); +authenticated.put( + "/org/:orgId/launcher/default-view", + verifyOrgAccess, + launcher.upsertLauncherDefaultView +); + +authenticated.delete( + "/org/:orgId/launcher/default-view", + verifyOrgAccess, + launcher.deleteLauncherDefaultView +); + authenticated.delete( "/org/:orgId/launcher/views/:viewId", verifyOrgAccess, diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts index 593bad304..4c77cd4f0 100644 --- a/server/routers/launcher/createLauncherView.ts +++ b/server/routers/launcher/createLauncherView.ts @@ -7,6 +7,7 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const createLauncherViewBodySchema = z.strictObject({ @@ -40,6 +41,15 @@ export async function createLauncherView( ); } + if (isLauncherDefaultOverrideViewName(parsed.data.name)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This view name is reserved" + ) + ); + } + if (parsed.data.orgWide) { const canCreateOrgWide = await checkUserActionPermission( ActionsEnum.createOrgWideLauncherView, diff --git a/server/routers/launcher/deleteLauncherDefaultView.ts b/server/routers/launcher/deleteLauncherDefaultView.ts new file mode 100644 index 000000000..50a1a7beb --- /dev/null +++ b/server/routers/launcher/deleteLauncherDefaultView.ts @@ -0,0 +1,104 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { + deleteAllDefaultViewOverrides, + deleteDefaultViewOverride +} from "./launcherDefaultView"; + +const deleteLauncherDefaultViewBodySchema = z.strictObject({ + orgWide: z.boolean().optional().default(false), + all: z.boolean().optional().default(false) +}); + +export async function deleteLauncherDefaultView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + if (parsed.data.all) { + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + await deleteAllDefaultViewOverrides(orgId, userId); + } else if (parsed.data.orgWide) { + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + await deleteDefaultViewOverride({ + orgId, + userId, + orgWide: true + }); + } else { + await deleteDefaultViewOverride({ + orgId, + userId, + orgWide: false + }); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Launcher default view reset successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error resetting launcher default view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts index beaf1b433..41d7dd214 100644 --- a/server/routers/launcher/deleteLauncherView.ts +++ b/server/routers/launcher/deleteLauncherView.ts @@ -6,6 +6,7 @@ import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; export async function deleteLauncherView( req: Request, @@ -46,6 +47,15 @@ export async function deleteLauncherView( ); } + if (isLauncherDefaultOverrideViewName(existing.name)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "The default view cannot be deleted from here" + ) + ); + } + const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; const canManageOrgWide = await checkUserActionPermission( diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index e941bbe10..b9edafc22 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -8,3 +8,5 @@ export { listLauncherViews } from "./listLauncherViews"; export { createLauncherView } from "./createLauncherView"; export { updateLauncherView } from "./updateLauncherView"; export { deleteLauncherView } from "./deleteLauncherView"; +export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView"; +export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView"; diff --git a/server/routers/launcher/launcherDefaultView.ts b/server/routers/launcher/launcherDefaultView.ts new file mode 100644 index 000000000..4d180bd1d --- /dev/null +++ b/server/routers/launcher/launcherDefaultView.ts @@ -0,0 +1,144 @@ +import { db, launcherViews } from "@server/db"; +import { and, eq, isNull } from "drizzle-orm"; +import moment from "moment"; +import { + LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, + launcherViewConfigSchema, + type LauncherDefaultViewOverrides, + type LauncherViewConfig, + type LauncherViewRecord +} from "./types"; + +export function isLauncherDefaultOverrideViewName(name: string) { + return name === LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME; +} + +export function mapViewRow( + row: typeof launcherViews.$inferSelect +): LauncherViewRecord { + return { + viewId: row.viewId, + orgId: row.orgId, + userId: row.userId, + name: row.name, + config: launcherViewConfigSchema.parse(JSON.parse(row.config)), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + isOrgWide: row.userId == null + }; +} + +export function extractDefaultViewOverrides( + rows: Array +): LauncherDefaultViewOverrides { + const overrideRows = rows.filter((row) => + isLauncherDefaultOverrideViewName(row.name) + ); + + const personalRow = overrideRows.find((row) => row.userId !== null); + const orgWideRow = overrideRows.find((row) => row.userId === null); + + return { + personal: personalRow ? mapViewRow(personalRow) : null, + orgWide: orgWideRow ? mapViewRow(orgWideRow) : null + }; +} + +export function listVisibleLauncherViews( + rows: Array +): LauncherViewRecord[] { + return rows + .filter((row) => !isLauncherDefaultOverrideViewName(row.name)) + .map(mapViewRow); +} + +export async function findDefaultViewOverride( + orgId: string, + orgWide: boolean, + userId: string +) { + const [existing] = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.orgId, orgId), + eq(launcherViews.name, LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME), + orgWide + ? isNull(launcherViews.userId) + : eq(launcherViews.userId, userId) + ) + ) + .limit(1); + + return existing ?? null; +} + +export async function upsertDefaultViewOverride({ + orgId, + userId, + orgWide, + config +}: { + orgId: string; + userId: string; + orgWide: boolean; + config: LauncherViewConfig; +}) { + const now = moment().toISOString(); + const existing = await findDefaultViewOverride(orgId, orgWide, userId); + + if (existing) { + const [updated] = await db + .update(launcherViews) + .set({ + config: JSON.stringify(config), + updatedAt: now + }) + .where(eq(launcherViews.viewId, existing.viewId)) + .returning(); + + return mapViewRow(updated); + } + + const [created] = await db + .insert(launcherViews) + .values({ + orgId, + userId: orgWide ? null : userId, + name: LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, + config: JSON.stringify(config), + createdAt: now, + updatedAt: now + }) + .returning(); + + return mapViewRow(created); +} + +export async function deleteDefaultViewOverride({ + orgId, + userId, + orgWide +}: { + orgId: string; + userId: string; + orgWide: boolean; +}) { + const existing = await findDefaultViewOverride(orgId, orgWide, userId); + if (!existing) { + return; + } + + await db + .delete(launcherViews) + .where(eq(launcherViews.viewId, existing.viewId)); +} + +export async function deleteAllDefaultViewOverrides( + orgId: string, + userId: string +) { + await deleteDefaultViewOverride({ orgId, userId, orgWide: false }); + await deleteDefaultViewOverride({ orgId, userId, orgWide: true }); +} diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts index e176bae46..8cdcf76ef 100644 --- a/server/routers/launcher/listLauncherViews.ts +++ b/server/routers/launcher/listLauncherViews.ts @@ -4,22 +4,10 @@ import HttpCode from "@server/types/HttpCode"; import { and, eq, isNull, or } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import { launcherViewConfigSchema, type LauncherViewRecord } from "./types"; - -function mapViewRow( - row: typeof launcherViews.$inferSelect -): LauncherViewRecord { - return { - viewId: row.viewId, - orgId: row.orgId, - userId: row.userId, - name: row.name, - config: launcherViewConfigSchema.parse(JSON.parse(row.config)), - createdAt: row.createdAt, - updatedAt: row.updatedAt, - isOrgWide: row.userId == null - }; -} +import { + extractDefaultViewOverrides, + listVisibleLauncherViews +} from "./launcherDefaultView"; export async function listLauncherViews( req: Request, @@ -51,7 +39,8 @@ export async function listLauncherViews( return response(res, { data: { - views: rows.map(mapViewRow) + views: listVisibleLauncherViews(rows), + defaultViewOverrides: extractDefaultViewOverrides(rows) }, success: true, error: false, diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 2b7ee7f6e..9f9701c96 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -91,8 +91,28 @@ export type LauncherViewRecord = { isOrgWide: boolean; }; +export type LauncherDefaultViewOverrides = { + personal: LauncherViewRecord | null; + orgWide: LauncherViewRecord | null; +}; + +export function getEffectiveDefaultLauncherConfig( + overrides: LauncherDefaultViewOverrides +): LauncherViewConfig { + if (overrides.personal) { + return overrides.personal.config; + } + + if (overrides.orgWide) { + return overrides.orgWide.config; + } + + return defaultLauncherViewConfig; +} + export type ListLauncherViewsResponse = { views: LauncherViewRecord[]; + defaultViewOverrides: LauncherDefaultViewOverrides; }; export const launcherFilterListQuerySchema = z.strictObject({ @@ -161,6 +181,8 @@ export function parseIdListParam(value: string | undefined): number[] { export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; +export const LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME = "__default__"; + export type LauncherViewSelection = | { type: "default" } | { type: "saved"; viewId: number }; diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts index 9450da153..757a16dac 100644 --- a/server/routers/launcher/updateLauncherView.ts +++ b/server/routers/launcher/updateLauncherView.ts @@ -9,6 +9,7 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const updateLauncherViewBodySchema = z.strictObject({ @@ -66,6 +67,15 @@ export async function updateLauncherView( ); } + if (isLauncherDefaultOverrideViewName(existing.name)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Use the default view save endpoint for this view" + ) + ); + } + const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; const canManageOrgWide = await checkUserActionPermission( diff --git a/server/routers/launcher/upsertLauncherDefaultView.ts b/server/routers/launcher/upsertLauncherDefaultView.ts new file mode 100644 index 000000000..dd9c8bdba --- /dev/null +++ b/server/routers/launcher/upsertLauncherDefaultView.ts @@ -0,0 +1,82 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { upsertDefaultViewOverride } from "./launcherDefaultView"; +import { launcherViewConfigSchema } from "./types"; + +const upsertLauncherDefaultViewBodySchema = z.strictObject({ + config: launcherViewConfigSchema, + orgWide: z.boolean().optional().default(false) +}); + +export async function upsertLauncherDefaultView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + if (parsed.data.orgWide) { + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + } + + const view = await upsertDefaultViewOverride({ + orgId, + userId, + orgWide: parsed.data.orgWide, + config: parsed.data.config + }); + + return response(res, { + data: view, + success: true, + error: false, + message: "Launcher default view saved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error saving launcher default view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 0197a5502..d543b2752 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -82,6 +82,7 @@ export default async function OrgPage(props: OrgPageProps) { orgId={orgId} isAdmin={isAdminOrOwner} views={launcherData.views} + defaultViewOverrides={launcherData.defaultViewOverrides} activeViewId={launcherData.activeViewId} config={launcherData.config} savedConfig={launcherData.savedConfig} diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx index 2cf2a3cbb..fc10d4a7f 100644 --- a/src/components/resource-launcher/LauncherSettingsMenu.tsx +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -24,22 +24,18 @@ import { Settings } from "lucide-react"; type LauncherSettingsMenuProps = { config: LauncherViewConfig; - isDefaultView: boolean; capabilities: LauncherScaleCapabilities; isCompactMode: boolean; selectedGroupBy: LauncherViewConfig["groupBy"]; onConfigChange: (patch: Partial) => void; - onDeleteView: () => void; }; export function LauncherSettingsMenu({ config, - isDefaultView, capabilities, isCompactMode, selectedGroupBy, - onConfigChange, - onDeleteView + onConfigChange }: LauncherSettingsMenuProps) { const t = useTranslations(); @@ -157,16 +153,6 @@ export function LauncherSettingsMenu({ /> - - {!isDefaultView ? ( - - ) : null} diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx index 98d8f7289..0b0261560 100644 --- a/src/components/resource-launcher/LauncherViewTabs.tsx +++ b/src/components/resource-launcher/LauncherViewTabs.tsx @@ -68,11 +68,14 @@ type LauncherSaveViewMenuProps = { isAdmin: boolean; isOrgWideView: boolean; hasUnsavedChanges: boolean; + canResetSystemDefault: boolean; onSaveToCurrent: () => void; onSaveAsNew: () => void; onSaveForEveryone: () => void; onMakePersonal: () => void; onResetView: () => void; + onResetSystemDefault: () => void; + onDeleteView: () => void; }; export function LauncherSaveViewMenu({ @@ -80,13 +83,17 @@ export function LauncherSaveViewMenu({ isAdmin, isOrgWideView, hasUnsavedChanges, + canResetSystemDefault, onSaveToCurrent, onSaveAsNew, onSaveForEveryone, onMakePersonal, - onResetView + onResetView, + onResetSystemDefault, + onDeleteView }: LauncherSaveViewMenuProps) { const t = useTranslations(); + const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView); return ( @@ -108,7 +115,26 @@ export function LauncherSaveViewMenu({ ) : null} - {!isDefaultView && (isAdmin || !isOrgWideView) ? ( + {isDefaultView && canResetSystemDefault ? ( + <> + + {t("resourceLauncherResetSystemDefault")} + + + + ) : null} + {isDefaultView && hasUnsavedChanges ? ( + <> + + {t("resourceLauncherSaveDefaultPersonal")} + + {isAdmin ? ( + + {t("resourceLauncherSaveForEveryone")} + + ) : null} + + ) : !isDefaultView && (isAdmin || !isOrgWideView) ? ( {t("resourceLauncherSaveToCurrentView")} @@ -126,6 +152,17 @@ export function LauncherSaveViewMenu({ {t("resourceLauncherMakePersonal")} ) : null} + {canDeleteView ? ( + <> + + + {t("resourceLauncherDeleteView")} + + + ) : null} ); diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 23db498ea..9f9d44d11 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -36,11 +36,13 @@ import { shouldShowSearchFirstGate } from "@app/lib/launcherScale"; import { launcherQueries } from "@app/lib/queries"; -import type { - LauncherGroup, - LauncherScaleInfo, - LauncherViewConfig, - LauncherViewRecord +import { + getEffectiveDefaultLauncherConfig, + type LauncherDefaultViewOverrides, + type LauncherGroup, + type LauncherScaleInfo, + type LauncherViewConfig, + type LauncherViewRecord } from "@server/routers/launcher/types"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Search } from "lucide-react"; @@ -67,12 +69,14 @@ import { LauncherRefreshButton } from "./LauncherRefreshButton"; 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; @@ -89,6 +93,7 @@ export default function ResourceLauncher({ orgId, isAdmin, views, + defaultViewOverrides, activeViewId, config, savedConfig, @@ -107,6 +112,7 @@ export default function ResourceLauncher({ const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); @@ -173,13 +179,24 @@ export default function ResourceLauncher({ return; } - const baseConfig = getLauncherUrlBaseConfig(lastView, views); + const baseConfig = getLauncherUrlBaseConfig( + lastView, + views, + defaultViewOverrides + ); const params = serializeLauncherUrlState({ viewId: lastView, config: baseConfig }); navigate({ searchParams: params, replace: true }); - }, [activeViewId, navigate, orgId, searchParams, views]); + }, [ + activeViewId, + defaultViewOverrides, + navigate, + orgId, + searchParams, + views + ]); const navigateToConfig = useCallback( (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { @@ -202,10 +219,14 @@ export default function ResourceLauncher({ const selectView = useCallback( (viewId: LauncherActiveViewId) => { writeLauncherLastView(orgId, viewId); - const baseConfig = getLauncherUrlBaseConfig(viewId, views); + const baseConfig = getLauncherUrlBaseConfig( + viewId, + views, + defaultViewOverrides + ); navigateToConfig(viewId, baseConfig); }, - [navigateToConfig, orgId, views] + [defaultViewOverrides, navigateToConfig, orgId, views] ); const activeSavedView = useMemo( @@ -219,6 +240,10 @@ export default function ResourceLauncher({ 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( () => @@ -314,6 +339,87 @@ export default function ResourceLauncher({ } }); + 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}`); @@ -322,7 +428,11 @@ export default function ResourceLauncher({ writeLauncherLastView(orgId, "default"); const params = serializeLauncherUrlState({ viewId: "default", - config: getLauncherUrlBaseConfig("default", views) + config: getLauncherUrlBaseConfig( + "default", + views, + defaultViewOverrides + ) }); navigate({ searchParams: params, replace: true }); router.refresh(); @@ -372,6 +482,10 @@ export default function ResourceLauncher({ navigateToConfig(activeViewIdRef.current, savedConfig); }, [navigateToConfig, savedConfig]); + const handleResetSystemDefault = useCallback(() => { + resetSystemDefaultMutation.mutate(); + }, [resetSystemDefaultMutation]); + const refreshData = () => { startRefreshTransition(async () => { try { @@ -387,7 +501,14 @@ export default function ResourceLauncher({ }; const handleSaveToCurrent = () => { - if (isDefaultView || (isOrgWideView && !isAdmin)) { + if (isDefaultView) { + saveDefaultViewMutation.mutate({ + config, + orgWide: false + }); + return; + } + if (isOrgWideView && !isAdmin) { return; } updateViewMutation.mutate({ @@ -404,6 +525,10 @@ export default function ResourceLauncher({ const handleSaveForEveryone = () => { if (isDefaultView) { + saveDefaultViewMutation.mutate({ + config, + orgWide: true + }); return; } updateViewMutation.mutate({ @@ -456,19 +581,8 @@ export default function ResourceLauncher({ ); - const renderToolbarActions = () => ( + const renderToolbarFilterSort = () => ( <> - + + ); + + const renderToolbarActions = () => ( + <> + setDeleteDialogOpen(true)} + /> { - if (!isDefaultView) { - deleteViewMutation.mutate(activeViewId); - } - }} /> {renderToolbarSearch("w-64")} +
+ {renderToolbarFilterSort()} +
{renderToolbarViews()}
@@ -542,7 +672,12 @@ export default function ResourceLauncher({
{renderToolbarActions()}
- {renderToolbarSearch("w-full")} +
+
+ {renderToolbarSearch("w-full")} +
+ {renderToolbarFilterSort()} +
{renderToolbarViews()}
@@ -569,6 +704,22 @@ export default function ResourceLauncher({ /> ) : null} + {activeSavedView ? ( + {t("resourceLauncherDeleteViewQuestion")}

} + onConfirm={async () => { + await deleteViewMutation.mutateAsync( + activeSavedView.viewId + ); + }} + /> + ) : null} + diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts index 994ca2ff4..8abb0d2bc 100644 --- a/src/lib/launcherServerData.ts +++ b/src/lib/launcherServerData.ts @@ -6,6 +6,7 @@ import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import type { LauncherGroup, LauncherScaleInfo, + LauncherDefaultViewOverrides, LauncherViewConfig, LauncherViewRecord, ListLauncherGroupsResponse, @@ -16,6 +17,7 @@ import { AxiosResponse } from "axios"; export type LauncherPageData = { views: LauncherViewRecord[]; + defaultViewOverrides: LauncherDefaultViewOverrides; activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; @@ -36,17 +38,23 @@ export async function fetchLauncherPageData( > ): Promise { let views: LauncherViewRecord[] = []; + let defaultViewOverrides: LauncherDefaultViewOverrides = { + personal: null, + orgWide: null + }; try { const viewsRes = await internal.get< AxiosResponse >(`/org/${orgId}/launcher/views`, cookieHeader); views = viewsRes.data.data.views; + defaultViewOverrides = viewsRes.data.data.defaultViewOverrides; } catch (e) {} const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl( searchParams, views, - null + null, + defaultViewOverrides ); const scaleFilters = { @@ -110,6 +118,7 @@ export async function fetchLauncherPageData( return { views, + defaultViewOverrides, activeViewId, config, savedConfig, diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts index c3b2da568..dff904568 100644 --- a/src/lib/launcherUrlState.ts +++ b/src/lib/launcherUrlState.ts @@ -1,7 +1,9 @@ import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; import { defaultLauncherViewConfig, + getEffectiveDefaultLauncherConfig, parseIdListParam, + type LauncherDefaultViewOverrides, type LauncherViewConfig, type LauncherViewRecord } from "@server/routers/launcher/types"; @@ -64,10 +66,13 @@ export function isLauncherConfigEqual( export function getLauncherUrlBaseConfig( viewId: LauncherActiveViewId, - views: LauncherViewRecord[] + views: LauncherViewRecord[], + defaultViewOverrides?: LauncherDefaultViewOverrides ): LauncherViewConfig { if (viewId === "default") { - return defaultLauncherViewConfig; + return defaultViewOverrides + ? getEffectiveDefaultLauncherConfig(defaultViewOverrides) + : defaultLauncherViewConfig; } const savedView = views.find((view) => view.viewId === viewId); @@ -172,7 +177,8 @@ function isValidActiveViewId( export function resolveLauncherStateFromUrl( searchParams: URLSearchParams, views: LauncherViewRecord[], - fallbackViewId: LauncherActiveViewId | null + fallbackViewId: LauncherActiveViewId | null, + defaultViewOverrides?: LauncherDefaultViewOverrides ): ResolvedLauncherState { const parsed = parseLauncherUrlState(searchParams); @@ -188,18 +194,26 @@ export function resolveLauncherStateFromUrl( : "default"; } - const savedConfig = getLauncherUrlBaseConfig(activeViewId, views); + const savedConfig = getLauncherUrlBaseConfig( + activeViewId, + views, + defaultViewOverrides + ); let config: LauncherViewConfig; if (hasLauncherConfigParams(searchParams)) { config = resolveLauncherConfig( - defaultLauncherViewConfig, + getEffectiveDefaultLauncherConfig( + defaultViewOverrides ?? { personal: null, orgWide: null } + ), parsed.configOverrides ); } else if (activeViewId !== "default") { config = savedConfig; } else { - config = defaultLauncherViewConfig; + config = getEffectiveDefaultLauncherConfig( + defaultViewOverrides ?? { personal: null, orgWide: null } + ); } return { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 5ca6624d6..2626a3ba3 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1190,7 +1190,7 @@ export const launcherQueries = { const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/launcher/views`, { signal }); - return res.data.data.views; + return res.data.data; } }), sites: ({