List the last visited workspace first in the rail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -61,6 +61,11 @@ export const AppData = {
|
||||
* reads it.
|
||||
*/
|
||||
LastVisitedBotCookieName: `${SiteData["SiteSlug"]}_last_visited_bot_id`,
|
||||
/**
|
||||
* The workspace this browser was last in: the rail lists it first. Never
|
||||
* where `/admin/bots` lands. `HttpOnly`: only the server reads it.
|
||||
*/
|
||||
LastVisitedWorkspaceCookieName: `${SiteData["SiteSlug"]}_last_visited_workspace_id`,
|
||||
LastVisitedCookieMaxAgeSeconds: 365 * 24 * 60 * 60,
|
||||
AuthExpiryDays: 60,
|
||||
APICrudBasePath: "/api/admin/crud",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { setCookies } from "@/src/utils/cookies";
|
||||
import isSecureRequest from "@/src/utils/is-secure-request";
|
||||
import lastVisitedWorkspaceCookies from "@/src/utils/last-visited-workspace-cookies";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
res: Response;
|
||||
workspace_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The page's response with the last-visited workspace cookie set, so the
|
||||
* rail lists this workspace first from the next page on. Called from the
|
||||
* workspace pages' `res_transform`.
|
||||
*/
|
||||
export default function withLastVisitedWorkspaceCookies({ req, res, workspace_id }: Params): Response {
|
||||
const new_res = res.clone();
|
||||
|
||||
setCookies(
|
||||
new_res,
|
||||
lastVisitedWorkspaceCookies({
|
||||
workspace_id,
|
||||
secure: isSecureRequest({ req }),
|
||||
}),
|
||||
);
|
||||
|
||||
return new_res;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import orderRailWorkspaces from "@/src/utils/order-rail-workspaces";
|
||||
import type { WorkspaceWithActivity } from "@/src/types";
|
||||
import { AddFlowContext } from "../(data)/add-flow-context";
|
||||
import AllWorkspacesModal from "./all-workspaces-modal";
|
||||
@@ -16,12 +18,17 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's workspaces, with a "+" to create one: the latest
|
||||
* `AppData.RailRecentWorkspaces`, then "See all workspaces" for the rest,
|
||||
* which lists them all in a modal.
|
||||
* The user's workspaces, with a "+" to create one: the last visited one
|
||||
* first, then the rest up to `AppData.RailRecentWorkspaces`, then "See all
|
||||
* workspaces" for the others, which lists them all in a modal.
|
||||
*/
|
||||
export default function RailWorkspacesSection({ workspaces, selected_id }: Props) {
|
||||
export default function RailWorkspacesSection({ workspaces: listed, selected_id }: Props) {
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const workspaces = orderRailWorkspaces({
|
||||
workspaces: listed,
|
||||
first_id: pageProps?.last_visited_workspace_id,
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
const shown = workspaces.slice(0, AppData.RailRecentWorkspaces);
|
||||
const hidden = workspaces.slice(AppData.RailRecentWorkspaces);
|
||||
|
||||
@@ -8,6 +8,7 @@ import isUnknownSettingsPath from "../utils/is-unknown-settings-path";
|
||||
import { SettingsDefaultPath } from "../dict/settings-pages-dict";
|
||||
import grabInstallName from "../functions/backend/db/app-settings/grab-install-name";
|
||||
import grabInstallIcon from "../functions/backend/db/app-settings/grab-install-icon";
|
||||
import readLastVisitedWorkspace from "../utils/read-last-visited-workspace";
|
||||
|
||||
/**
|
||||
* Routing for a local install. There are no public pages:
|
||||
@@ -84,6 +85,7 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
|
||||
user_types,
|
||||
install_name: await grabInstallName().catch(() => ""),
|
||||
install_icon: await grabInstallIcon().catch(() => null),
|
||||
last_visited_workspace_id: readLastVisitedWorkspace({ req }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
|
||||
import withLastVisitedBotCookies from "@/src/functions/server/with-last-visited-bot-cookies";
|
||||
import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
@@ -37,11 +38,16 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
}
|
||||
|
||||
const bot_id = props.bot.id;
|
||||
const workspace_id = props.workspace?.id;
|
||||
|
||||
return {
|
||||
props,
|
||||
res_transform(res) {
|
||||
return withLastVisitedBotCookies({ req: params.req, res, bot_id });
|
||||
const with_bot = withLastVisitedBotCookies({ req: params.req, res, bot_id });
|
||||
|
||||
return workspace_id
|
||||
? withLastVisitedWorkspaceCookies({ req: params.req, res: with_bot, workspace_id })
|
||||
: with_bot;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabChatroomMessages from "@/src/functions/backend/db/chatrooms/grab-chatroom-messages";
|
||||
import grabUserChatroom from "@/src/functions/backend/db/chatrooms/grab-user-chatroom";
|
||||
@@ -44,5 +45,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
chatroom: { ...chatroom, unread: 0 },
|
||||
chatroom_messages: await grabChatroomMessages({ chatroom_id: chatroom.id }),
|
||||
},
|
||||
res_transform(res) {
|
||||
return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id: Number(rail.workspace.id) });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserChatrooms from "@/src/functions/backend/db/chatrooms/grab-user-chatrooms";
|
||||
import grabWorkspaceRailProps from "@/src/functions/backend/db/workspaces/grab-workspace-rail-props";
|
||||
@@ -24,5 +25,10 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
|
||||
const chatrooms = await grabUserChatrooms({ user_id: user.id, workspace_id: Number(rail.workspace.id) });
|
||||
|
||||
return { props: { ...rail, chatrooms } };
|
||||
return {
|
||||
props: { ...rail, chatrooms },
|
||||
res_transform(res) {
|
||||
return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id: Number(rail.workspace.id) });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-workspace-activity";
|
||||
@@ -54,5 +55,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
workspace_activity,
|
||||
workspace_tasks,
|
||||
},
|
||||
res_transform(res) {
|
||||
return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,6 +36,10 @@ export const server: BunextPageServerFn = async ({ req }) => {
|
||||
name: AppData["LastVisitedBotCookieName"],
|
||||
httpOnly: true,
|
||||
},
|
||||
{
|
||||
name: AppData["LastVisitedWorkspaceCookieName"],
|
||||
httpOnly: true,
|
||||
},
|
||||
]);
|
||||
return new_res;
|
||||
},
|
||||
|
||||
@@ -1998,6 +1998,11 @@ export type PagePropsType = {
|
||||
* General, and the root's icon links).
|
||||
*/
|
||||
install_icon?: PwaInstallIcon | null;
|
||||
/**
|
||||
* The workspace this browser was last in (a cookie read in the root):
|
||||
* the rail lists it first.
|
||||
*/
|
||||
last_visited_workspace_id?: number | null;
|
||||
/**
|
||||
* The release the server is running, for the General settings page.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { CookieOptions } from "./cookies";
|
||||
|
||||
type Params = {
|
||||
workspace_id: number | string;
|
||||
/**
|
||||
* From `isSecureRequest`, so plain HTTP over a VPN still keeps it.
|
||||
*/
|
||||
secure: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The cookie that remembers the workspace this browser was last in, for
|
||||
* `setCookies`: read back by `readLastVisitedWorkspace` in the root, so the
|
||||
* rail lists that workspace first. It never decides where `/admin/bots`
|
||||
* lands. `HttpOnly`, since only the server reads it.
|
||||
*/
|
||||
export default function lastVisitedWorkspaceCookies({ workspace_id, secure }: Params): CookieOptions[] {
|
||||
return [
|
||||
{
|
||||
name: AppData["LastVisitedWorkspaceCookieName"],
|
||||
value: String(workspace_id),
|
||||
secure,
|
||||
maxAge: AppData["LastVisitedCookieMaxAgeSeconds"],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { WorkspaceWithActivity } from "@/src/types";
|
||||
import orderRailWorkspaces from "./order-rail-workspaces";
|
||||
|
||||
const workspaces = [1, 2, 3, 4].map((id) => ({ id, name: `W${id}` }) as WorkspaceWithActivity);
|
||||
|
||||
function ids(list: WorkspaceWithActivity[]) {
|
||||
return list.map((workspace) => workspace.id);
|
||||
}
|
||||
|
||||
describe("orderRailWorkspaces", () => {
|
||||
test("the last visited workspace leads, the rest keep their order", () => {
|
||||
expect(ids(orderRailWorkspaces({ workspaces, first_id: 3 }))).toEqual([3, 1, 2, 4]);
|
||||
});
|
||||
|
||||
test("unchanged without one, or when it isn't listed", () => {
|
||||
expect(ids(orderRailWorkspaces({ workspaces }))).toEqual([1, 2, 3, 4]);
|
||||
expect(ids(orderRailWorkspaces({ workspaces, first_id: null }))).toEqual([1, 2, 3, 4]);
|
||||
expect(ids(orderRailWorkspaces({ workspaces, first_id: 9 }))).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { WorkspaceWithActivity } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
workspaces: WorkspaceWithActivity[];
|
||||
/**
|
||||
* The workspace this browser was last in (`readLastVisitedWorkspace`).
|
||||
*/
|
||||
first_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The rail's workspaces with the last visited one first, the rest in
|
||||
* their order. Unchanged when it isn't among them.
|
||||
*/
|
||||
export default function orderRailWorkspaces({ workspaces, first_id }: Params): WorkspaceWithActivity[] {
|
||||
if (!first_id) return workspaces;
|
||||
|
||||
const first = workspaces.find((workspace) => Number(workspace.id) === Number(first_id));
|
||||
|
||||
if (!first) return workspaces;
|
||||
|
||||
return [first, ...workspaces.filter((workspace) => workspace !== first)];
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import parsePositiveId from "./parse-positive-id";
|
||||
|
||||
export type LastVisitedBot = {
|
||||
bot_id: number;
|
||||
};
|
||||
@@ -13,21 +15,9 @@ type Params = {
|
||||
* is the caller's check.
|
||||
*/
|
||||
export default function parseLastVisitedBot({ bot_id }: Params): LastVisitedBot | null {
|
||||
const bot = parseId(bot_id);
|
||||
const bot = parsePositiveId({ value: bot_id });
|
||||
|
||||
if (bot == null) return null;
|
||||
|
||||
return { bot_id: bot };
|
||||
}
|
||||
|
||||
function parseId(value?: string | number | null): number | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
|
||||
const raw = String(value).trim();
|
||||
|
||||
if (!/^\d+$/.test(raw)) return null;
|
||||
|
||||
const id = Number(raw);
|
||||
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
type Params = {
|
||||
value?: string | number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A positive whole number from a cookie or query value, or null when it
|
||||
* isn't one, so a hand-edited value never reaches a query. Surrounding
|
||||
* whitespace is trimmed.
|
||||
*/
|
||||
export default function parsePositiveId({ value }: Params): number | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
|
||||
const raw = String(value).trim();
|
||||
|
||||
if (!/^\d+$/.test(raw)) return null;
|
||||
|
||||
const id = Number(raw);
|
||||
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { getCookie } from "./cookies";
|
||||
import parsePositiveId from "./parse-positive-id";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
};
|
||||
|
||||
/**
|
||||
* The workspace this browser was last in, from the last-visited cookie
|
||||
* (see `lastVisitedWorkspaceCookies`), or null. Only orders the rail, so a
|
||||
* workspace the user no longer reaches just isn't in the list.
|
||||
*/
|
||||
export default function readLastVisitedWorkspace({ req }: Params): number | null {
|
||||
return parsePositiveId({
|
||||
value: getCookie(req, AppData["LastVisitedWorkspaceCookieName"]),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user