Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/cool-badgers-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@tailor-platform/app-shell": minor
---

Add a built-in `/__appinfo` page to `AppShell` for exposing app metadata and the current AppShell version.

```tsx
<AppShell
title="My App"
appInfo={{
metadata: [
{ label: "Environment", value: "staging" },
{ label: "Release", value: "2026.07.16" },
],
}}
/>
```

The page stays out of AppShell's built-in auto-generated sidebar navigation, appears in the Command Palette as a page entry, and includes a copy button for the rendered app information.
92 changes: 92 additions & 0 deletions packages/core/src/components/app-info.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import packageJson from "../../package.json";
import { BuiltInCommandPalette } from "@/components/command-palette";
import {
AppShellConfigContext,
AppShellDataContext,
type RootConfiguration,
} from "@/contexts/appshell-context";
import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
import { CommandPaletteProvider } from "@/contexts/command-palette-context";
import { RouterContainer } from "@/routing/router";
import { Outlet } from "react-router";

afterEach(() => {
cleanup();
window.history.replaceState({}, "", "/");
});

const configurations: RootConfiguration = {
modules: [],
settingsResources: [],
locale: "ja",
errorBoundary: undefined,
};

const renderAppShell = (initialEntries: string[]) =>
render(
<AppShellConfigContext.Provider
value={{
title: "受発注システム",
appInfo: {
metadata: [
{ label: "環境", value: "staging" },
{ label: "リリース", value: "2026.07.16" },
],
},
configurations,
}}
>
<AppShellDataContext.Provider value={{ contextData: {} }}>
<BreadcrumbOverrideProvider>
<CommandPaletteProvider>
<RouterContainer memory initialEntries={initialEntries}>
<Outlet />
<BuiltInCommandPalette />
</RouterContainer>
</CommandPaletteProvider>
</BreadcrumbOverrideProvider>
</AppShellDataContext.Provider>
</AppShellConfigContext.Provider>,
);

describe("App info", () => {
it("opens /__appinfo from the command palette and renders built-in + app-defined rows", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

renderAppShell(["/"]);

fireEvent.keyDown(document, { key: "k", metaKey: true });

await screen.findByPlaceholderText("ページを検索...");
fireEvent.click(screen.getByText("/__appinfo").closest("button")!);

await screen.findAllByText("アプリ情報");
expect(screen.getByText("受発注システム")).toBeDefined();
expect(screen.getByText("staging")).toBeDefined();
expect(screen.getByText("2026.07.16")).toBeDefined();
expect(screen.getByText(packageJson.version)).toBeDefined();

fireEvent.click(screen.getByRole("button", { name: "コピー" }));

await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
[
"アプリ名: 受発注システム",
`AppShell バージョン: ${packageJson.version}`,
"環境: staging",
"リリース: 2026.07.16",
].join("\n"),
);
});

await waitFor(() => {
expect(document.title).toBe("アプリ情報 · 受発注システム");
});
});
});
83 changes: 83 additions & 0 deletions packages/core/src/components/app-info.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import packageJson from "../../package.json";
import { Button } from "@/components/button";
import { Card } from "@/components/card";
import { useAppShellConfig, type AppInfoEntry } from "@/contexts/appshell-context";
import { useOverrideBreadcrumb } from "@/hooks/use-override-breadcrumb";
import { useT } from "@/i18n-labels";
import type { NavigatableRoute } from "@/routing/path";

export const APP_INFO_SLUG = "__appinfo";
export const APP_INFO_PATH = `/${APP_INFO_SLUG}`;

const APP_SHELL_VERSION = packageJson.version;

const renderValue = (value: AppInfoEntry["value"]) => {
if (value === null || value === undefined) return "-";
return String(value);
};

const formatRowsForClipboard = (rows: AppInfoEntry[]) =>
rows.map((row) => `${row.label}: ${renderValue(row.value)}`).join("\n");

const AppInfoRow = ({ label, value }: AppInfoEntry) => (
<div className="astw:grid astw:gap-1 astw:py-3 astw:sm:grid-cols-[12rem_minmax(0,1fr)] astw:sm:gap-4">
<dt className="astw:text-sm astw:text-muted-foreground">{label}</dt>
<dd className="astw:min-w-0 astw:text-sm astw:break-all">{renderValue(value)}</dd>
</div>
);

export const useAppInfoPageRoute = (): NavigatableRoute => {
const t = useT();

return {
path: APP_INFO_PATH,
title: t("appInfoTitle"),
breadcrumb: [t("appInfoTitle")],
};
};

export const AppInfoPage = () => {
const t = useT();
const { title, appInfo } = useAppShellConfig();

useOverrideBreadcrumb(t("appInfoTitle"));

const rows: AppInfoEntry[] = [
...(title ? [{ label: t("appInfoAppName"), value: title }] : []),
{ label: t("appInfoAppShellVersion"), value: APP_SHELL_VERSION },
...(appInfo?.metadata ?? []),
];

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(formatRowsForClipboard(rows));
} catch {
// Silently fail if clipboard access is denied.
}
};

return (
<div className="astw:w-full astw:max-w-xl">
<Card.Root>
<div className="astw:flex astw:items-center astw:justify-between astw:px-6 astw:pt-6 astw:pb-4">
<h1 className="astw:text-lg astw:font-semibold astw:leading-none">{t("appInfoTitle")}</h1>
<Button variant="outline" size="sm" onClick={handleCopy}>
{t("appInfoCopy")}
</Button>
</div>
<Card.Content>
<dl>
{rows.map((row, index) => (
<div
key={`${row.label}-${index}`}
className={index > 0 ? "astw:border-t astw:border-border" : undefined}
>
<AppInfoRow {...row} />
</div>
))}
</dl>
</Card.Content>
</Card.Root>
</div>
);
};
19 changes: 17 additions & 2 deletions packages/core/src/components/appshell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
AppShellConfigContext,
AppShellDataContext,
buildConfigurations,
type AppInfo,
type ContextData,
} from "@/contexts/appshell-context";
import { RouterContainer } from "@/routing/router";
Expand Down Expand Up @@ -60,6 +61,14 @@ type SharedAppShellProps = React.PropsWithChildren<{
*/
favicon?: string;

/**
* Additional application metadata shown on the built-in `/__appinfo` page.
*
* AppShell always includes its own version there; use this prop to append
* app-defined rows such as environment, release, or commit.
*/
appInfo?: AppInfo;

/**
* Base path for the app shell
*/
Expand Down Expand Up @@ -327,9 +336,15 @@ export const AppShell = (props: AppShellProps) => {
const configValue = useMemo(
() =>
configurations
? { title: props.title, icon: props.icon, favicon: props.favicon, configurations }
? {
title: props.title,
icon: props.icon,
favicon: props.favicon,
appInfo: props.appInfo,
configurations,
}
: null,
[props.title, props.icon, props.favicon, configurations],
[props.title, props.icon, props.favicon, props.appInfo, configurations],
);

const dataValue = useMemo(
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/components/command-palette.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useReducer, useEffect, useMemo, useCallback, useRef, Suspense } from "react";
import { useNavigate, Await } from "react-router";
import { useAppInfoPageRoute } from "@/components/app-info";
import { SearchIcon, LoaderCircleIcon } from "lucide-react";
import { Dialog } from "@/components/dialog";
import { Input } from "@/components/input";
Expand Down Expand Up @@ -500,13 +501,17 @@ export function useCommandPalette({

type CommandPaletteContentProps = {
navItems: Array<NavItem>;
extraRoutes?: Array<NavigatableRoute>;
};

export function CommandPaletteContent({ navItems }: CommandPaletteContentProps) {
export function CommandPaletteContent({ navItems, extraRoutes = [] }: CommandPaletteContentProps) {
const t = useT();
const contextualActions = useCommandPaletteActions();
const { searchSources, open, setOpen, openRequest, clearOpenRequest } = useCommandPaletteState();
const routes = useMemo(() => navItemsToRoutes(navItems), [navItems]);
const routes = useMemo(
() => [...navItemsToRoutes(navItems), ...extraRoutes],
[extraRoutes, navItems],
);
const {
open: paletteOpen,
handleOpenChange,
Expand Down Expand Up @@ -748,11 +753,12 @@ export function CommandPalette(): React.ReactNode {
*/
export function BuiltInCommandPalette() {
const navItems = useNavItems();
const appInfoRoute = useAppInfoPageRoute();

return (
<Suspense fallback={null}>
<Await resolve={navItems}>
{(items) => <CommandPaletteContent navItems={items ?? []} />}
{(items) => <CommandPaletteContent navItems={items ?? []} extraRoutes={[appInfoRoute]} />}
</Await>
</Suspense>
);
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/contexts/appshell-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ export type RootConfiguration = {
timeZone?: string;
};

export type AppInfoEntry = {
/** Display label shown on the built-in "/__appinfo" page. */
label: string;
/** Primitive value rendered as text. Format complex values before passing them in. */
value: string | number | boolean | null | undefined;
};

export type AppInfo = {
/** Additional app-defined rows shown on the built-in "/__appinfo" page. */
metadata?: readonly AppInfoEntry[];
};

export type ConfigurationOptions = {
modules: Modules;
settingsResources?: Resource[];
Expand Down Expand Up @@ -105,6 +117,7 @@ type AppShellConfigContextType = {
title?: string;
icon?: ReactNode;
favicon?: string;
appInfo?: AppInfo;
configurations: RootConfiguration;
};

Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/i18n-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export const labels = defineI18nLabels({
commandPalettePages: "Pages",
commandPaletteSearchModes: "Search Modes",
commandPaletteSearching: "Searching...",
appInfoTitle: "App info",
appInfoDescription: "Information about the current app and AppShell.",
appInfoAppName: "App name",
appInfoAppShellVersion: "AppShell version",
appInfoCopy: "Copy",
},
ja: {
home: "ホーム",
Expand All @@ -50,6 +55,11 @@ export const labels = defineI18nLabels({
commandPalettePages: "ページ",
commandPaletteSearchModes: "検索モード",
commandPaletteSearching: "検索中...",
appInfoTitle: "アプリ情報",
appInfoDescription: "現在のアプリと AppShell の情報です。",
appInfoAppName: "アプリ名",
appInfoAppShellVersion: "AppShell バージョン",
appInfoCopy: "コピー",
},
});

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export {
useAppShellData,
useResolvedLocale,
useTimeZone,
type AppInfo,
type AppInfoEntry,
type TimeZone,
} from "./contexts/appshell-context";
export { useAppShellScrollContainer } from "./contexts/scroll-container-context";
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/routing/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,14 @@ describe("createContentRoutes", () => {
settingsResources: [settingsResource],
});

const indexSettingsRoute = routes[2];
expect(indexSettingsRoute.path).toBe("settings");
expect(indexSettingsRoute.index).toBe(true);
const indexSettingsRoute = routes.find((route) => route.path === "settings" && route.index);
expect(indexSettingsRoute?.path).toBe("settings");
expect(indexSettingsRoute?.index).toBe(true);

const settingsWrapperRoute = routes[3];
expect(settingsWrapperRoute.Component).toBe(SettingsWrapper);
const settingsWrapperRoute = routes.find((route) => route.Component === SettingsWrapper);
expect(settingsWrapperRoute?.Component).toBe(SettingsWrapper);

const childRoute = settingsWrapperRoute.children?.[0];
const childRoute = settingsWrapperRoute?.children?.[0];
expect(childRoute?.path).toBe("profile");
expect(typeof childRoute?.ErrorBoundary).toBe("function");
});
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/routing/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentType, ReactNode } from "react";
import { RouteObject, Navigate } from "react-router";
import { AppInfoPage, APP_INFO_SLUG } from "@/components/app-info";
import { EmptyOutlet, SettingsWrapper } from "@/components/content";
import { DefaultErrorBoundary } from "@/components/default-error-boundary";
import {
Expand Down Expand Up @@ -144,6 +145,10 @@ export const createContentRoutes = ({
{
children: routesFromModules(modules),
},
{
path: APP_INFO_SLUG,
Component: AppInfoPage,
},
...settingsRoutes,
{
path: "*",
Expand Down
1 change: 1 addition & 0 deletions packages/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"lib": ["ES2023", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"useDefineForClassFields": true,
"skipLibCheck": true,
"declaration": true,
Expand Down
Loading