mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge remote-tracking branch 'origin/Dev' into feat/messenger-groups-receipts
# Conflicts: # public/configuration/UITexts.example # src/css/friends/FriendsView.css
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,549 @@
|
||||
# Navigator Modernization — P1: Hook Split + UI Store
|
||||
|
||||
**Branch:** `feat/navigator-modernization` (forked from `origin/Dev` @ `d5d5ca59`)
|
||||
**Date:** 2026-05-26
|
||||
**Scope:** P1 of a 4-phase Navigator modernization sweep (P1 → P2 → P3 → P4).
|
||||
**This spec covers ONLY P1.** P2 (TanStack Query), P3 (reactive snapshots),
|
||||
and P4 (visual rework + virtualization + persistence) will each get their
|
||||
own spec when P1 lands.
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Nitro-V3 client has established patterns for god-hook
|
||||
modernization, all visible on the current `origin/Dev` tip:
|
||||
|
||||
- **God-hook split into filters over a `useBetween` singleton.** Two
|
||||
precedents:
|
||||
- `useWiredTools` — 4 files (`useWiredToolsStore` + `useWiredToolsState`
|
||||
+ `useWiredToolsActions` + `useWiredTools` shim). 630-line store.
|
||||
- `useCatalog` — single 1055-line file holding store + three filters
|
||||
(`useCatalogData` / `useCatalogUiState` / `useCatalogActions`).
|
||||
- **Zustand UI stores** via `createNitroStore` (`src/state/createNitroStore.ts`)
|
||||
for cross-feature UI flags.
|
||||
- **Renderer snapshot consumer hooks** (`useSyncExternalStore`) — out of
|
||||
scope for P1, used in P3.
|
||||
- **`useNitroQuery`** for composer/parser request-response — out of
|
||||
scope for P1, used in P2.
|
||||
- **Co-located Vitest suites** under `src/`, sharing the renderer-SDK
|
||||
stub at `src/nitro-renderer.mock.ts`.
|
||||
|
||||
`src/hooks/navigator/useNavigator.ts` is the largest remaining god-hook
|
||||
on this branch: 492 lines, 21 event listeners, 9 internal `useState`,
|
||||
consumed by 13 files (10 inside `src/components/navigator/` + 3
|
||||
outside in `room-tools`, `room-filter-words`, and `catalog` views). It
|
||||
mixes three logically separate concerns:
|
||||
|
||||
1. **Navigator data** — search results, categories, top-level
|
||||
contexts, favourites, metadata.
|
||||
2. **Door state** — doorbell, password prompt, accepted / no-answer /
|
||||
wrong-password lifecycle.
|
||||
3. **Local UI flags** — 9 `useState` in `NavigatorView.tsx` controlling
|
||||
panel visibility and search lifecycle.
|
||||
|
||||
P1 separates these three and migrates all consumers.
|
||||
|
||||
## 2. Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Door state | **Extract** to `src/hooks/rooms/widgets/useDoorState.ts` |
|
||||
| UI store scope | **All 9 flags** into `navigatorUiStore` Zustand |
|
||||
| Shim retention | **Remove** `useNavigator` after all 13 consumers migrated |
|
||||
| Filter shape | **Flat objects**, mirroring `useCatalog` and `useWiredTools` |
|
||||
| File layout | **4 separate files**, mirroring `wired-tools` (not the monolithic `useCatalog.ts`) |
|
||||
| Scope of P1 | **Pure refactor** — zero user-visible change |
|
||||
| Branch | `feat/navigator-modernization` (forked from `origin/Dev`, not a sub-branch of any other modernization branch) |
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Mirrors the `wired-tools` layout exactly — 4 hook files in
|
||||
`src/hooks/navigator/`, plus a sibling `navigatorUiStore.ts` for the
|
||||
Zustand UI flags, plus `useDoorState.ts` extracted to
|
||||
`src/hooks/rooms/widgets/`:
|
||||
|
||||
```
|
||||
src/hooks/navigator/
|
||||
├── useNavigatorStore.ts ← NEW: internal useBetween closure
|
||||
│ (data state + non-door listeners + actions)
|
||||
├── useNavigatorData.ts ← NEW: public filter — read-only data
|
||||
├── useNavigatorUiState.ts ← NEW: public filter — read-only UI flags
|
||||
├── useNavigatorActions.ts ← NEW: public filter — imperative actions
|
||||
├── navigatorUiStore.ts ← NEW: Zustand UI store (9 flags + actions)
|
||||
├── index.ts ← REWRITTEN: barrel exports the 3 filters,
|
||||
│ useNavigatorUiStore, and re-exports useDoorState
|
||||
└── useNavigator.ts ← DELETED at end of P1 (god-hook shim removed)
|
||||
|
||||
src/hooks/rooms/widgets/
|
||||
└── useDoorState.ts ← NEW: extracted door lifecycle
|
||||
```
|
||||
|
||||
### 3.1 Internal `useNavigatorStore` closure (in `useNavigatorStore.ts`)
|
||||
|
||||
The single `useBetween` singleton's internal function. Holds:
|
||||
|
||||
- All non-door state currently in `useNavigatorState` of the old
|
||||
`useNavigator.ts`: `categories`, `eventCategories`,
|
||||
`favouriteRoomIds`, `topLevelContext`, `topLevelContexts`,
|
||||
`searchResult`, `navigatorSearches`, `navigatorData`.
|
||||
- All non-door event listeners (16 of them): `FavouritesEvent`,
|
||||
`FavouriteChangedEvent`, `RoomSettingsUpdatedEvent`,
|
||||
`CanCreateRoomEventEvent`, `UserInfoEvent`, `UserPermissionsEvent`,
|
||||
`RoomForwardEvent`, `RoomEntryInfoMessageEvent`,
|
||||
`NavigatorMetadataEvent`, `NavigatorSearchEvent`,
|
||||
`UserFlatCatsEvent`, `UserEventCatsEvent`, `FlatCreatedEvent`,
|
||||
`NavigatorHomeRoomEvent`, `RoomEnterErrorEvent`,
|
||||
`NavigatorOpenRoomCreatorEvent`, `NavigatorSearchesEvent`,
|
||||
plus `NitroEventType.SOCKET_RECONNECTING`.
|
||||
- `GetGuestRoomResultEvent` — dual-subscribed (see §5.2).
|
||||
- `GenericErrorEvent` — dual-subscribed (see §5.3).
|
||||
- New imperative actions `sendSearch` and `reloadCurrentSearch`,
|
||||
extracted from the current `NavigatorView.tsx` locals (today defined
|
||||
on lines 42-79 of `src/components/navigator/NavigatorView.tsx`).
|
||||
|
||||
### 3.2 The three filters (flat shape, wired-tools layout)
|
||||
|
||||
```ts
|
||||
// useNavigatorData.ts
|
||||
import { useBetween } from 'use-between';
|
||||
import { useNavigatorStore } from './useNavigatorStore';
|
||||
|
||||
export const useNavigatorData = () => {
|
||||
const {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
searchResult, navigatorSearches, navigatorData,
|
||||
} = useBetween(useNavigatorStore);
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
searchResult, navigatorSearches, navigatorData,
|
||||
};
|
||||
};
|
||||
|
||||
// useNavigatorUiState.ts
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
export const useNavigatorUiState = () => {
|
||||
const isVisible = useNavigatorUiStore(s => s.isVisible);
|
||||
const isReady = useNavigatorUiStore(s => s.isReady);
|
||||
const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen);
|
||||
const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen);
|
||||
const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen);
|
||||
const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches);
|
||||
const isLoading = useNavigatorUiStore(s => s.isLoading);
|
||||
const needsInit = useNavigatorUiStore(s => s.needsInit);
|
||||
const needsSearch = useNavigatorUiStore(s => s.needsSearch);
|
||||
return {
|
||||
isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen,
|
||||
isOpenSavesSearches, isLoading, needsInit, needsSearch,
|
||||
};
|
||||
};
|
||||
|
||||
// useNavigatorActions.ts
|
||||
import { useBetween } from 'use-between';
|
||||
import { useNavigatorStore } from './useNavigatorStore';
|
||||
|
||||
export const useNavigatorActions = () => {
|
||||
const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore);
|
||||
return { sendSearch, reloadCurrentSearch };
|
||||
};
|
||||
```
|
||||
|
||||
`useNavigatorActions` is intentionally small in P1 — favourite
|
||||
toggles, room visits, and door responses keep flowing through their
|
||||
existing direct composer calls in consumer components. We only hoist
|
||||
the two functions that are currently prop-drilled into
|
||||
`NavigatorSearchView` and the tab `onClick` handlers.
|
||||
|
||||
`useNavigatorUiState` uses per-key Zustand selectors (one selector
|
||||
per flag) so a component re-renders only when a flag it actually
|
||||
reads changes. The flat object it returns preserves the API shape
|
||||
consumers expect.
|
||||
|
||||
### 3.3 `navigatorUiStore` (Zustand)
|
||||
|
||||
```ts
|
||||
// src/hooks/navigator/navigatorUiStore.ts
|
||||
import { createNitroStore } from '../../state/createNitroStore';
|
||||
|
||||
type NavigatorUiState = {
|
||||
isVisible: boolean;
|
||||
isReady: boolean;
|
||||
isCreatorOpen: boolean;
|
||||
isRoomInfoOpen: boolean;
|
||||
isRoomLinkOpen: boolean;
|
||||
isOpenSavesSearches: boolean;
|
||||
isLoading: boolean;
|
||||
needsInit: boolean;
|
||||
needsSearch: boolean;
|
||||
};
|
||||
|
||||
type NavigatorUiActions = {
|
||||
show(): void;
|
||||
hide(): void;
|
||||
toggle(): void;
|
||||
openCreator(): void;
|
||||
closeCreator(): void;
|
||||
setRoomInfoOpen(open: boolean): void;
|
||||
toggleRoomInfo(): void;
|
||||
setRoomLinkOpen(open: boolean): void;
|
||||
toggleRoomLink(): void;
|
||||
toggleSavesSearches(): void;
|
||||
setLoading(loading: boolean): void;
|
||||
markReady(): void;
|
||||
markInitDone(): void;
|
||||
requestSearch(): void; // sets needsSearch = true
|
||||
consumeSearchRequest(): void; // sets needsSearch = false
|
||||
};
|
||||
|
||||
const INITIAL: NavigatorUiState = {
|
||||
isVisible: false,
|
||||
isReady: false,
|
||||
isCreatorOpen: false,
|
||||
isRoomInfoOpen: false,
|
||||
isRoomLinkOpen: false,
|
||||
isOpenSavesSearches: false,
|
||||
isLoading: false,
|
||||
needsInit: true,
|
||||
needsSearch: false,
|
||||
};
|
||||
|
||||
export const useNavigatorUiStore = createNitroStore<NavigatorUiState & NavigatorUiActions>()((set) => ({
|
||||
...INITIAL,
|
||||
show: () => set({ isVisible: true, needsSearch: true }),
|
||||
hide: () => set({ isVisible: false }),
|
||||
toggle: () => set((s) => s.isVisible
|
||||
? { isVisible: false }
|
||||
: { isVisible: true, needsSearch: true }),
|
||||
openCreator: () => set({ isVisible: true, isCreatorOpen: true }),
|
||||
closeCreator: () => set({ isCreatorOpen: false }),
|
||||
setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }),
|
||||
toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })),
|
||||
setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }),
|
||||
toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })),
|
||||
toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
markReady: () => set({ isReady: true }),
|
||||
markInitDone: () => set({ needsInit: false }),
|
||||
requestSearch: () => set({ needsSearch: true }),
|
||||
consumeSearchRequest: () => set({ needsSearch: false }),
|
||||
}));
|
||||
```
|
||||
|
||||
The `linkTracker` in `NavigatorView.tsx` calls these actions directly
|
||||
on `useNavigatorUiStore.getState()` instead of mutating local
|
||||
`useState`. That collapses the switch statement from 30+ lines to a
|
||||
clean dispatch table and eliminates the closure-over-stale-state hazard
|
||||
where the tracker re-registers on every `isVisible` change (today at
|
||||
`src/components/navigator/NavigatorView.tsx:162`).
|
||||
|
||||
### 3.4 `useDoorState` (extracted to `src/hooks/rooms/widgets/`)
|
||||
|
||||
```ts
|
||||
// src/hooks/rooms/widgets/useDoorState.ts
|
||||
import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent,
|
||||
GenericErrorEvent, GetGuestRoomResultEvent,
|
||||
GetSessionDataManager, RoomDataParser,
|
||||
RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { DoorStateType } from '../../../api';
|
||||
import { useMessageEvent } from '../../events';
|
||||
|
||||
export type DoorStateSnapshot = {
|
||||
roomInfo: RoomDataParser | null;
|
||||
state: number; // DoorStateType.*
|
||||
};
|
||||
|
||||
const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE };
|
||||
|
||||
const useDoorStateStore = () => {
|
||||
const [snapshot, setSnapshot] = useState<DoorStateSnapshot>(INITIAL);
|
||||
|
||||
useMessageEvent<DoorbellMessageEvent>(DoorbellMessageEvent, event => {
|
||||
const parser = event.getParser();
|
||||
if (parser.userName && parser.userName.length > 0) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING }));
|
||||
});
|
||||
|
||||
useMessageEvent<RoomDoorbellAcceptedEvent>(RoomDoorbellAcceptedEvent, event => {
|
||||
const parser = event.getParser();
|
||||
if (parser.userName && parser.userName.length > 0) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED }));
|
||||
});
|
||||
|
||||
useMessageEvent<FlatAccessDeniedMessageEvent>(FlatAccessDeniedMessageEvent, event => {
|
||||
const parser = event.getParser();
|
||||
if (parser.userName && parser.userName.length > 0) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER }));
|
||||
});
|
||||
|
||||
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, event => {
|
||||
const parser = event.getParser();
|
||||
if (parser.errorCode !== -100002) return; // door-only error code
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD }));
|
||||
});
|
||||
|
||||
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, event => {
|
||||
const parser = event.getParser();
|
||||
// ONLY handle the roomForward branch with door modes
|
||||
if (!parser.roomForward) return;
|
||||
if (parser.data.ownerName === GetSessionDataManager().userName) return;
|
||||
if (parser.isGroupMember) return;
|
||||
if (parser.data.doorMode === RoomDataParser.DOORBELL_STATE) {
|
||||
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_DOORBELL });
|
||||
} else if (parser.data.doorMode === RoomDataParser.PASSWORD_STATE) {
|
||||
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD });
|
||||
}
|
||||
});
|
||||
|
||||
const reset = useCallback(() => setSnapshot(INITIAL), []);
|
||||
|
||||
return { snapshot, setSnapshot, reset };
|
||||
};
|
||||
|
||||
export const useDoorState = () => useBetween(useDoorStateStore);
|
||||
```
|
||||
|
||||
The current `NavigatorDoorStateView.tsx` does
|
||||
`setDoorData({ roomInfo: null, state: DoorStateType.NONE })` to reset
|
||||
— after P1 it calls `reset()`.
|
||||
|
||||
## 4. Consumer migration map (13 files)
|
||||
|
||||
| File | Reads today | Reads after P1 |
|
||||
|---|---|---|
|
||||
| `NavigatorView.tsx` | full `useNavigator()` + 9 local useState | `useNavigatorData` + `useNavigatorActions` + `useNavigatorUiStore` (one selector per flag) |
|
||||
| `NavigatorDoorStateView.tsx` | `doorData`, `setDoorData` | `useDoorState` (`snapshot`, `setSnapshot`, `reset`) |
|
||||
| `NavigatorRoomCreatorView.tsx` | `categories` | `useNavigatorData` |
|
||||
| `NavigatorRoomInfoView.tsx` | `navigatorData`, `favouriteRoomIds` | `useNavigatorData` |
|
||||
| `NavigatorRoomLinkView.tsx` | `navigatorData.enteredGuestRoom` | `useNavigatorData` |
|
||||
| `NavigatorRoomSettingsBasicTabView.tsx` | `categories` | `useNavigatorData` |
|
||||
| `NavigatorSearchResultItemView.tsx` | `favouriteRoomIds`, `navigatorData` | `useNavigatorData` |
|
||||
| `NavigatorSearchResultItemInfoView.tsx` | `navigatorData` | `useNavigatorData` |
|
||||
| `NavigatorSearchResultView.tsx` | `topLevelContext` | `useNavigatorData` |
|
||||
| `NavigatorSearchView.tsx` | `topLevelContext` + `sendSearch` prop | `useNavigatorData` + `useNavigatorActions` |
|
||||
| `CatalogLayoutRoomAdsView.tsx` | `navigatorData.currentRoomId` | `useNavigatorData` |
|
||||
| `RoomFilterWordsWidgetView.tsx` | `navigatorData.currentRoomId` | `useNavigatorData` |
|
||||
| `RoomToolsWidgetView.tsx` | `navigatorData` | `useNavigatorData` |
|
||||
|
||||
All 13 consumers get a one-line import swap (plus `NavigatorView`
|
||||
which is more involved since it owns the 9 useState + linkTracker
|
||||
dispatch + `sendSearch` prop drilling that all go away). No
|
||||
behavioural change.
|
||||
|
||||
## 5. Dual-subscription edge cases
|
||||
|
||||
### 5.1 `useBetween` guarantee
|
||||
|
||||
`useDoorState` uses `useBetween(useDoorStateStore)`, so multiple
|
||||
consumers (currently only `NavigatorDoorStateView`) share a single
|
||||
listener registration — same as how `useNavigatorStore` works.
|
||||
|
||||
### 5.2 `GetGuestRoomResultEvent` — dual subscription
|
||||
|
||||
Today this event is handled in one place (current `useNavigator.ts`
|
||||
lines 130-209) with three branches: `roomEnter`, `roomForward`, else.
|
||||
After P1:
|
||||
|
||||
- `useDoorStateStore` subscribes and acts ONLY on the `roomForward`
|
||||
branch when `doorMode` is `DOORBELL_STATE` or `PASSWORD_STATE` AND
|
||||
the user is not the owner / not a group member.
|
||||
- `useNavigatorStore` subscribes and handles `roomEnter`, the
|
||||
`roomForward` branch WITHOUT door modes (direct `CreateRoomSession`
|
||||
call), and the `else` branch.
|
||||
|
||||
Multiple subscribers to the same event is an accepted pattern (see
|
||||
`FlatCreatedEvent` listened in `useNavigator` and elsewhere). Both
|
||||
listeners register through `useMessageEvent` so the renderer event
|
||||
bus dispatches to both.
|
||||
|
||||
### 5.3 `GenericErrorEvent` — dual subscription
|
||||
|
||||
- `useDoorStateStore` acts ONLY on `errorCode === -100002` (wrong
|
||||
password).
|
||||
- `useNavigatorStore` acts on `4009`, `4010`, `4011`, `4013` (room
|
||||
management alerts via `simpleAlert`).
|
||||
|
||||
Each side filters by `errorCode` immediately — no cross-effects.
|
||||
|
||||
## 6. Visual direction (anchor for P4 — informational only)
|
||||
|
||||
P1 ships zero visual change. This section documents the visual
|
||||
target that P4's spec will detail, so the architecture choices in
|
||||
P1 align with where we are heading.
|
||||
|
||||
### 6.1 Current pain points (from user screenshots, 2026-05-26)
|
||||
|
||||
- **Tab "Pubbliche":** empty state is bare text "No rooms found".
|
||||
- **Tab "Tutte le stanze":** popular rooms shown as a small thumbnail
|
||||
grid; the "Party" category uses a compact list mode with no
|
||||
visual hierarchy or live signal.
|
||||
- **Tab "Eventi":** empty state is bare text "No rooms found".
|
||||
- **Tab "Il mio mondo":** sparse list, no per-room preview.
|
||||
- **Saved searches:** today a 600px-wide sidebar that resizes the
|
||||
card and pushes content right.
|
||||
- **Filter dropdown "Qualsiasi":** opaque about what filters exist.
|
||||
|
||||
### 6.2 Target shape (P4 spec will detail)
|
||||
|
||||
**Empty states with illustration + contextual CTA:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Navigator @ Habbo [×] │
|
||||
│ [⚡][Pubbliche][Tutte][Eventi][Mio] │
|
||||
├─────────────────────────────────────┤
|
||||
│ [🔓 Aperte] [🚪 Campanello] [🔒] │
|
||||
│ [filtra stanze...] 🔍 │
|
||||
│ [🔖 staff] [🔖 party] [🔖 chill] + │
|
||||
├─────────────────────────────────────┤
|
||||
│ ╭──────────╮ │
|
||||
│ │ 🏠 ✨ │ │
|
||||
│ ╰──────────╯ │
|
||||
│ Nessuna stanza pubblica │
|
||||
│ ancora attiva │
|
||||
│ │
|
||||
│ [ Esplora stanze popolari → ] │
|
||||
├─────────────────────────────────────┤
|
||||
│ [+ Crea stanza] [Da qualche parte] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Card list with row-level hover-reveal:**
|
||||
|
||||
```
|
||||
▼ Stanze più popolari [▦ ☰] [⚡]
|
||||
┌─────────┐ Big Party Room
|
||||
│ 🏠 🎵 │ 👤 22 · 🔓 Aperta · ★ 4.7
|
||||
│ (img) │ by @Cocco
|
||||
└─────────┘ [Entra] [ⓘ] [☆ favori] ← shown on row hover
|
||||
─────────────────────────────────────────
|
||||
▼ Party [▦ ☰] [⚡]
|
||||
🟢 fcfcvcvcv 👤2 🔓 [ⓘ]
|
||||
🔒 aaaaa 👤1 🚪 [ⓘ]
|
||||
```
|
||||
|
||||
**Saved searches as horizontal chip row** above the filter input
|
||||
(replaces the 600px sidebar — no layout shift on toggle).
|
||||
|
||||
**Filter intent as visible chips** instead of "Qualsiasi" dropdown:
|
||||
`🔓 Aperte` `🚪 Campanello` `🔒 Con password` `👥 Solo amici`.
|
||||
|
||||
**Sticky section headers** when scrolling long lists.
|
||||
|
||||
**Skeleton loaders** during fetch (post-P2 when query state lands).
|
||||
|
||||
**Per-card actions on hover**: favourite ☆, info ⓘ, room link 🔗.
|
||||
|
||||
### 6.3 Why P1 architecture supports this
|
||||
|
||||
- `useNavigatorUiStore` makes future flags (`viewMode: 'compact' | 'expanded'`,
|
||||
`lastTab`, `lastScrollTop`) trivial to add — they're new state on
|
||||
the store; persistence can be added with a Zustand `persist`
|
||||
middleware on a single line.
|
||||
- Splitting `useDoorState` out means the visual rework of the door
|
||||
prompt (a separate panel, possibly modal) can evolve independently
|
||||
of Navigator search UI.
|
||||
- Three flat filters mean a new card variant (compact-vs-expanded
|
||||
list) reads `useNavigatorData` only — no risk of re-rendering the
|
||||
whole Navigator when card-mode toggles.
|
||||
|
||||
## 7. Testing strategy
|
||||
|
||||
Coherent with `CLAUDE.md` "`yarn test` must stay green on every
|
||||
commit":
|
||||
|
||||
| Suite | New / changed | Cases (target) |
|
||||
|---|---|---|
|
||||
| `navigatorUiStore.test.ts` | NEW | ~30: each action idempotent on no-op, transitions valid, `requestSearch`/`consumeSearchRequest` symmetric |
|
||||
| `useDoorState.test.tsx` | NEW | ~12: each event listener happy path + filter-by-userName + filter-by-errorCode + reset() |
|
||||
| `useNavigatorStore.test.tsx` | NEW (smoke) | ~5: 3 filters return expected shape, dispatch updates propagate to `useNavigatorData`, GenericError 4010 does NOT touch door state, GenericError -100002 DOES touch door state |
|
||||
| Existing Vitest suites | Stay green | — |
|
||||
|
||||
All tests co-located under `src/`, alongside their subject. Reuse
|
||||
`src/nitro-renderer.mock.ts` for event dispatching (the
|
||||
`mockEventDispatcher` / `clearMockEventDispatcher` helpers).
|
||||
|
||||
CI gates that must stay green: `yarn typecheck` (TS 7 native),
|
||||
`yarn test`, `yarn lint:hooks` (`react-hooks/rules-of-hooks: error`).
|
||||
|
||||
## 8. Compatibility with project conventions
|
||||
|
||||
`feat/navigator-modernization` is forked from `origin/Dev` @ `d5d5ca59`,
|
||||
so it carries everything upstream has shipped through the floorplan
|
||||
editor work + classic catalog view + emustats + housekeeping panel.
|
||||
The design respects every constraint of this base:
|
||||
|
||||
- **No new dependencies.** Uses `zustand` (present), `use-between`
|
||||
(present), `vitest` (present), `createNitroStore` (present at
|
||||
`src/state/createNitroStore.ts`).
|
||||
- **React 19 idioms** identical to the rest of the codebase. No
|
||||
manual `useMemo`/`useCallback` unless the React Compiler asks for
|
||||
them.
|
||||
- **TypeScript strict** consistent with the rest of the project.
|
||||
- **Co-located tests** under `src/` per the layout convention.
|
||||
- **No conflicts with adopted patterns**: `useNitroEvent`,
|
||||
`useMessageEvent`, `useBetween`, `createNitroStore`. The new
|
||||
filters expose plain data — they don't call snapshot hooks
|
||||
(`useSyncExternalStore`) inside `useBetween` scopes, so the
|
||||
documented "snapshot-outside-useBetween" constraint never
|
||||
triggers here.
|
||||
- **Commit author** per house rules: `simoleo89
|
||||
<simoleo89@users.noreply.github.com>` via per-command `-c`
|
||||
overrides. **No Co-Authored-By trailer.**
|
||||
- **Branch policy**: fresh branch off `origin/Dev`, pushable
|
||||
fast-forward to `simoleo/feat/navigator-modernization` (which
|
||||
doesn't yet exist on the fork — first push creates it). No
|
||||
force-push required.
|
||||
|
||||
## 9. Out of scope (explicit)
|
||||
|
||||
- TanStack Query migration of search (P2).
|
||||
- Reactive favourite icons via snapshot (P3).
|
||||
- Live user counts via snapshot (P3).
|
||||
- Virtualization of result list (P4).
|
||||
- Empty-state component (P4).
|
||||
- Saved-search chip row (P4).
|
||||
- Persistence of tab/scroll/filter (P4).
|
||||
- `useActionState` on search input (P6).
|
||||
- `WidgetErrorBoundary` wrapping of Navigator sub-views (P5 —
|
||||
independent, can land in parallel).
|
||||
- Any visual change. P1 ships byte-identical UI.
|
||||
- Any change to `NavigatorRoomSettings*` subtree (self-contained,
|
||||
only reads `categories` in one tab).
|
||||
|
||||
## 10. Acceptance criteria
|
||||
|
||||
P1 is complete when:
|
||||
|
||||
1. `src/hooks/navigator/useNavigator.ts` does NOT exist (god-hook
|
||||
removed).
|
||||
2. `src/hooks/navigator/` contains `useNavigatorStore.ts`,
|
||||
`useNavigatorData.ts`, `useNavigatorUiState.ts`,
|
||||
`useNavigatorActions.ts`, `navigatorUiStore.ts`, and an updated
|
||||
`index.ts`.
|
||||
3. `src/hooks/rooms/widgets/useDoorState.ts` exists.
|
||||
4. All 13 active consumers compile after their import swap.
|
||||
5. `yarn typecheck` clean.
|
||||
6. `yarn lint:hooks` clean.
|
||||
7. `yarn test --run` green, with at least 3 new suites
|
||||
(`navigatorUiStore`, `useDoorState`, `useNavigatorStore` smoke).
|
||||
8. Manual smoke test: open Navigator, switch each top-level tab, run
|
||||
a search, open a room with a doorbell, get rejected, open a room
|
||||
with a password, enter the right password, enter wrong password,
|
||||
open a room you own, click a favourite ☆, open RoomInfo, open
|
||||
RoomLink. Each path renders identically to pre-P1 behaviour.
|
||||
9. Branch `feat/navigator-modernization` pushed (fast-forward only)
|
||||
to `simoleo/feat/navigator-modernization` on the user's fork.
|
||||
|
||||
## 11. Risk register
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| A consumer reads a field we forgot to expose on a filter | medium | Type-checker catches it — all 13 consumers re-typecheck on swap |
|
||||
| Dual-subscription on `GetGuestRoomResultEvent` causes double `CreateRoomSession` | low | `useDoorStateStore` only acts on doorMode bell/password; `useNavigatorStore` only acts on the other branches. Explicit `if` guards on both sides |
|
||||
| `linkTracker` re-registration leaks because deps changed | low | New tracker reads `useNavigatorUiStore.getState()` instead of closure-captured state, so its `useEffect` deps shrink |
|
||||
| `useDoorState` consumer in `NavigatorDoorStateView` regresses on `reset()` semantics | low | Smoke test in §10 covers this |
|
||||
| Per-key Zustand selectors in `useNavigatorUiState` cause stale-closure issues | low | Each selector is one-shot, no derived values; identical pattern to existing Zustand stores in the codebase |
|
||||
| Renderer SDK mismatch on local dev (e.g. floorplan-live-preview not in renderer's main) | medium | Already exists today regardless of this PR; surface in plan as a `yarn typecheck` caveat, not introduced by P1 |
|
||||
@@ -0,0 +1,243 @@
|
||||
# Navigator Modernization — P2: TanStack Query for Search
|
||||
|
||||
**Branch**: `feat/navigator-p2-query` (forked from `feat/navigator-modernization` @ `1148c0a6`)
|
||||
**Date**: 2026-05-27
|
||||
**Depends on**: P1 (hook split) — merged or pending merge
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Migrate Navigator's search request/response from event-driven imperative state to TanStack Query. The user gets:
|
||||
- **Instant tab switching** when the same tab/filter was visited before in the session (cache hit, no round-trip)
|
||||
- **Stale-while-revalidate** on revisit (shows cached results while refetching in background)
|
||||
- **Server-driven refresh** via `useNitroEventInvalidator` on `FlatCreatedEvent` and `RoomSettingsUpdatedEvent` (and possibly `FavouriteChangedEvent` if the active tab is `favorites_view`)
|
||||
- **Single source of truth** for `isFetching` — no separate `isLoading` flag to manage
|
||||
|
||||
## 2. Architecture changes
|
||||
|
||||
### 2.1 New file: `src/hooks/navigator/useNavigatorSearch.ts`
|
||||
|
||||
The query hook. Reads `currentTabCode` + `currentFilter` from `navigatorUiStore`, fires `NavigatorSearchComposer`, waits for `NavigatorSearchEvent`, returns the parsed `NavigatorSearchResultSet`.
|
||||
|
||||
```ts
|
||||
import { NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
export const useNavigatorSearch = () =>
|
||||
{
|
||||
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||
const filter = useNavigatorUiStore(s => s.currentFilter);
|
||||
|
||||
const query = useNitroQuery<typeof NavigatorSearchEvent, NavigatorSearchResultSet>({
|
||||
key: [ 'navigator', 'search', tabCode, filter ],
|
||||
request: () => new NavigatorSearchComposer(tabCode, filter),
|
||||
parser: NavigatorSearchEvent,
|
||||
select: e => e.getParser()?.result ?? null,
|
||||
accept: e => {
|
||||
const result = e.getParser()?.result;
|
||||
// accept-filter: only this query's matching tab code
|
||||
return !!result && result.code === tabCode;
|
||||
},
|
||||
enabled: !!tabCode,
|
||||
staleTime: 30_000 // re-fetch after 30s of staleness on revisit
|
||||
});
|
||||
|
||||
useNitroEventInvalidator(FlatCreatedEvent, [ 'navigator', 'search' ]);
|
||||
useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ 'navigator', 'search' ]);
|
||||
|
||||
return {
|
||||
searchResult: query.data,
|
||||
isFetching: query.isFetching,
|
||||
refetch: query.refetch
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 `navigatorUiStore.ts` additions
|
||||
|
||||
Add 2 new state fields + 2 new actions:
|
||||
|
||||
```ts
|
||||
type NavigatorUiState = {
|
||||
// ...existing 9 flags...
|
||||
currentTabCode: string; // '' until NavigatorMetadataEvent arrives, then first top-level context code
|
||||
currentFilter: string; // '' by default
|
||||
};
|
||||
|
||||
type NavigatorUiActions = {
|
||||
// ...existing 15 actions...
|
||||
setTab(code: string): void; // also clears currentFilter
|
||||
setFilter(value: string): void;
|
||||
};
|
||||
```
|
||||
|
||||
`setTab(code)` resets `currentFilter` to `''` because switching tabs starts a fresh search. `setFilter` updates only the filter — the user is typing in the same tab.
|
||||
|
||||
### 2.3 `useNavigatorStore.ts` — remove search state ownership
|
||||
|
||||
Remove:
|
||||
- `useState<NavigatorSearchResultSet>(null)` for `searchResult`
|
||||
- `useMessageEvent<NavigatorSearchEvent>` listener
|
||||
- `sendSearch` and `reloadCurrentSearch` actions
|
||||
- The `useNavigatorUiStore.getState().setLoading(...)` calls (no longer needed)
|
||||
- The `topLevelContextRef` and `searchResultRef` (only consumed inside `reloadCurrentSearch`)
|
||||
|
||||
Keep:
|
||||
- `topLevelContext` + `topLevelContexts` (these still come from `NavigatorMetadataEvent` and drive the tab list)
|
||||
- The `NavigatorMetadataEvent` listener — but it now ALSO calls `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` on first arrival, to seed the initial tab. The query then activates because `currentTabCode` becomes non-empty (`enabled: !!tabCode`).
|
||||
|
||||
### 2.4 `useNavigatorData.ts` — remove `searchResult` from return shape
|
||||
|
||||
`useNavigatorData()` no longer returns `searchResult`. Consumers that need it call `useNavigatorSearch()` instead.
|
||||
|
||||
### 2.5 `useNavigatorActions.ts` — empty or removed
|
||||
|
||||
Both `sendSearch` and `reloadCurrentSearch` are gone. Either:
|
||||
- Remove the file + the export — consumers use `useNavigatorUiStore.getState().setTab(...)` / `setFilter(...)` directly
|
||||
- Or keep the file as an empty re-export for forward compat. (Decision: REMOVE — minimize dead API).
|
||||
|
||||
### 2.6 `useNavigatorUiState.ts` — add the 2 new flags
|
||||
|
||||
Add `currentTabCode` and `currentFilter` to the per-key selector list and return shape.
|
||||
|
||||
### 2.7 `useNavigatorSearch.test.tsx` — new
|
||||
|
||||
Test cases:
|
||||
- Initial mount with empty tabCode → query is disabled, no request fired
|
||||
- After `setTab('public')` → query fires NavigatorSearchComposer('public', '')
|
||||
- After `setFilter('cocco')` → query fires NavigatorSearchComposer('public', 'cocco')
|
||||
- After `setTab('events')` → currentFilter resets to '', query fires NavigatorSearchComposer('events', '')
|
||||
- `FlatCreatedEvent` invalidates the cache → refetch
|
||||
- `RoomSettingsUpdatedEvent` invalidates the cache → refetch
|
||||
- `NavigatorSearchEvent` with WRONG tabCode (e.g. server pushes an unsolicited result) is REJECTED by `accept` filter — does NOT update query data
|
||||
|
||||
### 2.8 `NavigatorView.tsx` — major rewrite
|
||||
|
||||
Replace:
|
||||
- `useNavigatorActions` import → gone
|
||||
- `useNavigatorData` no longer destructures `searchResult` — get it from `useNavigatorSearch` instead
|
||||
- 4 `useEffect` blocks driving the imperative search flow (`needsSearch`, `needsInit` lifecycle, `reloadCurrentSearch` orchestration) → gone
|
||||
- Tab `onClick={ () => sendSearch('', context.code) }` → `onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }`
|
||||
- `isLoading` from `useNavigatorUiState()` → `isFetching` from `useNavigatorSearch()` query
|
||||
- `NavigatorInitComposer` initial dispatch on first `isVisible` — KEEP (still need it to get `topLevelContexts` populated)
|
||||
- `pendingSearch` ref — gone (linkTracker `case 'search'` directly does `setTab(code); setFilter(value)`)
|
||||
|
||||
Major simplification: the file shrinks ~30 lines.
|
||||
|
||||
### 2.9 `NavigatorSearchView.tsx` — drive setFilter
|
||||
|
||||
Read the file. The component currently exposes a search input that, on enter or button click, calls `sendSearch(value, currentTabCode)`. After P2 it:
|
||||
- Reads `currentFilter` from `useNavigatorUiState`
|
||||
- onChange → `useNavigatorUiStore.getState().setFilter(value)` (debounced 300ms)
|
||||
- No more `sendSearch` reference
|
||||
|
||||
Debounce: use a local `useState` for the input text + a `useEffect` that calls `setFilter(text)` 300ms after the last keystroke. Standard pattern.
|
||||
|
||||
## 3. Backward-compat considerations
|
||||
|
||||
- `useNavigatorActions.sendSearch` and `useNavigatorActions.reloadCurrentSearch` are REMOVED. No consumer outside Navigator depends on them — verified by grepping the previous P1 consumer migration.
|
||||
- `useNavigatorData.searchResult` is REMOVED. Only `NavigatorView` reads it currently — easy to migrate.
|
||||
- The `useNavigatorActions` filter itself becomes empty — consider whether to delete the file entirely. **Decision: delete the file** to minimize the API surface. Tasks 5-8 of P1 migrated `NavigatorSearchView` to use `useNavigatorActions` — that's the only consumer; it migrates to `useNavigatorUiStore` directly.
|
||||
|
||||
## 4. Out of scope (each gets its own future spec)
|
||||
|
||||
- Reactive favourite stars on cards (P3)
|
||||
- Visual rework: empty states, virtualization, chip-based UI (P4)
|
||||
- Form Action on search input (P6)
|
||||
|
||||
## 5. Acceptance criteria
|
||||
|
||||
P2 is complete when:
|
||||
|
||||
1. `src/hooks/navigator/useNavigatorSearch.ts` exists and exports `useNavigatorSearch`
|
||||
2. `useNavigatorStore.ts` no longer owns `searchResult`, no longer subscribes to `NavigatorSearchEvent`, no longer exposes `sendSearch` or `reloadCurrentSearch`
|
||||
3. `navigatorUiStore.ts` has `currentTabCode` + `currentFilter` state and `setTab` + `setFilter` actions
|
||||
4. `useNavigatorActions.ts` is deleted; barrel no longer exports `useNavigatorActions`
|
||||
5. `useNavigatorData.ts` no longer returns `searchResult`
|
||||
6. `useNavigatorUiState.ts` returns `currentTabCode` + `currentFilter`
|
||||
7. `NavigatorView.tsx` reads `searchResult` from `useNavigatorSearch()`, uses `isFetching` for the loading flag, calls `setTab` on tab clicks
|
||||
8. `NavigatorSearchView.tsx` debounces `setFilter` calls
|
||||
9. `yarn typecheck` clean (same pre-existing floorplan errors)
|
||||
10. `yarn test --run` green; smoke test updated; new `useNavigatorSearch.test.tsx` with 7 cases
|
||||
11. `yarn lint:hooks` clean
|
||||
12. Manual smoke: switch tabs rapidly → results cached, no flicker. Type filter → debounced refetch. Create a room → list refreshes.
|
||||
|
||||
## 6. Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `NavigatorSearchEvent` arrives unsolicited (server-side push) — query wouldn't update | The `accept` filter checks the result's code matches the current tabCode, so only matching events update the query. Unsolicited results to a non-active tab are ignored (acceptable — when the user switches to that tab, the cache is empty and a fresh query fires). |
|
||||
| Removing `useNavigatorActions` breaks an import we missed | Type-checker catches it. The P1 grep showed only Navigator-internal consumers use it. |
|
||||
| Removing the `isLoading`/`isReady`/`needsInit`/`needsSearch` flags from `navigatorUiStore` (they're now derivable from query state) — too aggressive? | KEEP them in P2. Only `searchResult` ownership moves. Future cleanup can remove the obsolete lifecycle flags once we're sure nothing reads them. |
|
||||
| Debounce timing on search input | 300ms is standard; if it feels laggy the user can lower it later — pure UX tune |
|
||||
|
||||
## 7. Plan (executable)
|
||||
|
||||
### Task 1: Add UI store state + actions (TDD)
|
||||
|
||||
**Files**: `src/hooks/navigator/navigatorUiStore.ts`, `src/hooks/navigator/navigatorUiStore.test.ts`
|
||||
|
||||
- [ ] Add `currentTabCode: string` (initial `''`) and `currentFilter: string` (initial `''`) to `NavigatorUiState`
|
||||
- [ ] Add `setTab(code: string): void` and `setFilter(value: string): void` to `NavigatorUiActions`
|
||||
- [ ] `setTab(code)` sets `{ currentTabCode: code, currentFilter: '' }` (atomic reset on tab change)
|
||||
- [ ] `setFilter(value)` sets `{ currentFilter: value }` (no tab side-effect)
|
||||
- [ ] Update test file: 3 new cases — `setTab` updates tab and resets filter; `setFilter` updates filter without touching tab; idempotent `setTab` on same code resets filter to '' regardless
|
||||
- [ ] `yarn test --run src/hooks/navigator/navigatorUiStore.test.ts` → green
|
||||
- [ ] Commit: `feat(navigator): add currentTabCode + currentFilter to UI store (P2 prep)`
|
||||
|
||||
### Task 2: Create `useNavigatorSearch` query hook (TDD)
|
||||
|
||||
**Files**: `src/hooks/navigator/useNavigatorSearch.ts`, `src/hooks/navigator/useNavigatorSearch.test.tsx`
|
||||
|
||||
Implement per §2.1 + §2.7 above. 7 test cases.
|
||||
|
||||
The test will need: `QueryClientProvider` wrapper, mock for `NavigatorSearchComposer` (probably already in mock), `NavigatorSearchEvent` dispatch with parser.result.code matching/non-matching.
|
||||
|
||||
- [ ] Commit: `feat(navigator): useNavigatorSearch query hook (P2 core)`
|
||||
|
||||
### Task 3: Strip search ownership from `useNavigatorStore` + `useNavigatorData` + remove `useNavigatorActions`
|
||||
|
||||
**Files**: `useNavigatorStore.ts`, `useNavigatorData.ts`, `useNavigatorActions.ts` (DELETE), `useNavigatorUiState.ts`, `index.ts`
|
||||
|
||||
- [ ] Remove `searchResult` state + `setSearchResult` from `useNavigatorStore`
|
||||
- [ ] Remove `NavigatorSearchEvent` listener from `useNavigatorStore`
|
||||
- [ ] Remove `sendSearch` and `reloadCurrentSearch` from `useNavigatorStore` return
|
||||
- [ ] Remove `setLoading` calls inside `useNavigatorStore`
|
||||
- [ ] Remove `topLevelContextRef` and `searchResultRef` (no longer used after sendSearch/reload removal)
|
||||
- [ ] In `NavigatorMetadataEvent` handler, add `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` after `setTopLevelContext(...)` — seeds the query when contexts arrive
|
||||
- [ ] Remove `searchResult` from `useNavigatorData` destructure + return
|
||||
- [ ] DELETE `src/hooks/navigator/useNavigatorActions.ts`
|
||||
- [ ] Update `useNavigatorUiState.ts` to expose `currentTabCode` + `currentFilter` per-key selectors
|
||||
- [ ] Update `src/hooks/navigator/index.ts` to remove `useNavigatorActions` export, add `useNavigatorSearch` export
|
||||
- [ ] Update `useNavigatorStore.test.tsx` smoke test: 2 cases that expected `searchResult` in data shape or `sendSearch/reloadCurrentSearch` in actions shape — update accordingly (or just remove the "useNavigatorActions returns ..." test entirely)
|
||||
- [ ] Verify typecheck: ONLY consumer-side errors expected (NavigatorView still references the old API). Hook files clean.
|
||||
- [ ] Commit: `refactor(navigator): remove search ownership from useNavigatorStore`
|
||||
|
||||
### Task 4: Migrate `NavigatorView.tsx` + `NavigatorSearchView.tsx`
|
||||
|
||||
**Files**: `src/components/navigator/NavigatorView.tsx`, `src/components/navigator/views/search/NavigatorSearchView.tsx`
|
||||
|
||||
- [ ] In `NavigatorView`:
|
||||
- Import `useNavigatorSearch`
|
||||
- Replace `useNavigatorData` destructure of `searchResult` with `useNavigatorSearch()` call returning `{ searchResult, isFetching }`
|
||||
- Drop `useNavigatorActions` import + destructure (it's gone)
|
||||
- Drop the 4 lifecycle `useEffect` blocks (needsSearch / needsInit-init / markReady / reloadCurrentSearch); the new flow:
|
||||
- Keep the `NavigatorInitComposer` on first `isVisible` — still needed for metadata
|
||||
- Tab clicks call `useNavigatorUiStore.getState().setTab(context.code)`
|
||||
- linkTracker `case 'search'`: `store.setTab(parts[2]); store.setFilter(parts[3] ?? ''); store.show();` (no more `pendingSearch` ref)
|
||||
- Replace `<NitroCard.Content isLoading={ isLoading }>` with `isFetching` from the query
|
||||
- Drop the `pendingSearch` ref
|
||||
- [ ] In `NavigatorSearchView`:
|
||||
- Read `currentFilter` from `useNavigatorUiState` for the initial input value
|
||||
- Local `useState` for the text being typed (mirrors the store value)
|
||||
- Debounce: `useEffect` with 300ms timer calling `useNavigatorUiStore.getState().setFilter(text)`
|
||||
- Remove all `useNavigatorActions` references — the search submit happens via store, query refires automatically
|
||||
- [ ] `yarn typecheck` clean
|
||||
- [ ] `yarn test --run` green
|
||||
- [ ] `yarn lint:hooks` clean
|
||||
- [ ] Commit: `feat(navigator): drive search via TanStack Query + setTab/setFilter UI store actions`
|
||||
|
||||
### Task 5: PR
|
||||
|
||||
- [ ] Push branch
|
||||
- [ ] Open PR against `duckietm:Dev`: `feat(navigator): TanStack Query for search (P2)`
|
||||
@@ -0,0 +1,71 @@
|
||||
# Navigator — Room Settings "Base" tab: stacked-label layout
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Component:** Nitro-V3 client
|
||||
**File:** `src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx`
|
||||
**Type:** Layout-only refactor (no logic / data-flow change)
|
||||
|
||||
## Problem
|
||||
|
||||
The Base tab uses a horizontal two-column row layout: a fixed-width label on the
|
||||
left, the control on the right. In the narrow room-settings panel the label column
|
||||
is too tight, so multi-word Italian labels ("Visitatori massimi", "Impostazioni
|
||||
scambio") wrap onto two lines and look broken. An earlier fix replaced dead
|
||||
Bootstrap `col-3` classes with `w-1/4 shrink-0`, which stopped the crushing but
|
||||
still leaves the labels cramped and occasionally wrapping.
|
||||
|
||||
The other five room-settings tabs (Access, Rights, VIP/Chat, Mod, Misc) already use
|
||||
idiomatic vertical/grouped layouts. Base is the outlier.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt the **stacked-label** pattern (chosen from three mockup options — A stacked,
|
||||
B sectioned cards, C wider label column). Each field becomes a vertical block: bold
|
||||
label on top, full-width control below, validation message underneath. This mirrors
|
||||
the sibling **Access** tab's existing `<Column gap={1}>` + `<Text bold>` shape, so
|
||||
the two tabs become visually consistent and labels can never wrap.
|
||||
|
||||
## Layout
|
||||
|
||||
Every field → its own `<Column gap={1}>` block:
|
||||
|
||||
```tsx
|
||||
<Column gap={ 1 }>
|
||||
<Text bold>{ LocalizeText('navigator.roomname') }</Text>
|
||||
<input className="form-control form-control-sm" value={ roomName } … onBlur={ saveRoomName } />
|
||||
{ (roomName.length < ROOM_NAME_MIN_LENGTH) &&
|
||||
<Text bold small variant="danger">{ LocalizeText('navigator.roomsettings.roomnameismandatory') }</Text> }
|
||||
</Column>
|
||||
```
|
||||
|
||||
Field-by-field:
|
||||
|
||||
- **Nome stanza** — stacked block, mandatory-name validation preserved.
|
||||
- **Descrizione** — stacked block, `<textarea>` full width.
|
||||
- **Categoria** — stacked block, `<select>` from `categories`.
|
||||
- **Visitatori massimi** — stacked block, `<select>` from `GetMaxVisitorsList`.
|
||||
- **Impostazioni scambio** — stacked block, 3-option `<select>`.
|
||||
- **Tag** — one "Tag" label, then the two tag inputs side-by-side in a
|
||||
`<Flex gap={1}>`, each `fullWidth`, each keeping its own length/type validation.
|
||||
- **allow_walkthrough / allow_underpass** — remain inline `checkbox + label` rows;
|
||||
remove the empty `<Base className="w-1/4 shrink-0" />` spacers that only existed
|
||||
to align with the old label column.
|
||||
- **Delete link** — unchanged at the bottom.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- No change to `handleChange` field names or values.
|
||||
- No change to validation thresholds (`ROOM_NAME_MIN_LENGTH=3`,
|
||||
`ROOM_NAME_MAX_LENGTH=60`, `DESC_MAX_LENGTH=255`, `TAGS_MAX_LENGTH=15`).
|
||||
- No change to save-on-blur handlers (`saveRoomName`, `saveRoomDescription`,
|
||||
`saveTags`), the `RoomSettingsSaveErrorEvent` subscription, or `deleteRoom`.
|
||||
- No change to field order or any localization key.
|
||||
- No change to the other five tabs.
|
||||
- The `w-1/4 shrink-0` utility classes added in the prior fix are removed (labels
|
||||
are full-width now).
|
||||
|
||||
## Risk
|
||||
|
||||
Single-file, JSX-only diff. No test covers this view, so no test impact. Manual
|
||||
check: open Room Settings → Base, confirm no label wraps, all controls full width,
|
||||
validation still appears, save-on-blur still fires.
|
||||
Reference in New Issue
Block a user