Skip to content

Commit e2aac09

Browse files
authored
refactor: rewrite redirects list with ListPresenter and ListView (#5261)
1 parent 4d0cde2 commit e2aac09

226 files changed

Lines changed: 4262 additions & 3114 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# Plan: Reusable ListView Compound Component
2+
3+
> Source PRD: `ai-context/prds/reusable-list-view-component.md`
4+
5+
## Context
6+
7+
Every admin list view (Redirects, FileManager, Pages) repeats identical layout boilerplate: SplitView sidebar, header with search/filters/actions, bulk action bar, scrollable content with loadMore, bottom info bar. The only differences are domain-specific (what the sidebar contains, what actions exist, how create/edit works). This component goes in `@webiny/app-admin` and eliminates ~200 lines per module.
8+
9+
## Architectural decisions
10+
11+
- **Location**: `packages/app-admin/src/components/ListView/`
12+
- **Context**: `ListView` provides `{ list: IListViewModel, actions: IListActions, showingFilters, onToggleFilters }` via React context. Domain-specific state (folders, dialogs) stays in the domain presenter context.
13+
- **Composition via named props, not children**: All layout regions (sidebar, header, content, bulkActions, filters, bottomBar) are passed as **named props** on the root `<ListView>` component. No child detection, no `React.Children` inspection. Named props are typeable with TypeScript and unambiguous to parse.
14+
- **Layout detection**: `ListView` accepts an optional `sidebar` prop. When present, wraps in `SplitView` + `LeftPanel` + `RightPanel`. When absent, single-column layout.
15+
- **Dependency direction**: `app-admin` does NOT depend on `app-aco`. The `ListView.Table` bridge is a hook (`useListViewTableProps`) that produces props compatible with the ACO `Table` — consumers spread these onto `<AcoTable>` themselves.
16+
- **Standard cells**: Live in `app-aco` (where `useTableRow` / `TableRowContext` lives). Exported from `@webiny/app-aco`.
17+
- **Existing components reused**: `SplitView`, `LeftPanel`, `RightPanel`, `FiltersToggle`, `Filters`, `Buttons`, `Scrollbar`, `DelayedOnChange`, `Input`, `EmptyView` — all already in `app-admin` or `admin-ui`.
18+
19+
---
20+
21+
## Phase 1: Foundation + Sidebar + Header + BottomBar + Redirects Migration (Tracer Bullet) [x]
22+
23+
**Goal**: Build the core compound component skeleton and migrate Redirects layout to prove the pattern end-to-end.
24+
25+
### What to build
26+
27+
**In `packages/app-admin/src/components/ListView/`:**
28+
29+
1. **`types.ts`**`ListViewContextValue` interface wrapping `IListViewModel<any>`, `IListActions`, `showingFilters: boolean`, `onToggleFilters?: () => void`.
30+
31+
2. **`context.ts`** — React context + `useListView()` hook with missing-provider guard.
32+
33+
3. **`ListView.tsx`** — Root component. All layout regions are **named props**:
34+
- `list: IListViewModel<any>` — view model
35+
- `actions: IListActions` — actions
36+
- `showingFilters?: boolean`
37+
- `onToggleFilters?: () => void`
38+
- `namespace: string` — SplitView localStorage key (e.g., `"wb/redirect/list"`)
39+
- `sidebar?: ReactNode` — content for `LeftPanel`. When present, uses `SplitView`. When absent, single-column.
40+
- `header?: ReactNode` — rendered via `ListView.Header` helper
41+
- `bulkActions?: ReactNode` — rendered via `ListView.BulkActions` helper
42+
- `filters?: ReactNode` — rendered via `ListView.Filters` helper
43+
- `content: ReactNode` — rendered via `ListView.Content` helper (scrollable area)
44+
- `bottomBar?: ReactNode` — rendered via `ListView.BottomBar` helper
45+
46+
Provides context. Composes layout: `SplitView(LeftPanel(sidebar), RightPanel(header + bulkActions + filters + content + bottomBar))`.
47+
48+
4. **`ListViewSidebar.tsx`** — Container component. Props: `title?: string`, `children`. Renders `flex flex-col h-main-content` with optional `Heading` + `Separator`. Includes `Sidebar.Section` sub-component for flex layout (props: `grow?: boolean`, `maxHeight?: string`, `scrollable?: boolean`).
49+
50+
5. **`ListViewHeader.tsx`** — Single component with **named props** for each section:
51+
- `title: { icon: ReactElement; text?: string; after?: ReactNode }` — Renders icon + heading (or Skeleton when text is undefined).
52+
- `search?: { placeholder?: string; id?: string; disabled?: boolean } | true` — Observer. Reads `list.search` / `actions.search` from context. `true` uses defaults.
53+
- `filtersToggle?: boolean` — Observer. Reads `showingFilters` / `onToggleFilters` from context. Wraps existing `FiltersToggle`.
54+
- `actions?: ReactNode` — Slot for custom buttons (e.g., create folder, create redirect). Renders in `flex gap-sm`.
55+
56+
6. **`ListViewBottomBar.tsx`** — Sticky bottom bar with `Separator`. **Named props** for each section:
57+
- `meta?: { itemLabel: string }` — Observer. Reads `list.pagination` from context. Renders "Showing X out of Y {label}s."
58+
- `status?: { loadingText?: string } | true` — Observer. Reads `list.pagination.loadingMore`. Renders spinner + text. `true` uses defaults.
59+
60+
7. **`index.ts`** — Compound component assembly. Exports `ListView` with all sub-components as static properties.
61+
62+
**In `packages/app-website-builder/` (Redirects migration):**
63+
64+
- Rewrite `DocumentList.tsx` to use `<ListView>` with named props. Example shape:
65+
```tsx
66+
<ListView
67+
list={vm.list}
68+
actions={actions}
69+
namespace="wb/redirect/list"
70+
showingFilters={vm.showingFilters}
71+
onToggleFilters={...}
72+
sidebar={
73+
<ListView.Sidebar title="Redirects">
74+
<ListView.Sidebar.Section grow>
75+
<FolderTree vm={vm.folders} actions={actions.folders} ... />
76+
</ListView.Sidebar.Section>
77+
</ListView.Sidebar>
78+
}
79+
header={
80+
<ListView.Header
81+
title={{ icon: <HomeIcon />, text: "Home" }}
82+
search={{ placeholder: "Search..." }}
83+
filtersToggle
84+
actions={<ButtonsCreate ... />}
85+
/>
86+
}
87+
content={...existing Table + Empty for now...}
88+
bottomBar={
89+
<ListView.BottomBar
90+
meta={{ itemLabel: "redirect" }}
91+
status
92+
/>
93+
}
94+
/>
95+
```
96+
- Delete: `Layout/Layout.tsx`, `Header/Header.tsx`, `Header/Title.tsx`, `Header/Search.tsx`, `Header/ButtonFilters.tsx`, `BottomInfoBar/BottomInfoBar.tsx`, `BottomInfoBar/ListMeta.tsx`, `BottomInfoBar/ListStatus.tsx`.
97+
- Keep: `Header/ButtonsCreate.tsx` (passed to header actions), `Main/Main.tsx` (simplified), `Table/`, `BulkActions/`, `Filters/`, `Empty/`, `Sidebar/Sidebar.tsx` (simplified — just FolderTree content, no layout chrome).
98+
99+
### Acceptance criteria
100+
101+
- [ ] `useListView()` returns context inside `<ListView>`, throws outside
102+
- [ ] Redirects list renders with SplitView sidebar + header (title, search, filters toggle, create buttons) + bottom bar from `ListView.*`
103+
- [ ] Visual parity with current Redirects layout
104+
- [ ] ~8 files deleted from Redirects module
105+
106+
### Key files to modify
107+
108+
- Create: `packages/app-admin/src/components/ListView/*.tsx`
109+
- Modify: `packages/app-website-builder/.../RedirectList/components/DocumentList.tsx`
110+
- Delete: `Layout/`, `Header/Header.tsx`, `Header/Title.tsx`, `Header/Search.tsx`, `Header/ButtonFilters.tsx`, `BottomInfoBar/`
111+
112+
---
113+
114+
## Phase 2: BulkActions + Filters + Content (scroll-to-loadMore) + Empty [x]
115+
116+
**Goal**: Complete the content area sub-components and finish the Redirects migration (except Table bridge).
117+
118+
### What to build
119+
120+
**In `packages/app-admin/src/components/ListView/`:**
121+
122+
1. **`ListViewBulkActions.tsx`** — Observer. Props: `itemLabel: string`, `itemLabelPlural?: string`, `actions: BulkActionConfig[]`. Reads `list.selection` from context. Renders selection count + `<Buttons actions={...}>` + deselect `IconButton`. Hidden when `selectedCount === 0`. Passed as `bulkActions` prop to `<ListView>`.
123+
124+
2. **`ListViewFilters.tsx`** — Observer. Props: `filters: FilterConfig[]`, `filtersToWhere?: FiltersToWhereConverter[]`. Reads `showingFilters` and `actions.filter` from context. Wraps existing `<Filters>` from `app-admin`. Applies `filtersToWhere` converters before dispatching to `actions.filter.set`. Passed as `filters` prop to `<ListView>`.
125+
126+
3. **`ListViewContent.tsx`** — Scrollable wrapper. Props: `loadMoreThreshold?: number` (default 0.8), `loadMoreDebounceMs?: number` (default 200), `empty?: ReactNode`, `searchEmpty?: ReactNode`, `children: ReactNode`. Wraps children in `<Scrollbar>`. Debounced `onScrollFrame` calls `actions.loadMore()` when threshold exceeded. Handles empty state logic internally: when `list.empty` is true, renders `empty`; when `list.emptyWithFilters` is true, renders `searchEmpty`; otherwise renders `children`. Passed as `content` prop to `<ListView>`.
127+
128+
This merges `ListViewContent` + `ListViewEmpty` into a single component since the empty state is always inside the content area.
129+
130+
**In Redirects:**
131+
132+
- Rewrite `Main.tsx` — remove BulkActions, Filters, Scrollbar, Empty conditional logic. These now come from `ListView.*` sub-components.
133+
- Delete: `BulkActions/BulkActions.tsx` (the UI wrapper — keep `BulkActionDelete.tsx` and `BulkActionMove.tsx` as they are domain-specific actions registered via config), `Filters/Filters.tsx`.
134+
- Keep: `Empty/Empty.tsx` (domain-specific empty state), `Filters/FilterByStatus.tsx` (domain-specific filter), `Table/Table.tsx`.
135+
136+
### Acceptance criteria
137+
138+
- [ ] Redirects uses `ListView.BulkActions`, `ListView.Filters`, `ListView.Content` (with built-in empty state handling)
139+
- [ ] Scroll-to-loadMore works identically (debounced, 80% threshold)
140+
- [ ] Bulk action bar shows/hides based on selection, with correct label
141+
- [ ] Filters toggle shows/hides filter panel
142+
- [ ] Empty state renders correctly (search empty vs default empty)
143+
- [ ] `Main.tsx` reduced to just composing `ListView.*` sub-components
144+
145+
### Key files to modify
146+
147+
- Create: `ListViewBulkActions.tsx`, `ListViewFilters.tsx`, `ListViewContent.tsx`
148+
- Modify: `Main.tsx` → simplified or deleted (content moves to `DocumentList.tsx`)
149+
- Delete: `BulkActions/BulkActions.tsx`, `Filters/Filters.tsx`
150+
151+
---
152+
153+
## Phase 3: ListView.Table Bridge + Standard Cells [x]
154+
155+
**Goal**: Extract the Table bridge hook and standard cells. Eliminate the per-module table adapter and duplicated cell components.
156+
157+
### What to build
158+
159+
**In `packages/app-admin/src/components/ListView/`:**
160+
161+
1. **`ListViewTable.tsx`** — Exports `useListViewTableProps(options)` hook. Options: `{ namespace: string, nameColumnId?: string }`. Returns `{ sorting, onSortingChange, onSelectRow, selected, loading }` computed from `useListView()` context. This maps `IListViewModel.sort``[{ id, desc }]` and `onSortingChange``actions.sort.set()`, etc.
162+
163+
**In `packages/app-aco/` (standard cells):**
164+
165+
2. **`src/components/Table/cells/CellAuthor.tsx`** — Reads `row.data.createdBy.displayName` from `TableRowContext`.
166+
3. **`src/components/Table/cells/CellCreated.tsx`** — Reads `row.data.createdOn`, renders via `<TimeAgo>`.
167+
4. **`src/components/Table/cells/CellModified.tsx`** — Reads `row.data.savedOn`, renders via `<TimeAgo>`.
168+
169+
Standard cells use `TableRowContext` directly (not `createUseTableRow`) since they need to work with any table row type. Export from `@webiny/app-aco`.
170+
171+
**In Redirects:**
172+
173+
- Rewrite `Table/Table.tsx` — use `useListViewTableProps()` hook, spread result onto `<AcoTable>`. Keeps row mapping logic (domain-specific `TableRowMapper`) but eliminates the sort/selection bridge.
174+
- Delete: `Table/Cells/CellAuthor.tsx`, `Table/Cells/CellCreated.tsx`, `Table/Cells/CellModified.tsx` — replaced by standard cells from `app-aco`.
175+
- Update `RedirectsListConfig.tsx` to import standard cells from `@webiny/app-aco`.
176+
177+
### Acceptance criteria
178+
179+
- [ ] `useListViewTableProps()` correctly bridges sort (ListPresenter ↔ ACO Table format)
180+
- [ ] `useListViewTableProps()` correctly bridges selection (selectedIds → selected rows, onSelectRow → actions.selection.selectRows)
181+
- [ ] Standard cells render author name, created date, modified date correctly
182+
- [ ] Redirects `Table.tsx` reduced from ~55 to ~20 lines
183+
- [ ] Standard cells used in `RedirectsListConfig.tsx`
184+
185+
### Key files to modify
186+
187+
- Create: `ListViewTable.tsx` in `app-admin`, `cells/` in `app-aco`
188+
- Modify: `Table/Table.tsx`, `RedirectsListConfig.tsx`
189+
- Delete: `Table/Cells/CellAuthor.tsx`, `Table/Cells/CellCreated.tsx`, `Table/Cells/CellModified.tsx`
190+
191+
---
192+
193+
## Phase 4: Cleanup + Exports [x]
194+
195+
**Goal**: Final cleanup, proper public API exports, delete all dead code.
196+
197+
### What to build
198+
199+
- Update `packages/app-admin/src/components/index.ts` (or equivalent barrel) to export `ListView` and `useListView`.
200+
- Update `packages/app-aco/src/index.ts` to export standard cells.
201+
- Delete all unused files in Redirects module.
202+
- Verify no broken imports across the monorepo.
203+
204+
### Acceptance criteria
205+
206+
- [ ] `ListView` exported from `@webiny/app-admin`
207+
- [ ] Standard cells exported from `@webiny/app-aco`
208+
- [ ] No dead code in Redirects module
209+
- [ ] `yarn build` passes for `app-admin`, `app-aco`, `app-website-builder`
210+
- [ ] `yarn lint` passes
211+
212+
---
213+
214+
## Verification
215+
216+
After each phase:
217+
218+
1. `yarn build -p @webiny/app-admin --no-cache --safe-replace` — confirm build
219+
2. `yarn build -p @webiny/app-website-builder --no-cache --safe-replace` — confirm consumer builds
220+
3. Start dev server, navigate to Redirects list — visual parity check
221+
4. Test: search, filter toggle, sort columns, select rows, bulk actions, scroll loadMore, empty states, folder navigation

0 commit comments

Comments
 (0)