add save default view

This commit is contained in:
miloschwartz
2026-07-02 22:27:51 -04:00
parent 12f9ac94fd
commit f0546eb622
18 changed files with 665 additions and 75 deletions
+10 -3
View File
@@ -3571,12 +3571,16 @@
"memberPortalResourceDisabled": "Resource Disabled", "memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"resourceLauncherTitle": "Resource Launcher", "resourceLauncherTitle": "Resource Launcher",
"resourceLauncherDescription": "View resource details and launch them from one place", "resourceLauncherDescription": "View all available resources and launch them from one central hub",
"resourceLauncherSearchPlaceholder": "Search all sites...", "resourceLauncherSearchPlaceholder": "Search your resources...",
"resourceLauncherDefaultView": "Default", "resourceLauncherDefaultView": "Default",
"resourceLauncherSaveView": "Save View", "resourceLauncherSaveView": "Save View",
"resourceLauncherSaveToCurrentView": "Save to Current View", "resourceLauncherSaveToCurrentView": "Save to Current View",
"resourceLauncherSaveDefaultPersonal": "Save for Me",
"resourceLauncherResetView": "Reset View", "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", "resourceLauncherSaveAsNewView": "Save as New View",
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
"resourceLauncherSaveForEveryone": "Save for Everyone", "resourceLauncherSaveForEveryone": "Save for Everyone",
@@ -3598,6 +3602,9 @@
"resourceLauncherShowSiteTags": "Show Site Tags", "resourceLauncherShowSiteTags": "Show Site Tags",
"resourceLauncherShowRecents": "Show Recents", "resourceLauncherShowRecents": "Show Recents",
"resourceLauncherDeleteView": "Delete View", "resourceLauncherDeleteView": "Delete View",
"resourceLauncherDeleteViewTitle": "Delete View",
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
"resourceLauncherDeleteViewConfirm": "Delete View",
"resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherViewAsAdmin": "View as Admin",
"resourceLauncherResourceDetailsDescription": "View details for this resource.", "resourceLauncherResourceDetailsDescription": "View details for this resource.",
"resourceLauncherUnlabeled": "Unlabeled", "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.", "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.", "resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse", "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.", "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
+12
View File
@@ -512,6 +512,18 @@ authenticated.put(
launcher.updateLauncherView launcher.updateLauncherView
); );
authenticated.put(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.upsertLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.deleteLauncherDefaultView
);
authenticated.delete( authenticated.delete(
"/org/:orgId/launcher/views/:viewId", "/org/:orgId/launcher/views/:viewId",
verifyOrgAccess, verifyOrgAccess,
@@ -7,6 +7,7 @@ import moment from "moment";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { z } from "zod"; import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types"; import { launcherViewConfigSchema } from "./types";
const createLauncherViewBodySchema = z.strictObject({ 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) { if (parsed.data.orgWide) {
const canCreateOrgWide = await checkUserActionPermission( const canCreateOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView, ActionsEnum.createOrgWideLauncherView,
@@ -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<any> {
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"
)
);
}
}
@@ -6,6 +6,7 @@ import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView";
export async function deleteLauncherView( export async function deleteLauncherView(
req: Request, 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 isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null; const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission( const canManageOrgWide = await checkUserActionPermission(
+2
View File
@@ -8,3 +8,5 @@ export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView"; export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView"; export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView"; export { deleteLauncherView } from "./deleteLauncherView";
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
@@ -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<typeof launcherViews.$inferSelect>
): 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<typeof launcherViews.$inferSelect>
): 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 });
}
+6 -17
View File
@@ -4,22 +4,10 @@ import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types"; import {
extractDefaultViewOverrides,
function mapViewRow( listVisibleLauncherViews
row: typeof launcherViews.$inferSelect } from "./launcherDefaultView";
): 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 async function listLauncherViews( export async function listLauncherViews(
req: Request, req: Request,
@@ -51,7 +39,8 @@ export async function listLauncherViews(
return response(res, { return response(res, {
data: { data: {
views: rows.map(mapViewRow) views: listVisibleLauncherViews(rows),
defaultViewOverrides: extractDefaultViewOverrides(rows)
}, },
success: true, success: true,
error: false, error: false,
+22
View File
@@ -91,8 +91,28 @@ export type LauncherViewRecord = {
isOrgWide: boolean; 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 = { export type ListLauncherViewsResponse = {
views: LauncherViewRecord[]; views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
}; };
export const launcherFilterListQuerySchema = z.strictObject({ 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 DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export const LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME = "__default__";
export type LauncherViewSelection = export type LauncherViewSelection =
| { type: "default" } | { type: "default" }
| { type: "saved"; viewId: number }; | { type: "saved"; viewId: number };
@@ -9,6 +9,7 @@ import moment from "moment";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { z } from "zod"; import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types"; import { launcherViewConfigSchema } from "./types";
const updateLauncherViewBodySchema = z.strictObject({ 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 isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null; const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission( const canManageOrgWide = await checkUserActionPermission(
@@ -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<any> {
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"
)
);
}
}
+1
View File
@@ -82,6 +82,7 @@ export default async function OrgPage(props: OrgPageProps) {
orgId={orgId} orgId={orgId}
isAdmin={isAdminOrOwner} isAdmin={isAdminOrOwner}
views={launcherData.views} views={launcherData.views}
defaultViewOverrides={launcherData.defaultViewOverrides}
activeViewId={launcherData.activeViewId} activeViewId={launcherData.activeViewId}
config={launcherData.config} config={launcherData.config}
savedConfig={launcherData.savedConfig} savedConfig={launcherData.savedConfig}
@@ -24,22 +24,18 @@ import { Settings } from "lucide-react";
type LauncherSettingsMenuProps = { type LauncherSettingsMenuProps = {
config: LauncherViewConfig; config: LauncherViewConfig;
isDefaultView: boolean;
capabilities: LauncherScaleCapabilities; capabilities: LauncherScaleCapabilities;
isCompactMode: boolean; isCompactMode: boolean;
selectedGroupBy: LauncherViewConfig["groupBy"]; selectedGroupBy: LauncherViewConfig["groupBy"];
onConfigChange: (patch: Partial<LauncherViewConfig>) => void; onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
onDeleteView: () => void;
}; };
export function LauncherSettingsMenu({ export function LauncherSettingsMenu({
config, config,
isDefaultView,
capabilities, capabilities,
isCompactMode, isCompactMode,
selectedGroupBy, selectedGroupBy,
onConfigChange, onConfigChange
onDeleteView
}: LauncherSettingsMenuProps) { }: LauncherSettingsMenuProps) {
const t = useTranslations(); const t = useTranslations();
@@ -157,16 +153,6 @@ export function LauncherSettingsMenu({
/> />
</div> </div>
</div> </div>
{!isDefaultView ? (
<Button
variant="destructive"
className="w-full rounded-xl"
onClick={onDeleteView}
>
{t("resourceLauncherDeleteView")}
</Button>
) : null}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -68,11 +68,14 @@ type LauncherSaveViewMenuProps = {
isAdmin: boolean; isAdmin: boolean;
isOrgWideView: boolean; isOrgWideView: boolean;
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
canResetSystemDefault: boolean;
onSaveToCurrent: () => void; onSaveToCurrent: () => void;
onSaveAsNew: () => void; onSaveAsNew: () => void;
onSaveForEveryone: () => void; onSaveForEveryone: () => void;
onMakePersonal: () => void; onMakePersonal: () => void;
onResetView: () => void; onResetView: () => void;
onResetSystemDefault: () => void;
onDeleteView: () => void;
}; };
export function LauncherSaveViewMenu({ export function LauncherSaveViewMenu({
@@ -80,13 +83,17 @@ export function LauncherSaveViewMenu({
isAdmin, isAdmin,
isOrgWideView, isOrgWideView,
hasUnsavedChanges, hasUnsavedChanges,
canResetSystemDefault,
onSaveToCurrent, onSaveToCurrent,
onSaveAsNew, onSaveAsNew,
onSaveForEveryone, onSaveForEveryone,
onMakePersonal, onMakePersonal,
onResetView onResetView,
onResetSystemDefault,
onDeleteView
}: LauncherSaveViewMenuProps) { }: LauncherSaveViewMenuProps) {
const t = useTranslations(); const t = useTranslations();
const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView);
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -108,7 +115,26 @@ export function LauncherSaveViewMenu({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
) : null} ) : null}
{!isDefaultView && (isAdmin || !isOrgWideView) ? ( {isDefaultView && canResetSystemDefault ? (
<>
<DropdownMenuItem onSelect={onResetSystemDefault}>
{t("resourceLauncherResetSystemDefault")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
{isDefaultView && hasUnsavedChanges ? (
<>
<DropdownMenuItem onSelect={onSaveToCurrent}>
{t("resourceLauncherSaveDefaultPersonal")}
</DropdownMenuItem>
{isAdmin ? (
<DropdownMenuItem onSelect={onSaveForEveryone}>
{t("resourceLauncherSaveForEveryone")}
</DropdownMenuItem>
) : null}
</>
) : !isDefaultView && (isAdmin || !isOrgWideView) ? (
<DropdownMenuItem onSelect={onSaveToCurrent}> <DropdownMenuItem onSelect={onSaveToCurrent}>
{t("resourceLauncherSaveToCurrentView")} {t("resourceLauncherSaveToCurrentView")}
</DropdownMenuItem> </DropdownMenuItem>
@@ -126,6 +152,17 @@ export function LauncherSaveViewMenu({
{t("resourceLauncherMakePersonal")} {t("resourceLauncherMakePersonal")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{canDeleteView ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={onDeleteView}
className="text-destructive focus:text-destructive"
>
{t("resourceLauncherDeleteView")}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
@@ -36,11 +36,13 @@ import {
shouldShowSearchFirstGate shouldShowSearchFirstGate
} from "@app/lib/launcherScale"; } from "@app/lib/launcherScale";
import { launcherQueries } from "@app/lib/queries"; import { launcherQueries } from "@app/lib/queries";
import type { import {
LauncherGroup, getEffectiveDefaultLauncherConfig,
LauncherScaleInfo, type LauncherDefaultViewOverrides,
LauncherViewConfig, type LauncherGroup,
LauncherViewRecord type LauncherScaleInfo,
type LauncherViewConfig,
type LauncherViewRecord
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
@@ -67,12 +69,14 @@ import { LauncherRefreshButton } from "./LauncherRefreshButton";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton"; import { LauncherSortButton } from "./LauncherSortButton";
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type ResourceLauncherProps = { type ResourceLauncherProps = {
orgId: string; orgId: string;
isAdmin: boolean; isAdmin: boolean;
views: LauncherViewRecord[]; views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId; activeViewId: LauncherActiveViewId;
config: LauncherViewConfig; config: LauncherViewConfig;
savedConfig: LauncherViewConfig; savedConfig: LauncherViewConfig;
@@ -89,6 +93,7 @@ export default function ResourceLauncher({
orgId, orgId,
isAdmin, isAdmin,
views, views,
defaultViewOverrides,
activeViewId, activeViewId,
config, config,
savedConfig, savedConfig,
@@ -107,6 +112,7 @@ export default function ResourceLauncher({
const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [searchInputResetKey, setSearchInputResetKey] = useState(0);
const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [newViewName, setNewViewName] = useState(""); const [newViewName, setNewViewName] = useState("");
const [saveOrgWide, setSaveOrgWide] = useState(false); const [saveOrgWide, setSaveOrgWide] = useState(false);
@@ -173,13 +179,24 @@ export default function ResourceLauncher({
return; return;
} }
const baseConfig = getLauncherUrlBaseConfig(lastView, views); const baseConfig = getLauncherUrlBaseConfig(
lastView,
views,
defaultViewOverrides
);
const params = serializeLauncherUrlState({ const params = serializeLauncherUrlState({
viewId: lastView, viewId: lastView,
config: baseConfig config: baseConfig
}); });
navigate({ searchParams: params, replace: true }); navigate({ searchParams: params, replace: true });
}, [activeViewId, navigate, orgId, searchParams, views]); }, [
activeViewId,
defaultViewOverrides,
navigate,
orgId,
searchParams,
views
]);
const navigateToConfig = useCallback( const navigateToConfig = useCallback(
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
@@ -202,10 +219,14 @@ export default function ResourceLauncher({
const selectView = useCallback( const selectView = useCallback(
(viewId: LauncherActiveViewId) => { (viewId: LauncherActiveViewId) => {
writeLauncherLastView(orgId, viewId); writeLauncherLastView(orgId, viewId);
const baseConfig = getLauncherUrlBaseConfig(viewId, views); const baseConfig = getLauncherUrlBaseConfig(
viewId,
views,
defaultViewOverrides
);
navigateToConfig(viewId, baseConfig); navigateToConfig(viewId, baseConfig);
}, },
[navigateToConfig, orgId, views] [defaultViewOverrides, navigateToConfig, orgId, views]
); );
const activeSavedView = useMemo( const activeSavedView = useMemo(
@@ -219,6 +240,10 @@ export default function ResourceLauncher({
const isDefaultView = activeViewId === "default"; const isDefaultView = activeViewId === "default";
const isOrgWideView = Boolean(activeSavedView?.isOrgWide); const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig); const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
const canResetSystemDefault =
isDefaultView &&
(Boolean(defaultViewOverrides.personal) ||
(isAdmin && Boolean(defaultViewOverrides.orgWide)));
const selectedSites: Selectedsite[] = useMemo( 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({ const deleteViewMutation = useMutation({
mutationFn: async (viewId: number) => { mutationFn: async (viewId: number) => {
await api.delete(`/org/${orgId}/launcher/views/${viewId}`); await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
@@ -322,7 +428,11 @@ export default function ResourceLauncher({
writeLauncherLastView(orgId, "default"); writeLauncherLastView(orgId, "default");
const params = serializeLauncherUrlState({ const params = serializeLauncherUrlState({
viewId: "default", viewId: "default",
config: getLauncherUrlBaseConfig("default", views) config: getLauncherUrlBaseConfig(
"default",
views,
defaultViewOverrides
)
}); });
navigate({ searchParams: params, replace: true }); navigate({ searchParams: params, replace: true });
router.refresh(); router.refresh();
@@ -372,6 +482,10 @@ export default function ResourceLauncher({
navigateToConfig(activeViewIdRef.current, savedConfig); navigateToConfig(activeViewIdRef.current, savedConfig);
}, [navigateToConfig, savedConfig]); }, [navigateToConfig, savedConfig]);
const handleResetSystemDefault = useCallback(() => {
resetSystemDefaultMutation.mutate();
}, [resetSystemDefaultMutation]);
const refreshData = () => { const refreshData = () => {
startRefreshTransition(async () => { startRefreshTransition(async () => {
try { try {
@@ -387,7 +501,14 @@ export default function ResourceLauncher({
}; };
const handleSaveToCurrent = () => { const handleSaveToCurrent = () => {
if (isDefaultView || (isOrgWideView && !isAdmin)) { if (isDefaultView) {
saveDefaultViewMutation.mutate({
config,
orgWide: false
});
return;
}
if (isOrgWideView && !isAdmin) {
return; return;
} }
updateViewMutation.mutate({ updateViewMutation.mutate({
@@ -404,6 +525,10 @@ export default function ResourceLauncher({
const handleSaveForEveryone = () => { const handleSaveForEveryone = () => {
if (isDefaultView) { if (isDefaultView) {
saveDefaultViewMutation.mutate({
config,
orgWide: true
});
return; return;
} }
updateViewMutation.mutate({ updateViewMutation.mutate({
@@ -456,19 +581,8 @@ export default function ResourceLauncher({
</div> </div>
); );
const renderToolbarActions = () => ( const renderToolbarFilterSort = () => (
<> <>
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
onResetView={handleResetView}
/>
<LauncherFilterPopover <LauncherFilterPopover
orgId={orgId} orgId={orgId}
selectedSites={selectedSites} selectedSites={selectedSites}
@@ -492,18 +606,31 @@ export default function ResourceLauncher({
}) })
} }
/> />
</>
);
const renderToolbarActions = () => (
<>
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
canResetSystemDefault={canResetSystemDefault}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
onResetView={handleResetView}
onResetSystemDefault={handleResetSystemDefault}
onDeleteView={() => setDeleteDialogOpen(true)}
/>
<LauncherSettingsMenu <LauncherSettingsMenu
config={config} config={config}
isDefaultView={isDefaultView}
capabilities={scale.capabilities} capabilities={scale.capabilities}
isCompactMode={scale.mode === "compact"} isCompactMode={scale.mode === "compact"}
selectedGroupBy={effectiveConfig.groupBy} selectedGroupBy={effectiveConfig.groupBy}
onConfigChange={applyConfigPatch} onConfigChange={applyConfigPatch}
onDeleteView={() => {
if (!isDefaultView) {
deleteViewMutation.mutate(activeViewId);
}
}}
/> />
<LauncherRefreshButton <LauncherRefreshButton
onRefresh={refreshData} onRefresh={refreshData}
@@ -530,6 +657,9 @@ export default function ResourceLauncher({
{isDesktop ? ( {isDesktop ? (
<div className="mb-6 flex w-full min-w-0 items-center gap-3"> <div className="mb-6 flex w-full min-w-0 items-center gap-3">
{renderToolbarSearch("w-64")} {renderToolbarSearch("w-64")}
<div className="flex shrink-0 items-center gap-2">
{renderToolbarFilterSort()}
</div>
<div className="min-w-0 flex-1 overflow-x-auto"> <div className="min-w-0 flex-1 overflow-x-auto">
{renderToolbarViews()} {renderToolbarViews()}
</div> </div>
@@ -542,7 +672,12 @@ export default function ResourceLauncher({
<div className="flex items-center gap-2 overflow-x-auto"> <div className="flex items-center gap-2 overflow-x-auto">
{renderToolbarActions()} {renderToolbarActions()}
</div> </div>
{renderToolbarSearch("w-full")} <div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
{renderToolbarSearch("w-full")}
</div>
{renderToolbarFilterSort()}
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{renderToolbarViews()} {renderToolbarViews()}
</div> </div>
@@ -569,6 +704,22 @@ export default function ResourceLauncher({
/> />
) : null} ) : null}
{activeSavedView ? (
<ConfirmDeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
string={activeSavedView.name}
title={t("resourceLauncherDeleteViewTitle")}
buttonText={t("resourceLauncherDeleteViewConfirm")}
dialog={<p>{t("resourceLauncherDeleteViewQuestion")}</p>}
onConfirm={async () => {
await deleteViewMutation.mutateAsync(
activeSavedView.viewId
);
}}
/>
) : null}
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}> <Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<CredenzaContent> <CredenzaContent>
<CredenzaHeader> <CredenzaHeader>
+10 -1
View File
@@ -6,6 +6,7 @@ import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type { import type {
LauncherGroup, LauncherGroup,
LauncherScaleInfo, LauncherScaleInfo,
LauncherDefaultViewOverrides,
LauncherViewConfig, LauncherViewConfig,
LauncherViewRecord, LauncherViewRecord,
ListLauncherGroupsResponse, ListLauncherGroupsResponse,
@@ -16,6 +17,7 @@ import { AxiosResponse } from "axios";
export type LauncherPageData = { export type LauncherPageData = {
views: LauncherViewRecord[]; views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId; activeViewId: LauncherActiveViewId;
config: LauncherViewConfig; config: LauncherViewConfig;
savedConfig: LauncherViewConfig; savedConfig: LauncherViewConfig;
@@ -36,17 +38,23 @@ export async function fetchLauncherPageData(
> >
): Promise<LauncherPageData> { ): Promise<LauncherPageData> {
let views: LauncherViewRecord[] = []; let views: LauncherViewRecord[] = [];
let defaultViewOverrides: LauncherDefaultViewOverrides = {
personal: null,
orgWide: null
};
try { try {
const viewsRes = await internal.get< const viewsRes = await internal.get<
AxiosResponse<ListLauncherViewsResponse> AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, cookieHeader); >(`/org/${orgId}/launcher/views`, cookieHeader);
views = viewsRes.data.data.views; views = viewsRes.data.data.views;
defaultViewOverrides = viewsRes.data.data.defaultViewOverrides;
} catch (e) {} } catch (e) {}
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl( const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
searchParams, searchParams,
views, views,
null null,
defaultViewOverrides
); );
const scaleFilters = { const scaleFilters = {
@@ -110,6 +118,7 @@ export async function fetchLauncherPageData(
return { return {
views, views,
defaultViewOverrides,
activeViewId, activeViewId,
config, config,
savedConfig, savedConfig,
+20 -6
View File
@@ -1,7 +1,9 @@
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { import {
defaultLauncherViewConfig, defaultLauncherViewConfig,
getEffectiveDefaultLauncherConfig,
parseIdListParam, parseIdListParam,
type LauncherDefaultViewOverrides,
type LauncherViewConfig, type LauncherViewConfig,
type LauncherViewRecord type LauncherViewRecord
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
@@ -64,10 +66,13 @@ export function isLauncherConfigEqual(
export function getLauncherUrlBaseConfig( export function getLauncherUrlBaseConfig(
viewId: LauncherActiveViewId, viewId: LauncherActiveViewId,
views: LauncherViewRecord[] views: LauncherViewRecord[],
defaultViewOverrides?: LauncherDefaultViewOverrides
): LauncherViewConfig { ): LauncherViewConfig {
if (viewId === "default") { if (viewId === "default") {
return defaultLauncherViewConfig; return defaultViewOverrides
? getEffectiveDefaultLauncherConfig(defaultViewOverrides)
: defaultLauncherViewConfig;
} }
const savedView = views.find((view) => view.viewId === viewId); const savedView = views.find((view) => view.viewId === viewId);
@@ -172,7 +177,8 @@ function isValidActiveViewId(
export function resolveLauncherStateFromUrl( export function resolveLauncherStateFromUrl(
searchParams: URLSearchParams, searchParams: URLSearchParams,
views: LauncherViewRecord[], views: LauncherViewRecord[],
fallbackViewId: LauncherActiveViewId | null fallbackViewId: LauncherActiveViewId | null,
defaultViewOverrides?: LauncherDefaultViewOverrides
): ResolvedLauncherState { ): ResolvedLauncherState {
const parsed = parseLauncherUrlState(searchParams); const parsed = parseLauncherUrlState(searchParams);
@@ -188,18 +194,26 @@ export function resolveLauncherStateFromUrl(
: "default"; : "default";
} }
const savedConfig = getLauncherUrlBaseConfig(activeViewId, views); const savedConfig = getLauncherUrlBaseConfig(
activeViewId,
views,
defaultViewOverrides
);
let config: LauncherViewConfig; let config: LauncherViewConfig;
if (hasLauncherConfigParams(searchParams)) { if (hasLauncherConfigParams(searchParams)) {
config = resolveLauncherConfig( config = resolveLauncherConfig(
defaultLauncherViewConfig, getEffectiveDefaultLauncherConfig(
defaultViewOverrides ?? { personal: null, orgWide: null }
),
parsed.configOverrides parsed.configOverrides
); );
} else if (activeViewId !== "default") { } else if (activeViewId !== "default") {
config = savedConfig; config = savedConfig;
} else { } else {
config = defaultLauncherViewConfig; config = getEffectiveDefaultLauncherConfig(
defaultViewOverrides ?? { personal: null, orgWide: null }
);
} }
return { return {
+1 -1
View File
@@ -1190,7 +1190,7 @@ export const launcherQueries = {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListLauncherViewsResponse> AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, { signal }); >(`/org/${orgId}/launcher/views`, { signal });
return res.data.data.views; return res.data.data;
} }
}), }),
sites: ({ sites: ({