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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ All notable changes to this project are documented in this file. The format is b

### Fixed

- The `Browse`, `Create` and `Edit` pages that `smpy create-module` (and `make new-module`) generates now render inside the app shell: each sets `<Page>.layout = [AuthenticatedLayout]`. Before, they had no sidebar, header or footer, so the module's sidebar entry led to a bare page with no way back. To fix a module generated earlier, add `import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';` and that assignment to each of its pages, or use `AdminLayout` for pages you moved under `/admin` and `ADMIN_SIDEBAR` (#897).
- A fresh `smpy new` app passes its own `make lint`. `host/alembic.ini` gains a `[post_write_hooks]` entry that runs the project's ruff (`check --select I --fix`, then `format`) on every new revision, so the initial-schema revision and each `make migration` output no longer fail `ruff format --check` and `ruff check` (this repo's `host/alembic.ini` has it too); a standalone `smpy create-host` project, which has no workspace root, gets a `dev` dependency group with `ruff` for the hook. `create-module` sorts the `module.py` constants import for any module name (it was unsorted for names that sort before `group`, such as `bookmarks`). The `# ty: ignore[invalid-assignment]` in `contracts/schemas.py` is generated only for a module inside this repo, where ty checks that rule, and new apps pin `ty>=0.0.90,<0.0.100`, since ty 0.0.100 renamed the `unresolved-attribute` rule the scaffold's config names. The hooks use Alembic's `module` runner, so `simple_module_db` and the host template now require `alembic>=1.16.3`. An existing app can copy the `[post_write_hooks]` block from a new app's `host/alembic.ini` (and raise its own `alembic` requirement to `>=1.16.3`), drop the unused ignore comments and pin ty the same way (#896).
- `make doctor` (and `smpy doctor`) now runs the migration checks it documents. It reads `alembic_version` and the table list from `SM_DATABASE_URL` and the heads from the project's `alembic.ini`: `SM010` is an error when the database lacks any head and a warning when it was never migrated or could not be read, and `SM011` reports a module table that a database at every head does not have. A development boot reports `SM011` too once its database is at head (or once the setup wizard has migrated it), and the Doctor screen keeps it on re-run. Doctor now resolves a relative SQLite `SM_DATABASE_URL` against the project root as the app does, so run from `host/` it reads the app's database for `SM033` too. Before, neither code was ever reported. **Behaviour change:** `make doctor` now exits 1 against a dev database that is behind head; run `make migrate` (#895).
- `background_tasks`: `SM_BG_TASKS_BROKER_URL` and `SM_BG_TASKS_RESULT_BACKEND` are reported as deprecated only when they hold a Redis URL (`redis`, `rediss`), the only values `SM_REDIS_URL` can replace. A production host that runs tasks in-process with `memory://` + `cache+memory://`, or uses an AMQP, SQS or `rpc://` transport, no longer logs a warning on every boot that it cannot act on, and the production localhost-broker error no longer calls those vars deprecated for non-Redis values (#868).
Expand Down
2 changes: 1 addition & 1 deletion docs/frontend/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Or use Inertia's persistent-layout pattern for layouts that survive navigation:
Browse.layout = (page: ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
```

The second form re-uses the layout instance across navigations — good for layouts with expensive setup or local state you want preserved.
The second form re-uses the layout instance across navigations — good for layouts with expensive setup or local state you want preserved. The array form `Browse.layout = [AuthenticatedLayout];` is what the module scaffold (`smpy create-module`) and the in-repo modules use. The host sets no default layout: a page that assigns none renders bare, without the sidebar or header.

### Which layout

Expand Down
13 changes: 13 additions & 0 deletions docs/superpowers/plans/2026-11-10-scaffold-page-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Plan: scaffolded module pages use AuthenticatedLayout (#897)

Design: `docs/superpowers/specs/2026-11-10-scaffold-page-layout-design.md`.

1. **Test first:** add `TestPageLayout` to `framework/cli/tests/test_scaffolding_module_js.py`,
checking both variants and all three pages.
2. **Templates:** add the `AuthenticatedLayout` import, in sorted position, and the
`.layout` assignment to the six `pages/*.tsx.tpl` files.
3. **Check:**
- run `biome check` on the rendered pages of both variants;
- in a real app, `create-module`, then the module's `npm run typecheck`;
- start `make dev` and open `/<module>/` in a browser: the sidebar must show.
4. **Docs:** add a CHANGELOG Fixed entry with the fix for existing modules.
45 changes: 45 additions & 0 deletions docs/superpowers/specs/2026-11-10-scaffold-page-layout-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Scaffolded module pages render inside the app shell (#897)

## Goal and scope

The `Browse`, `Create` and `Edit` pages that `smpy create-module` (and `make new-module`)
generates set no persistent Inertia layout. They render with no sidebar, header or
footer, so a user who clicks the module's sidebar entry is left on a bare page with no
way back.

In scope: both template variants, the app one (`templates/module/__PACKAGE__/pages/`) and
the framework-repo overlay (`_optional/framework_repo/__PACKAGE__/pages/`).

Out of scope: modules that were already generated. The CHANGELOG entry gives the
two-line fix for them.

## Approach

Each page imports `AuthenticatedLayout` from
`@simple-module-py/ui/layouts/AuthenticatedLayout` and sets
`<Page>.layout = [AuthenticatedLayout];`. This is the pattern the framework's own
non-admin module pages use (`modules/api_tokens/api_tokens/pages/Index.tsx:107`,
`modules/dashboard/dashboard/pages/Home.tsx`).

The scaffolded menu item is registered in `MenuSection.SIDEBAR`, which is what
`AuthenticatedLayout` renders, so the entry shows on its own pages.

Rejected: a default layout in the host's `client_app/app.tsx`. Every existing page
already picks its own layout (`AdminLayout`, `PublicLayout`, …), so a default would add
a second mechanism, and would wrap pages that deliberately use none (the login page).

## Decisions made

- `AuthenticatedLayout` rather than `AdminLayout`: the template's `view_prefix` is
`/<slug>` and its menu item goes in `SIDEBAR`, not `ADMIN_SIDEBAR`. CLAUDE.md requires
`/admin/*`, `ADMIN_SIDEBAR` and `AdminLayout` together.
- The layout import is placed in Biome's sorted order. The rendered pages of both
variants pass `biome check`.

## Testing

- `test_scaffolding_module_js.py::TestPageLayout` checks that every generated page in
both variants imports `AuthenticatedLayout` and sets `.layout`.
- `biome check` runs on the rendered pages of both variants.
- Real check in a scaffolded app: the module's `npm run typecheck` passes, and
`/<module>/` shows the sidebar in a browser.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Head, Link, router } from '@inertiajs/react';
import { PageShell } from '@simple-module-py/ui/components/PageShell';
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
import { apiResult } from '@simple-module-py/ui/lib/api';
import { useState } from 'react';
import { api } from './api';
Expand Down Expand Up @@ -55,3 +56,5 @@ export default function Browse({ items }: { items: Item[] }) {
</PageShell>
);
}

Browse.layout = [AuthenticatedLayout];
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Head, useForm } from '@inertiajs/react';
import { PageShell } from '@simple-module-py/ui/components/PageShell';
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';

export default function Create() {
const form = useForm({ name: '', description: '' });
Expand Down Expand Up @@ -41,3 +42,5 @@ export default function Create() {
</PageShell>
);
}

Create.layout = [AuthenticatedLayout];
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Head, useForm } from '@inertiajs/react';
import { PageShell } from '@simple-module-py/ui/components/PageShell';
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
import type { components } from './api.generated';

type Item = components['schemas']['{{SINGULAR_CLASS}}Out'];
Expand Down Expand Up @@ -44,3 +45,5 @@ export default function Edit({ item }: { item: Item }) {
</PageShell>
);
}

Edit.layout = [AuthenticatedLayout];
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Head, Link, router } from '@inertiajs/react';
import { keys, useT } from '@simple-module-py/i18n';
import { PageShell } from '@simple-module-py/ui/components/PageShell';
import { useApiErrorToast } from '@simple-module-py/ui/hooks/use-api-error-toast';
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
import { apiResult } from '@simple-module-py/ui/lib/api';
import { api } from './api';
import type { components } from './api.generated';
Expand Down Expand Up @@ -61,3 +62,5 @@ export default function Browse({ items }: { items: Item[] }) {
</PageShell>
);
}

Browse.layout = [AuthenticatedLayout];
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Head, useForm } from '@inertiajs/react';
import { keys, useT } from '@simple-module-py/i18n';
import { PageShell } from '@simple-module-py/ui/components/PageShell';
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';

export default function Create() {
const { t } = useT();
Expand Down Expand Up @@ -43,3 +44,5 @@ export default function Create() {
</PageShell>
);
}

Create.layout = [AuthenticatedLayout];
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Head, useForm } from '@inertiajs/react';
import { keys, useT } from '@simple-module-py/i18n';
import { PageShell } from '@simple-module-py/ui/components/PageShell';
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
import type { components } from './api.generated';

type Item = components['schemas']['{{SINGULAR_CLASS}}Out'];
Expand Down Expand Up @@ -46,3 +47,5 @@ export default function Edit({ item }: { item: Item }) {
</PageShell>
);
}

Edit.layout = [AuthenticatedLayout];
19 changes: 19 additions & 0 deletions framework/cli/tests/test_scaffolding_module_js.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import pytest


class TestModuleTsconfig:
async def test_tsconfig_resolves_ui_from_node_modules(self, tmp_path):
Expand Down Expand Up @@ -137,3 +139,20 @@ async def test_in_repo_keeps_workspace_configs(self, tmp_path):
pkg = (dest / "package.json").read_text(encoding="utf-8")
assert "typecheck" not in pkg
assert not (dest / ".github").exists()


class TestPageLayout:
"""GH #897: without a persistent layout a scaffolded page renders bare."""

@pytest.mark.parametrize("framework_repo", [False, True])
async def test_every_page_renders_inside_the_app_shell(self, tmp_path, framework_repo):
from simple_module_cli.scaffolding import create_module

dest = create_module(tmp_path / "orders", "orders", framework_repo=framework_repo)
for page in ("Browse", "Create", "Edit"):
source = (dest / "orders" / "pages" / f"{page}.tsx").read_text(encoding="utf-8")
assert (
"import { AuthenticatedLayout } from "
"'@simple-module-py/ui/layouts/AuthenticatedLayout';" in source
), page
assert f"{page}.layout = [AuthenticatedLayout];" in source, page
2 changes: 2 additions & 0 deletions skills/simple-module-inertia-pages/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Boot regenerates it too; mid-session adds need the manual call before HMR sees t

**Typing them in TSX.** A page receives its own props as typed component arguments (`export default function Browse({ items }: Props)`). Shared props are typed globally: `usePage().props.auth` is already a `SharedProps['auth']`. Components and layouts that need page props too use `usePageProps<Props>()` from `@simple-module-py/ui` (it returns `Props & SharedProps & { errors }`). Never write `usePage<{ props: X }>().props as unknown as X`; `make lint` rejects `as unknown as` outside tests unless the line carries `// cast-ok: <reason>`.

**A page sets its own layout.** The host applies none by default, so a page with no `Page.layout` renders without the sidebar, header and footer. Signed-in screens end the file with `Browse.layout = [AuthenticatedLayout];` (import from `@simple-module-py/ui/layouts/AuthenticatedLayout`); `/admin/*` pages use `AdminLayout`, public ones `PublicLayout`. The module scaffold generates this.

**`auth.user` is `None` when no `principal_serializer` is registered**, even if the user is authenticated. The framework can't know the shape of your user object. The `auth` module registers the callable during `register_settings(app)` (`modules/auth/auth/module.py`):

```python
Expand Down
Loading