refactor: use X-style route paths
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Twitter Lite is an intentional, read-only X reader. It shows content only after
|
Twitter Lite is an intentional, read-only X reader. It shows content only after
|
||||||
you enter a user handle, profile URL, search query, or list URL, follow a
|
you enter a user handle, profile URL, search query, or list URL, follow a
|
||||||
deliberate post detail link, or manually open a valid `/status/:tweetId` URL.
|
deliberate post detail link, or manually open a valid
|
||||||
|
`/:handle/status/:tweetId` URL.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -21,6 +22,15 @@ history, account directory, or write actions.
|
|||||||
Post detail pages show only the selected post and its visible conversation;
|
Post detail pages show only the selected post and its visible conversation;
|
||||||
they do not add related-post recommendations or an account-discovery surface.
|
they do not add related-post recommendations or an account-discovery surface.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
- `/` shows the empty user input.
|
||||||
|
- `/:handle` shows a user timeline.
|
||||||
|
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` shows search results.
|
||||||
|
- `/i/lists` shows the authenticated account's lists.
|
||||||
|
- `/i/lists/:listId` shows a list timeline.
|
||||||
|
- `/:handle/status/:tweetId` shows one selected post and its visible conversation.
|
||||||
|
|
||||||
## Requirements and setup
|
## Requirements and setup
|
||||||
|
|
||||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ same intentional infinite-scroll behavior as the existing user and search
|
|||||||
feeds.
|
feeds.
|
||||||
|
|
||||||
This document originally extended `2026-07-13-twitter-lite-design.md` with the
|
This document originally extended `2026-07-13-twitter-lite-design.md` with the
|
||||||
third route, `/status/:tweetId`, and the third narrowly scoped read-only server
|
post-detail route, now `/:handle/status/:tweetId`, and the narrowly scoped
|
||||||
function, `loadThreadPosts`. The base design now incorporates both additions.
|
read-only server function, `loadThreadPosts`. The base design now incorporates
|
||||||
|
both additions.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
@@ -38,11 +39,13 @@ function, `loadThreadPosts`. The base design now incorporates both additions.
|
|||||||
Add the typed route:
|
Add the typed route:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/status/:tweetId
|
/:handle/status/:tweetId
|
||||||
```
|
```
|
||||||
|
|
||||||
`tweetId` is a non-empty decimal X post ID. Invalid IDs render a non-retryable
|
Generated links use the focal post author's normalized `handle`, and `tweetId`
|
||||||
input error and make no relay request.
|
is a non-empty decimal X post ID. Both path parameters are validated, while the
|
||||||
|
tweet lookup remains ID-based. Invalid parameters render a non-retryable input
|
||||||
|
error and make no relay request.
|
||||||
|
|
||||||
Every non-quoted post card gains an internal `詳細・スレッド` link in its
|
Every non-quoted post card gains an internal `詳細・スレッド` link in its
|
||||||
footer. The existing external `元の投稿を開く` link remains available. The
|
footer. The existing external `元の投稿を開く` link remains available. The
|
||||||
@@ -50,9 +53,9 @@ whole card is not clickable because cards can contain text links and media
|
|||||||
controls. Quoted cards retain their current compact presentation.
|
controls. Quoted cards retain their current compact presentation.
|
||||||
|
|
||||||
The status route uses the existing application shell. Neither `ユーザー` nor
|
The status route uses the existing application shell. Neither `ユーザー` nor
|
||||||
`検索` receives `aria-current` on this third route. The status page adds no
|
`検索` receives `aria-current` on the post-detail route. The status page adds
|
||||||
third global navigation tab; it is reachable only from a deliberate post link
|
no global navigation tab; it is reachable only from a deliberate post link or
|
||||||
or a manually entered URL.
|
a manually entered URL.
|
||||||
|
|
||||||
## Bird and Relay Boundary
|
## Bird and Relay Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ Twitter Lite is a localhost-only reader for intentional access to X posts. It le
|
|||||||
- enter a handle or profile URL and read that user's posts;
|
- enter a handle or profile URL and read that user's posts;
|
||||||
- search posts in popular or chronological order;
|
- search posts in popular or chronological order;
|
||||||
- optionally restrict search to accounts followed by the authenticated relay profile;
|
- optionally restrict search to accounts followed by the authenticated relay profile;
|
||||||
- follow a deliberate post link or manually enter a valid `/status/:tweetId` URL
|
- follow a deliberate post link or manually enter a valid `/:handle/status/:tweetId` URL
|
||||||
to read its visible conversation; and
|
to read its visible conversation; and
|
||||||
- continue through results with infinite scrolling.
|
- continue through results with infinite scrolling.
|
||||||
|
|
||||||
The product reduces ambient discovery rather than limiting how far a deliberate search may scroll. It shows content only after a submitted target or query, deliberate post-link navigation, or a manually entered valid `/status/:tweetId` URL.
|
The product reduces ambient discovery rather than limiting how far a deliberate search may scroll. It shows content only after a submitted target or query, deliberate list navigation, deliberate post-link navigation, or a manually entered valid `/:handle/status/:tweetId` URL.
|
||||||
|
|
||||||
## Product Principles
|
## Product Principles
|
||||||
|
|
||||||
1. **Intent before content.** The initial screen is empty. Every result begins with a submitted target or query, deliberate post-link navigation, or a manually entered valid `/status/:tweetId` URL.
|
1. **Intent before content.** The initial screen is empty. Every result begins with a submitted target or query, deliberate list navigation, deliberate post-link navigation, or a manually entered valid `/:handle/status/:tweetId` URL.
|
||||||
2. **No ambient feed.** There is no home timeline, recommendation surface, trend list, notification screen, or recent-history list.
|
2. **No ambient feed.** There is no home timeline, recommendation surface, trend list, notification screen, or recent-history list.
|
||||||
3. **Reading only.** The app exposes no posting, reply, follow, like, repost, bookmark, or deletion operation.
|
3. **Reading only.** The app exposes no posting, reply, follow, like, repost, bookmark, or deletion operation.
|
||||||
4. **No account directory.** Handles are unrestricted, but there is no autocomplete, suggested account list, or saved allowlist.
|
4. **No account directory.** Handles are unrestricted, but there is no autocomplete, suggested account list, or saved allowlist.
|
||||||
@@ -35,6 +35,7 @@ The product reduces ambient discovery rather than limiting how far a deliberate
|
|||||||
- raw X search syntax;
|
- raw X search syntax;
|
||||||
- `Top` and `Latest` search products;
|
- `Top` and `Latest` search products;
|
||||||
- `filter:follows` search filtering;
|
- `filter:follows` search filtering;
|
||||||
|
- authenticated account list selection and list timelines;
|
||||||
- deliberate post detail pages with cursor-paginated visible conversations;
|
- deliberate post detail pages with cursor-paginated visible conversations;
|
||||||
- photos, videos, animated GIFs, quoted posts, article previews, and engagement counts when supplied by Bird;
|
- photos, videos, animated GIFs, quoted posts, article previews, and engagement counts when supplied by Bird;
|
||||||
- infinite cursor pagination;
|
- infinite cursor pagination;
|
||||||
@@ -45,7 +46,7 @@ The product reduces ambient discovery rather than limiting how far a deliberate
|
|||||||
|
|
||||||
- authentication and multi-user accounts;
|
- authentication and multi-user accounts;
|
||||||
- databases, cookies, saved preferences, viewing history, and analytics;
|
- databases, cookies, saved preferences, viewing history, and analytics;
|
||||||
- home, notifications, trends, lists, bookmarks, and likes;
|
- home, notifications, trends, bookmarks, and likes;
|
||||||
- all X mutation operations;
|
- all X mutation operations;
|
||||||
- user autocomplete and typeahead;
|
- user autocomplete and typeahead;
|
||||||
- People and Media search products;
|
- People and Media search products;
|
||||||
@@ -105,14 +106,16 @@ TanStack Start supplies the browser/server boundary. Bird is imported only by se
|
|||||||
|
|
||||||
### Routes and URL State
|
### Routes and URL State
|
||||||
|
|
||||||
- `/user?target=<handle-or-url>` displays the User tab.
|
- `/` displays the empty User tab without fetching posts.
|
||||||
|
- `/:handle` displays the User tab for the normalized handle.
|
||||||
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` displays the Search tab.
|
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` displays the Search tab.
|
||||||
- `/status/:tweetId` displays one selected post and its visible conversation.
|
- `/i/lists` displays the authenticated account's lists.
|
||||||
- `/` redirects to `/user` without a target.
|
- `/i/lists/:listId` displays one selected list timeline.
|
||||||
|
- `/:handle/status/:tweetId` displays one selected post and its visible conversation.
|
||||||
|
|
||||||
The target, query, product, and following flag are validated typed search parameters. Cursor state is internal to TanStack Query because it is transport state rather than a user-controlled view setting.
|
The handle, list ID, and tweet ID are validated typed path parameters. The query, product, and following flag are validated typed search parameters. Cursor state is internal to TanStack Query because it is transport state rather than a user-controlled view setting.
|
||||||
|
|
||||||
Route preloading never fetches posts. Data retrieval begins only when a valid submitted target or query is present or a valid `/status/:tweetId` route is reached through a deliberate post link or manually entered URL.
|
Route preloading never fetches posts. Data retrieval begins only when a valid submitted handle or query is present, a list is deliberately selected, or a valid `/:handle/status/:tweetId` route is reached through a deliberate post link or manually entered URL.
|
||||||
|
|
||||||
### Server Boundary
|
### Server Boundary
|
||||||
|
|
||||||
@@ -206,7 +209,9 @@ The selected layout is **Focused Tabs**:
|
|||||||
|
|
||||||
The User tab contains one target input and a Display button. The Search tab contains a query input, a Popular/Latest segmented control, a Following only checkbox, and a Search button.
|
The User tab contains one target input and a Display button. The Search tab contains a query input, a Popular/Latest segmented control, a Following only checkbox, and a Search button.
|
||||||
|
|
||||||
Submitting updates the route's typed search parameters. Changing tabs does not preserve or surface the other tab's previous value.
|
Submitting a user target normalizes it to a handle and navigates to `/:handle`.
|
||||||
|
Submitting a search updates the route's typed search parameters. Changing tabs
|
||||||
|
does not preserve or surface the other tab's previous value.
|
||||||
|
|
||||||
### Visual Direction
|
### Visual Direction
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ export function AppShell({
|
|||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<p className="brand">TWITTER LITE</p>
|
<p className="brand">TWITTER LITE</p>
|
||||||
<nav aria-label="閲覧方法" className="tabs">
|
<nav aria-label="閲覧方法" className="tabs">
|
||||||
<Link
|
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
||||||
aria-current={active === 'user' ? 'page' : undefined}
|
|
||||||
search={{ target: '' }}
|
|
||||||
to="/user"
|
|
||||||
>
|
|
||||||
ユーザー
|
ユーザー
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -28,8 +24,7 @@ export function AppShell({
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
aria-current={active === 'list' ? 'page' : undefined}
|
aria-current={active === 'list' ? 'page' : undefined}
|
||||||
search={{ target: '' }}
|
to="/i/lists"
|
||||||
to="/list"
|
|
||||||
>
|
>
|
||||||
リスト
|
リスト
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export function ListPicker({ selectedId }: { selectedId?: string }) {
|
|||||||
<li key={list.id}>
|
<li key={list.id}>
|
||||||
<Link
|
<Link
|
||||||
aria-current={selectedId === list.id ? 'page' : undefined}
|
aria-current={selectedId === list.id ? 'page' : undefined}
|
||||||
search={{ target: list.id }}
|
params={{ listId: list.id }}
|
||||||
to="/list"
|
to="/i/lists/$listId"
|
||||||
>
|
>
|
||||||
<span>{list.name}</span>
|
<span>{list.name}</span>
|
||||||
<small>
|
<small>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe('PostCard', () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('link', { name: '詳細・スレッド' }),
|
screen.getByRole('link', { name: '詳細・スレッド' }),
|
||||||
).toHaveAttribute('href', '/status/123')
|
).toHaveAttribute('href', '/yuta/status/123')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks the focal post without linking to its current page', () => {
|
it('marks the focal post without linking to its current page', () => {
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export function PostCard({
|
|||||||
{post.likeCount ?? 0}
|
{post.likeCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
{!current ? (
|
{!current ? (
|
||||||
<a href={`/status/${encodeURIComponent(post.id)}`}>
|
<a
|
||||||
|
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||||
|
>
|
||||||
詳細・スレッド
|
詳細・スレッド
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
buildSearchQuery,
|
buildSearchQuery,
|
||||||
listRouteSearchSchema,
|
|
||||||
normalizeListTarget,
|
normalizeListTarget,
|
||||||
normalizeTweetId,
|
normalizeTweetId,
|
||||||
normalizeUserTarget,
|
normalizeUserTarget,
|
||||||
@@ -91,14 +90,6 @@ describe('normalizeListTarget', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('listRouteSearchSchema', () => {
|
|
||||||
it('keeps a numeric list target from a hand-entered URL', () => {
|
|
||||||
expect(listRouteSearchSchema.parse({ target: 1234567890 })).toEqual({
|
|
||||||
target: '1234567890',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('buildSearchQuery', () => {
|
describe('buildSearchQuery', () => {
|
||||||
it('keeps a deliberate query unchanged', () => {
|
it('keeps a deliberate query unchanged', () => {
|
||||||
expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja')
|
expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja')
|
||||||
|
|||||||
@@ -43,15 +43,6 @@ export const searchPageInputSchema = z.object({
|
|||||||
cursor: z.string().min(1).optional(),
|
cursor: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const userRouteSearchSchema = z.object({
|
|
||||||
target: z.string().catch(''),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const listRouteSearchSchema = z.object({
|
|
||||||
// List IDs are numeric, and the router JSON-parses numeric search values.
|
|
||||||
target: z.coerce.string().catch(''),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const postSearchRouteSchema = z.object({
|
export const postSearchRouteSchema = z.object({
|
||||||
q: z.string().catch(''),
|
q: z.string().catch(''),
|
||||||
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
||||||
|
|||||||
+86
-49
@@ -9,25 +9,21 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as UserRouteImport } from './routes/user'
|
|
||||||
import { Route as SearchRouteImport } from './routes/search'
|
import { Route as SearchRouteImport } from './routes/search'
|
||||||
import { Route as ListRouteImport } from './routes/list'
|
import { Route as HandleRouteImport } from './routes/$handle'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as StatusTweetIdRouteImport } from './routes/status.$tweetId'
|
import { Route as IListsRouteImport } from './routes/i.lists'
|
||||||
|
import { Route as IListsListIdRouteImport } from './routes/i.lists_.$listId'
|
||||||
|
import { Route as HandleStatusTweetIdRouteImport } from './routes/$handle_.status.$tweetId'
|
||||||
|
|
||||||
const UserRoute = UserRouteImport.update({
|
|
||||||
id: '/user',
|
|
||||||
path: '/user',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const SearchRoute = SearchRouteImport.update({
|
const SearchRoute = SearchRouteImport.update({
|
||||||
id: '/search',
|
id: '/search',
|
||||||
path: '/search',
|
path: '/search',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const ListRoute = ListRouteImport.update({
|
const HandleRoute = HandleRouteImport.update({
|
||||||
id: '/list',
|
id: '/$handle',
|
||||||
path: '/list',
|
path: '/$handle',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
@@ -35,59 +31,85 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const StatusTweetIdRoute = StatusTweetIdRouteImport.update({
|
const IListsRoute = IListsRouteImport.update({
|
||||||
id: '/status/$tweetId',
|
id: '/i/lists',
|
||||||
path: '/status/$tweetId',
|
path: '/i/lists',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IListsListIdRoute = IListsListIdRouteImport.update({
|
||||||
|
id: '/i/lists_/$listId',
|
||||||
|
path: '/i/lists/$listId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const HandleStatusTweetIdRoute = HandleStatusTweetIdRouteImport.update({
|
||||||
|
id: '/$handle_/status/$tweetId',
|
||||||
|
path: '/$handle/status/$tweetId',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/list': typeof ListRoute
|
'/$handle': typeof HandleRoute
|
||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/user': typeof UserRoute
|
'/i/lists': typeof IListsRoute
|
||||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||||
|
'/i/lists/$listId': typeof IListsListIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/list': typeof ListRoute
|
'/$handle': typeof HandleRoute
|
||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/user': typeof UserRoute
|
'/i/lists': typeof IListsRoute
|
||||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||||
|
'/i/lists/$listId': typeof IListsListIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/list': typeof ListRoute
|
'/$handle': typeof HandleRoute
|
||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/user': typeof UserRoute
|
'/i/lists': typeof IListsRoute
|
||||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
'/$handle_/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||||
|
'/i/lists_/$listId': typeof IListsListIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/list' | '/search' | '/user' | '/status/$tweetId'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/$handle'
|
||||||
|
| '/search'
|
||||||
|
| '/i/lists'
|
||||||
|
| '/$handle/status/$tweetId'
|
||||||
|
| '/i/lists/$listId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/list' | '/search' | '/user' | '/status/$tweetId'
|
to:
|
||||||
id: '__root__' | '/' | '/list' | '/search' | '/user' | '/status/$tweetId'
|
| '/'
|
||||||
|
| '/$handle'
|
||||||
|
| '/search'
|
||||||
|
| '/i/lists'
|
||||||
|
| '/$handle/status/$tweetId'
|
||||||
|
| '/i/lists/$listId'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/$handle'
|
||||||
|
| '/search'
|
||||||
|
| '/i/lists'
|
||||||
|
| '/$handle_/status/$tweetId'
|
||||||
|
| '/i/lists_/$listId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
ListRoute: typeof ListRoute
|
HandleRoute: typeof HandleRoute
|
||||||
SearchRoute: typeof SearchRoute
|
SearchRoute: typeof SearchRoute
|
||||||
UserRoute: typeof UserRoute
|
IListsRoute: typeof IListsRoute
|
||||||
StatusTweetIdRoute: typeof StatusTweetIdRoute
|
HandleStatusTweetIdRoute: typeof HandleStatusTweetIdRoute
|
||||||
|
IListsListIdRoute: typeof IListsListIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/user': {
|
|
||||||
id: '/user'
|
|
||||||
path: '/user'
|
|
||||||
fullPath: '/user'
|
|
||||||
preLoaderRoute: typeof UserRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/search': {
|
'/search': {
|
||||||
id: '/search'
|
id: '/search'
|
||||||
path: '/search'
|
path: '/search'
|
||||||
@@ -95,11 +117,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SearchRouteImport
|
preLoaderRoute: typeof SearchRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/list': {
|
'/$handle': {
|
||||||
id: '/list'
|
id: '/$handle'
|
||||||
path: '/list'
|
path: '/$handle'
|
||||||
fullPath: '/list'
|
fullPath: '/$handle'
|
||||||
preLoaderRoute: typeof ListRouteImport
|
preLoaderRoute: typeof HandleRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/': {
|
'/': {
|
||||||
@@ -109,11 +131,25 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/status/$tweetId': {
|
'/i/lists': {
|
||||||
id: '/status/$tweetId'
|
id: '/i/lists'
|
||||||
path: '/status/$tweetId'
|
path: '/i/lists'
|
||||||
fullPath: '/status/$tweetId'
|
fullPath: '/i/lists'
|
||||||
preLoaderRoute: typeof StatusTweetIdRouteImport
|
preLoaderRoute: typeof IListsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/i/lists_/$listId': {
|
||||||
|
id: '/i/lists_/$listId'
|
||||||
|
path: '/i/lists/$listId'
|
||||||
|
fullPath: '/i/lists/$listId'
|
||||||
|
preLoaderRoute: typeof IListsListIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/$handle_/status/$tweetId': {
|
||||||
|
id: '/$handle_/status/$tweetId'
|
||||||
|
path: '/$handle/status/$tweetId'
|
||||||
|
fullPath: '/$handle/status/$tweetId'
|
||||||
|
preLoaderRoute: typeof HandleStatusTweetIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,10 +157,11 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ListRoute: ListRoute,
|
HandleRoute: HandleRoute,
|
||||||
SearchRoute: SearchRoute,
|
SearchRoute: SearchRoute,
|
||||||
UserRoute: UserRoute,
|
IListsRoute: IListsRoute,
|
||||||
StatusTweetIdRoute: StatusTweetIdRoute,
|
HandleStatusTweetIdRoute: HandleStatusTweetIdRoute,
|
||||||
|
IListsListIdRoute: IListsListIdRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { UserPage } from './index'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/$handle')({
|
||||||
|
component: UserRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function UserRoute() {
|
||||||
|
const { handle } = Route.useParams()
|
||||||
|
return <UserPage handle={handle} />
|
||||||
|
}
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { AppShell } from '#/components/app-shell'
|
import { AppShell } from '#/components/app-shell'
|
||||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||||
import { InputError, normalizeTweetId } from '#/features/posts/inputs'
|
import {
|
||||||
|
InputError,
|
||||||
|
normalizeTweetId,
|
||||||
|
normalizeUserTarget,
|
||||||
|
} from '#/features/posts/inputs'
|
||||||
|
|
||||||
export const Route = createFileRoute('/status/$tweetId')({
|
export const Route = createFileRoute('/$handle_/status/$tweetId')({
|
||||||
component: StatusRoute,
|
component: StatusRoute,
|
||||||
})
|
})
|
||||||
|
|
||||||
function StatusRoute() {
|
function StatusRoute() {
|
||||||
const { tweetId: rawTweetId } = Route.useParams()
|
const { handle: rawHandle, tweetId: rawTweetId } = Route.useParams()
|
||||||
let tweetId: string | undefined
|
let tweetId: string | undefined
|
||||||
let error: string | undefined
|
let error: string | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
normalizeUserTarget(rawHandle)
|
||||||
tweetId = normalizeTweetId(rawTweetId)
|
tweetId = normalizeTweetId(rawTweetId)
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error =
|
error =
|
||||||
cause instanceof InputError
|
cause instanceof InputError ? cause.message : 'URL を確認してください。'
|
||||||
? cause.message
|
|
||||||
: '投稿 ID を確認してください。'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,7 +72,7 @@ describe('post feed route wiring', () => {
|
|||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
it('keeps an empty user URL disabled', async () => {
|
it('keeps an empty user URL disabled', async () => {
|
||||||
await renderRoute('/user?target=')
|
await renderRoute('/')
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }),
|
await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }),
|
||||||
@@ -81,7 +81,7 @@ describe('post feed route wiring', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('turns a manually entered user URL into a deliberate request', async () => {
|
it('turns a manually entered user URL into a deliberate request', async () => {
|
||||||
await renderRoute('/user?target=yuta')
|
await renderRoute('/yuta')
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(usePostFeed).toHaveBeenCalledWith({
|
expect(usePostFeed).toHaveBeenCalledWith({
|
||||||
@@ -91,8 +91,17 @@ describe('post feed route wiring', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps an invalid user path local and idle', async () => {
|
||||||
|
await renderRoute('/not-a-handle')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'ハンドルは英数字とアンダースコアで入力してください。',
|
||||||
|
)
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps an empty list URL disabled', async () => {
|
it('keeps an empty list URL disabled', async () => {
|
||||||
await renderRoute('/list?target=')
|
await renderRoute('/i/lists')
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('heading', { name: 'どのリストを読みますか?' }),
|
await screen.findByRole('heading', { name: 'どのリストを読みますか?' }),
|
||||||
@@ -101,7 +110,7 @@ describe('post feed route wiring', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('turns a manually entered list URL into a deliberate request', async () => {
|
it('turns a manually entered list URL into a deliberate request', async () => {
|
||||||
await renderRoute('/list?target=1234567890')
|
await renderRoute('/i/lists/1234567890')
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(usePostFeed).toHaveBeenCalledWith({
|
expect(usePostFeed).toHaveBeenCalledWith({
|
||||||
@@ -114,8 +123,17 @@ describe('post feed route wiring', () => {
|
|||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps an invalid list path local and idle', async () => {
|
||||||
|
await renderRoute('/i/lists/not-a-list')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'リスト ID は数字で入力してください。',
|
||||||
|
)
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
it('turns a selected authenticated list into a deliberate request', async () => {
|
it('turns a selected authenticated list into a deliberate request', async () => {
|
||||||
await renderRoute('/list?target=')
|
await renderRoute('/i/lists')
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole('link', { name: /My List.*10 メンバー/ }),
|
await screen.findByRole('link', { name: /My List.*10 メンバー/ }),
|
||||||
@@ -143,7 +161,7 @@ describe('post feed route wiring', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('turns a decimal status path into a deliberate thread request', async () => {
|
it('turns a decimal status path into a deliberate thread request', async () => {
|
||||||
await renderRoute('/status/123')
|
await renderRoute('/yuta/status/123')
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible()
|
expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible()
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -160,12 +178,21 @@ describe('post feed route wiring', () => {
|
|||||||
).toBeNull()
|
).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps an invalid status path local and idle', async () => {
|
it('keeps an invalid tweet path local and idle', async () => {
|
||||||
await renderRoute('/status/not-a-tweet')
|
await renderRoute('/yuta/status/not-a-tweet')
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
'投稿 ID を確認してください。',
|
'投稿 ID を確認してください。',
|
||||||
)
|
)
|
||||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps an invalid status handle local and idle', async () => {
|
||||||
|
await renderRoute('/not-a-handle/status/123')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'ハンドルは英数字とアンダースコアで入力してください。',
|
||||||
|
)
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { AppShell } from '#/components/app-shell'
|
||||||
|
import { ListForm } from '#/features/posts/components/list-form'
|
||||||
|
import { ListPicker } from '#/features/posts/components/list-picker'
|
||||||
|
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||||
|
import { InputError, normalizeListTarget } from '#/features/posts/inputs'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/i/lists')({
|
||||||
|
component: ListIndexRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ListIndexRoute() {
|
||||||
|
return <ListPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListPage({ listId = '' }: { listId?: string }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
let normalizedListId: string | undefined
|
||||||
|
let error: string | undefined
|
||||||
|
|
||||||
|
if (listId) {
|
||||||
|
try {
|
||||||
|
normalizedListId = normalizeListTarget(listId)
|
||||||
|
} catch (cause) {
|
||||||
|
error =
|
||||||
|
cause instanceof InputError
|
||||||
|
? cause.message
|
||||||
|
: 'リスト ID を確認してください。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell active="list">
|
||||||
|
<h1>どのリストを読みますか?</h1>
|
||||||
|
<p className="intro">
|
||||||
|
自分のリストを選ぶか、リストURLを入力してください。
|
||||||
|
</p>
|
||||||
|
<ListPicker selectedId={listId || undefined} />
|
||||||
|
<ListForm
|
||||||
|
initialTarget={listId}
|
||||||
|
key={listId}
|
||||||
|
onSubmit={(nextListId) =>
|
||||||
|
navigate({
|
||||||
|
to: '/i/lists/$listId',
|
||||||
|
params: { listId: nextListId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<p className="state" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<PostFeed
|
||||||
|
request={
|
||||||
|
normalizedListId
|
||||||
|
? { kind: 'list', target: normalizedListId }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { ListPage } from './i.lists'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/i/lists_/$listId')({
|
||||||
|
component: ListRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ListRoute() {
|
||||||
|
const { listId } = Route.useParams()
|
||||||
|
return <ListPage listId={listId} />
|
||||||
|
}
|
||||||
+53
-4
@@ -1,7 +1,56 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { AppShell } from '#/components/app-shell'
|
||||||
|
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||||
|
import { UserForm } from '#/features/posts/components/user-form'
|
||||||
|
import { InputError, normalizeUserTarget } from '#/features/posts/inputs'
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
beforeLoad: () => {
|
component: UserIndexRoute,
|
||||||
throw redirect({ to: '/user', search: { target: '' } })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function UserIndexRoute() {
|
||||||
|
return <UserPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserPage({ handle = '' }: { handle?: string }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
let normalizedHandle: string | undefined
|
||||||
|
let error: string | undefined
|
||||||
|
|
||||||
|
if (handle) {
|
||||||
|
try {
|
||||||
|
normalizedHandle = normalizeUserTarget(handle)
|
||||||
|
} catch (cause) {
|
||||||
|
error =
|
||||||
|
cause instanceof InputError
|
||||||
|
? cause.message
|
||||||
|
: 'ハンドルを確認してください。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell active="user">
|
||||||
|
<h1>誰の投稿を見ますか?</h1>
|
||||||
|
<p className="intro">履歴やおすすめは表示しません。</p>
|
||||||
|
<UserForm
|
||||||
|
initialTarget={handle}
|
||||||
|
key={handle}
|
||||||
|
onSubmit={(nextHandle) =>
|
||||||
|
navigate({ to: '/$handle', params: { handle: nextHandle } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<p className="state" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<PostFeed
|
||||||
|
request={
|
||||||
|
normalizedHandle
|
||||||
|
? { kind: 'user', target: normalizedHandle }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { AppShell } from '#/components/app-shell'
|
|
||||||
import { ListForm } from '#/features/posts/components/list-form'
|
|
||||||
import { ListPicker } from '#/features/posts/components/list-picker'
|
|
||||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
|
||||||
import { listRouteSearchSchema } from '#/features/posts/inputs'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/list')({
|
|
||||||
validateSearch: (search) => listRouteSearchSchema.parse(search),
|
|
||||||
component: ListRoute,
|
|
||||||
})
|
|
||||||
|
|
||||||
function ListRoute() {
|
|
||||||
const { target } = Route.useSearch()
|
|
||||||
const navigate = Route.useNavigate()
|
|
||||||
return (
|
|
||||||
<AppShell active="list">
|
|
||||||
<h1>どのリストを読みますか?</h1>
|
|
||||||
<p className="intro">
|
|
||||||
自分のリストを選ぶか、リストURLを入力してください。
|
|
||||||
</p>
|
|
||||||
<ListPicker selectedId={target || undefined} />
|
|
||||||
<ListForm
|
|
||||||
initialTarget={target}
|
|
||||||
key={target}
|
|
||||||
onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })}
|
|
||||||
/>
|
|
||||||
<PostFeed request={target ? { kind: 'list', target } : undefined} />
|
|
||||||
</AppShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { AppShell } from '#/components/app-shell'
|
|
||||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
|
||||||
import { UserForm } from '#/features/posts/components/user-form'
|
|
||||||
import { userRouteSearchSchema } from '#/features/posts/inputs'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/user')({
|
|
||||||
validateSearch: (search) => userRouteSearchSchema.parse(search),
|
|
||||||
component: UserRoute,
|
|
||||||
})
|
|
||||||
|
|
||||||
function UserRoute() {
|
|
||||||
const { target } = Route.useSearch()
|
|
||||||
const navigate = Route.useNavigate()
|
|
||||||
return (
|
|
||||||
<AppShell active="user">
|
|
||||||
<h1>誰の投稿を見ますか?</h1>
|
|
||||||
<p className="intro">履歴やおすすめは表示しません。</p>
|
|
||||||
<UserForm
|
|
||||||
initialTarget={target}
|
|
||||||
key={target}
|
|
||||||
onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })}
|
|
||||||
/>
|
|
||||||
<PostFeed request={target ? { kind: 'user', target } : undefined} />
|
|
||||||
</AppShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -44,7 +44,7 @@ test('requires intent and infinitely loads a user timeline', async ({
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await openReader(page, '/')
|
await openReader(page, '/')
|
||||||
await expect(page).toHaveURL(/\/user\?target=$/)
|
await expect(page).toHaveURL(/\/$/)
|
||||||
await expect(page.locator('article')).toHaveCount(0)
|
await expect(page.locator('article')).toHaveCount(0)
|
||||||
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
|
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
|
||||||
0,
|
0,
|
||||||
@@ -53,6 +53,7 @@ test('requires intent and infinitely loads a user timeline', async ({
|
|||||||
await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta')
|
await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta')
|
||||||
await page.getByRole('button', { name: '表示' }).click()
|
await page.getByRole('button', { name: '表示' }).click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/yuta$/)
|
||||||
await expect(page.getByText('user page 1')).toBeVisible()
|
await expect(page.getByText('user page 1')).toBeVisible()
|
||||||
await expect(page.getByText('user page 2')).toBeVisible()
|
await expect(page.getByText('user page 2')).toBeVisible()
|
||||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||||
@@ -65,12 +66,12 @@ test('requires intent and infinitely loads a user timeline', async ({
|
|||||||
test('selects an authenticated list and infinitely loads its timeline', async ({
|
test('selects an authenticated list and infinitely loads its timeline', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await openReader(page, '/list?target=')
|
await openReader(page, '/i/lists')
|
||||||
await expect(page.locator('article')).toHaveCount(0)
|
await expect(page.locator('article')).toHaveCount(0)
|
||||||
|
|
||||||
await page.getByRole('link', { name: /My E2E List.*10 メンバー/ }).click()
|
await page.getByRole('link', { name: /My E2E List.*10 メンバー/ }).click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/list\?target=(?:%22)?1234567890/)
|
await expect(page).toHaveURL(/\/i\/lists\/1234567890$/)
|
||||||
await expect(page.getByText('list page 1')).toBeVisible()
|
await expect(page.getByText('list page 1')).toBeVisible()
|
||||||
await expect(page.getByText('list page 2')).toBeVisible()
|
await expect(page.getByText('list page 2')).toBeVisible()
|
||||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||||
@@ -79,13 +80,13 @@ test('selects an authenticated list and infinitely loads its timeline', async ({
|
|||||||
test('opens a card detail and infinitely loads its conversation', async ({
|
test('opens a card detail and infinitely loads its conversation', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await openReader(page, '/user?target=yuta')
|
await openReader(page, '/yuta')
|
||||||
const sourceCard = page
|
const sourceCard = page
|
||||||
.getByRole('article')
|
.getByRole('article')
|
||||||
.filter({ hasText: 'user page 1' })
|
.filter({ hasText: 'user page 1' })
|
||||||
await sourceCard.getByRole('link', { name: '詳細・スレッド' }).click()
|
await sourceCard.getByRole('link', { name: '詳細・スレッド' }).click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/status\/1001$/)
|
await expect(page).toHaveURL(/\/yuta\/status\/1001$/)
|
||||||
await expect(page.getByRole('heading', { name: '会話' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: '会話' })).toBeVisible()
|
||||||
await expect(page.getByText('表示中の投稿')).toBeVisible()
|
await expect(page.getByText('表示中の投稿')).toBeVisible()
|
||||||
await expect(page.getByText('user page 1')).toHaveCount(1)
|
await expect(page.getByText('user page 1')).toHaveCount(1)
|
||||||
@@ -102,7 +103,7 @@ test('opens a card detail and infinitely loads its conversation', async ({
|
|||||||
test('resolves a reply root and retries only the failed continuation', async ({
|
test('resolves a reply root and retries only the failed continuation', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await openReader(page, '/status/2001')
|
await openReader(page, '/yuta/status/2001')
|
||||||
|
|
||||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||||
await expect(page.getByText('thread root')).toBeVisible()
|
await expect(page.getByText('thread root')).toBeVisible()
|
||||||
|
|||||||
Reference in New Issue
Block a user