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: ({