@kelpie/ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/dist/api/resource.d.ts +58 -5
  2. package/dist/api/resource.d.ts.map +1 -1
  3. package/dist/api/resource.js +106 -24
  4. package/dist/api/resource.js.map +1 -1
  5. package/dist/api/resources/companies.d.ts +2 -0
  6. package/dist/api/resources/companies.d.ts.map +1 -1
  7. package/dist/api/resources/companies.js +5 -1
  8. package/dist/api/resources/companies.js.map +1 -1
  9. package/dist/api/resources/decisions.d.ts +2 -0
  10. package/dist/api/resources/decisions.d.ts.map +1 -1
  11. package/dist/api/resources/decisions.js +1 -0
  12. package/dist/api/resources/decisions.js.map +1 -1
  13. package/dist/api/resources/emailDomainLinker.d.ts +16 -0
  14. package/dist/api/resources/emailDomainLinker.d.ts.map +1 -0
  15. package/dist/api/resources/emailDomainLinker.js +16 -0
  16. package/dist/api/resources/emailDomainLinker.js.map +1 -0
  17. package/dist/api/resources/forms.d.ts.map +1 -1
  18. package/dist/api/resources/forms.js +6 -20
  19. package/dist/api/resources/forms.js.map +1 -1
  20. package/dist/api/resources/handbookPages.d.ts +1 -1
  21. package/dist/api/resources/handbookPages.js +1 -1
  22. package/dist/api/resources/listMembers.d.ts +27 -0
  23. package/dist/api/resources/listMembers.d.ts.map +1 -0
  24. package/dist/api/resources/listMembers.js +108 -0
  25. package/dist/api/resources/listMembers.js.map +1 -0
  26. package/dist/api/resources/lists.d.ts +14 -0
  27. package/dist/api/resources/lists.d.ts.map +1 -0
  28. package/dist/api/resources/lists.js +34 -0
  29. package/dist/api/resources/lists.js.map +1 -0
  30. package/dist/api/resources/people.d.ts.map +1 -1
  31. package/dist/api/resources/people.js +4 -1
  32. package/dist/api/resources/people.js.map +1 -1
  33. package/dist/api/resources/roles.d.ts +2 -0
  34. package/dist/api/resources/roles.d.ts.map +1 -1
  35. package/dist/api/resources/roles.js +1 -1
  36. package/dist/api/resources/roles.js.map +1 -1
  37. package/dist/api/resources/sampleData.d.ts +17 -0
  38. package/dist/api/resources/sampleData.d.ts.map +1 -0
  39. package/dist/api/resources/sampleData.js +30 -0
  40. package/dist/api/resources/sampleData.js.map +1 -0
  41. package/dist/api/resources/webhooks.d.ts.map +1 -1
  42. package/dist/api/resources/webhooks.js +7 -22
  43. package/dist/api/resources/webhooks.js.map +1 -1
  44. package/dist/app/KelpieApp.d.ts.map +1 -1
  45. package/dist/app/KelpieApp.js +3 -1
  46. package/dist/app/KelpieApp.js.map +1 -1
  47. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  48. package/dist/components/ActivitiesPanel.js +2 -1
  49. package/dist/components/ActivitiesPanel.js.map +1 -1
  50. package/dist/components/ColumnPicker.d.ts +24 -0
  51. package/dist/components/ColumnPicker.d.ts.map +1 -0
  52. package/dist/components/ColumnPicker.js +56 -0
  53. package/dist/components/ColumnPicker.js.map +1 -0
  54. package/dist/components/DataTable.d.ts +16 -1
  55. package/dist/components/DataTable.d.ts.map +1 -1
  56. package/dist/components/DataTable.js +51 -10
  57. package/dist/components/DataTable.js.map +1 -1
  58. package/dist/components/DecisionsPanel.d.ts.map +1 -1
  59. package/dist/components/DecisionsPanel.js +4 -3
  60. package/dist/components/DecisionsPanel.js.map +1 -1
  61. package/dist/components/EntitySearch.d.ts.map +1 -1
  62. package/dist/components/EntitySearch.js +11 -5
  63. package/dist/components/EntitySearch.js.map +1 -1
  64. package/dist/components/ListsPanel.d.ts +14 -0
  65. package/dist/components/ListsPanel.d.ts.map +1 -0
  66. package/dist/components/ListsPanel.js +56 -0
  67. package/dist/components/ListsPanel.js.map +1 -0
  68. package/dist/components/NotesPanel.d.ts.map +1 -1
  69. package/dist/components/NotesPanel.js +6 -5
  70. package/dist/components/NotesPanel.js.map +1 -1
  71. package/dist/components/Paginator.d.ts +26 -0
  72. package/dist/components/Paginator.d.ts.map +1 -0
  73. package/dist/components/Paginator.js +16 -0
  74. package/dist/components/Paginator.js.map +1 -0
  75. package/dist/components/PlanPanel.d.ts.map +1 -1
  76. package/dist/components/PlanPanel.js +2 -1
  77. package/dist/components/PlanPanel.js.map +1 -1
  78. package/dist/components/Shell.d.ts.map +1 -1
  79. package/dist/components/Shell.js +1 -0
  80. package/dist/components/Shell.js.map +1 -1
  81. package/dist/index.d.ts +8 -2
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +4 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/lib/listView.d.ts +15 -0
  86. package/dist/lib/listView.d.ts.map +1 -0
  87. package/dist/lib/listView.js +66 -0
  88. package/dist/lib/listView.js.map +1 -0
  89. package/dist/lib/sort.d.ts +43 -0
  90. package/dist/lib/sort.d.ts.map +1 -0
  91. package/dist/lib/sort.js +102 -0
  92. package/dist/lib/sort.js.map +1 -0
  93. package/dist/pages/CompaniesPage.d.ts.map +1 -1
  94. package/dist/pages/CompaniesPage.js +88 -3
  95. package/dist/pages/CompaniesPage.js.map +1 -1
  96. package/dist/pages/CompanyDetail.d.ts.map +1 -1
  97. package/dist/pages/CompanyDetail.js +17 -14
  98. package/dist/pages/CompanyDetail.js.map +1 -1
  99. package/dist/pages/DealDetail.d.ts.map +1 -1
  100. package/dist/pages/DealDetail.js +3 -1
  101. package/dist/pages/DealDetail.js.map +1 -1
  102. package/dist/pages/DealsPage.d.ts.map +1 -1
  103. package/dist/pages/DealsPage.js +134 -14
  104. package/dist/pages/DealsPage.js.map +1 -1
  105. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  106. package/dist/pages/DecisionsPage.js +50 -8
  107. package/dist/pages/DecisionsPage.js.map +1 -1
  108. package/dist/pages/FormsPage.d.ts.map +1 -1
  109. package/dist/pages/FormsPage.js +53 -2
  110. package/dist/pages/FormsPage.js.map +1 -1
  111. package/dist/pages/FundraisingPage.d.ts.map +1 -1
  112. package/dist/pages/FundraisingPage.js +116 -14
  113. package/dist/pages/FundraisingPage.js.map +1 -1
  114. package/dist/pages/HiringPage.d.ts.map +1 -1
  115. package/dist/pages/HiringPage.js +27 -4
  116. package/dist/pages/HiringPage.js.map +1 -1
  117. package/dist/pages/ListDetail.d.ts +11 -0
  118. package/dist/pages/ListDetail.d.ts.map +1 -0
  119. package/dist/pages/ListDetail.js +188 -0
  120. package/dist/pages/ListDetail.js.map +1 -0
  121. package/dist/pages/ListsPage.d.ts +9 -0
  122. package/dist/pages/ListsPage.d.ts.map +1 -0
  123. package/dist/pages/ListsPage.js +125 -0
  124. package/dist/pages/ListsPage.js.map +1 -0
  125. package/dist/pages/OpportunitiesPage.d.ts.map +1 -1
  126. package/dist/pages/OpportunitiesPage.js +97 -14
  127. package/dist/pages/OpportunitiesPage.js.map +1 -1
  128. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  129. package/dist/pages/OpportunityDetail.js +3 -1
  130. package/dist/pages/OpportunityDetail.js.map +1 -1
  131. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  132. package/dist/pages/PartnershipDetail.js +3 -1
  133. package/dist/pages/PartnershipDetail.js.map +1 -1
  134. package/dist/pages/PartnershipsPage.d.ts.map +1 -1
  135. package/dist/pages/PartnershipsPage.js +111 -14
  136. package/dist/pages/PartnershipsPage.js.map +1 -1
  137. package/dist/pages/PeoplePage.d.ts.map +1 -1
  138. package/dist/pages/PeoplePage.js +102 -3
  139. package/dist/pages/PeoplePage.js.map +1 -1
  140. package/dist/pages/PersonDetail.d.ts.map +1 -1
  141. package/dist/pages/PersonDetail.js +5 -3
  142. package/dist/pages/PersonDetail.js.map +1 -1
  143. package/dist/pages/PlanningPage.d.ts.map +1 -1
  144. package/dist/pages/PlanningPage.js +85 -17
  145. package/dist/pages/PlanningPage.js.map +1 -1
  146. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  147. package/dist/pages/RaiseDetail.js +3 -1
  148. package/dist/pages/RaiseDetail.js.map +1 -1
  149. package/dist/pages/RoleDetail.d.ts.map +1 -1
  150. package/dist/pages/RoleDetail.js +5 -4
  151. package/dist/pages/RoleDetail.js.map +1 -1
  152. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  153. package/dist/pages/admin/DataPage.js +110 -12
  154. package/dist/pages/admin/DataPage.js.map +1 -1
  155. package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
  156. package/dist/pages/admin/WebhookDeliveries.js +3 -2
  157. package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
  158. package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
  159. package/dist/pages/admin/WebhooksPage.js +3 -2
  160. package/dist/pages/admin/WebhooksPage.js.map +1 -1
  161. package/dist/pages/admin/WorkspaceSettingsPage.d.ts.map +1 -1
  162. package/dist/pages/admin/WorkspaceSettingsPage.js +45 -2
  163. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  164. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  165. package/dist/pages/forms/SubmissionsTable.js +2 -1
  166. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  167. package/dist/pages/handbook/HandbookPage.js +1 -1
  168. package/dist/pages/hiringDirectory.js +2 -2
  169. package/dist/pages/onboarding/WorkspaceStepPage.d.ts.map +1 -1
  170. package/dist/pages/onboarding/WorkspaceStepPage.js +15 -2
  171. package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
  172. package/dist/pages/positionDirectory.js +2 -2
  173. package/package.json +2 -2
  174. package/src/api/resource.ts +149 -31
  175. package/src/api/resources/companies.ts +7 -1
  176. package/src/api/resources/decisions.ts +3 -0
  177. package/src/api/resources/emailDomainLinker.ts +43 -0
  178. package/src/api/resources/forms.ts +5 -23
  179. package/src/api/resources/handbookPages.ts +1 -1
  180. package/src/api/resources/listMembers.ts +173 -0
  181. package/src/api/resources/lists.ts +61 -0
  182. package/src/api/resources/people.ts +4 -1
  183. package/src/api/resources/roles.ts +3 -1
  184. package/src/api/resources/sampleData.ts +56 -0
  185. package/src/api/resources/webhooks.ts +8 -24
  186. package/src/app/KelpieApp.tsx +4 -0
  187. package/src/components/ActivitiesPanel.tsx +2 -10
  188. package/src/components/ColumnPicker.tsx +124 -0
  189. package/src/components/DataTable.tsx +89 -10
  190. package/src/components/DecisionsPanel.tsx +4 -12
  191. package/src/components/EntitySearch.tsx +12 -5
  192. package/src/components/ListsPanel.tsx +176 -0
  193. package/src/components/NotesPanel.tsx +6 -14
  194. package/src/components/Paginator.tsx +78 -0
  195. package/src/components/PlanPanel.tsx +2 -10
  196. package/src/components/Shell.tsx +1 -0
  197. package/src/index.ts +10 -1
  198. package/src/lib/listView.ts +149 -0
  199. package/src/lib/sort.ts +143 -0
  200. package/src/pages/CompaniesPage.tsx +125 -11
  201. package/src/pages/CompanyDetail.tsx +62 -62
  202. package/src/pages/DealDetail.tsx +3 -0
  203. package/src/pages/DealsPage.tsx +178 -24
  204. package/src/pages/DecisionsPage.tsx +73 -18
  205. package/src/pages/FormsPage.tsx +81 -17
  206. package/src/pages/FundraisingPage.tsx +152 -24
  207. package/src/pages/HiringPage.tsx +39 -11
  208. package/src/pages/ListDetail.tsx +408 -0
  209. package/src/pages/ListsPage.tsx +281 -0
  210. package/src/pages/OpportunitiesPage.tsx +130 -24
  211. package/src/pages/OpportunityDetail.tsx +3 -0
  212. package/src/pages/PartnershipDetail.tsx +3 -0
  213. package/src/pages/PartnershipsPage.tsx +149 -24
  214. package/src/pages/PeoplePage.tsx +137 -11
  215. package/src/pages/PersonDetail.tsx +5 -3
  216. package/src/pages/PlanningPage.tsx +256 -29
  217. package/src/pages/RaiseDetail.tsx +3 -0
  218. package/src/pages/RoleDetail.tsx +21 -26
  219. package/src/pages/admin/DataPage.tsx +276 -13
  220. package/src/pages/admin/WebhookDeliveries.tsx +10 -15
  221. package/src/pages/admin/WebhooksPage.tsx +14 -19
  222. package/src/pages/admin/WorkspaceSettingsPage.tsx +134 -2
  223. package/src/pages/forms/SubmissionsTable.tsx +3 -10
  224. package/src/pages/handbook/HandbookPage.tsx +1 -1
  225. package/src/pages/hiringDirectory.ts +2 -2
  226. package/src/pages/onboarding/WorkspaceStepPage.tsx +31 -3
  227. package/src/pages/positionDirectory.ts +2 -2
  228. package/src/styles.css +3 -3
@@ -1,5 +1,8 @@
1
1
  import type { ReactNode } from 'react'
2
2
 
3
+ import { sortRowsBy } from '../lib/sort.ts'
4
+ import type { SortValue } from '../lib/sort.ts'
5
+
3
6
  export interface Column<TRow> {
4
7
  readonly key: string
5
8
  readonly header: string
@@ -7,6 +10,14 @@ export interface Column<TRow> {
7
10
  readonly render: (row: TRow) => ReactNode
8
11
  /** The `?sort=` field this column drives. Omit for a column with no server-side sort. */
9
12
  readonly sortKey?: string | undefined
13
+ /**
14
+ * Returns the value this column sorts by on the client. Set on any column
15
+ * whose resource cannot sort on it server-side (join columns, computed
16
+ * values, fields not in the resource's `_SORTS` map). When the active sort
17
+ * names a `Column.key` with this set, `DataTable` orders the loaded rows in
18
+ * place and does not forward the change to the server.
19
+ */
20
+ readonly getSortValue?: ((row: TRow) => SortValue) | undefined
10
21
  }
11
22
 
12
23
  export interface DataTableGroup<TRow> {
@@ -35,6 +46,12 @@ export interface DataTableProps<TRow> {
35
46
  readonly sort?: string | undefined
36
47
  /** Fires with the next `?sort=` value when a sortable header is clicked. Required for any column to be clickable. */
37
48
  readonly onSortChange?: ((sort: string | undefined) => void) | undefined
49
+ /**
50
+ * Restricts rendering to these column keys, in the order the header uses.
51
+ * Undefined shows every column. An unknown key is ignored rather than raising:
52
+ * a stored preference outliving a rename should hide only what it recognises.
53
+ */
54
+ readonly visibleColumnKeys?: readonly string[] | undefined
38
55
  }
39
56
 
40
57
  type SortDirection = 'asc' | 'desc'
@@ -62,6 +79,35 @@ function nextSort(sort: string | undefined, field: string): string | undefined {
62
79
  return current === 'asc' ? `-${field}` : undefined
63
80
  }
64
81
 
82
+ /**
83
+ * The identifier a column contributes to the sort string. Server sorts use the
84
+ * `sortKey` — the field the server knows — so a stored sort keeps working when
85
+ * a column is renamed in the UI. Client-only sorts fall back to the display
86
+ * `key`, since there is no server field to reference.
87
+ */
88
+ function sortFieldOf<TRow>(column: Column<TRow>): string | undefined {
89
+ if (column.sortKey !== undefined) {
90
+ return column.sortKey
91
+ }
92
+
93
+ return column.getSortValue !== undefined ? column.key : undefined
94
+ }
95
+
96
+ function findActiveClientColumn<TRow>(
97
+ columns: readonly Column<TRow>[],
98
+ sort: string | undefined,
99
+ ): Column<TRow> | undefined {
100
+ if (sort === undefined) {
101
+ return undefined
102
+ }
103
+
104
+ const bare = sort.startsWith('-') ? sort.slice(1) : sort
105
+
106
+ return columns.find(
107
+ (column) => column.getSortValue !== undefined && column.sortKey === undefined && column.key === bare,
108
+ )
109
+ }
110
+
65
111
  export function DataTable<TRow>({
66
112
  columns,
67
113
  rows,
@@ -73,8 +119,34 @@ export function DataTable<TRow>({
73
119
  getRowId,
74
120
  sort,
75
121
  onSortChange,
122
+ visibleColumnKeys,
76
123
  }: DataTableProps<TRow>): React.JSX.Element {
77
124
  const flatRows = groups === undefined ? (rows ?? []) : groups.flatMap((group) => group.rows)
125
+ const displayedColumns =
126
+ visibleColumnKeys === undefined
127
+ ? columns
128
+ : columns.filter((column) => visibleColumnKeys.includes(column.key))
129
+
130
+ // Client sort is a page-local reorder: the server-returned page order stays
131
+ // the same, and only what is on screen shifts. Groups sort within each group,
132
+ // because the group is the primary key of the view — mixing rows across
133
+ // groups would break what the group header claims to count.
134
+ const clientSortColumn = findActiveClientColumn(displayedColumns, sort)
135
+ const clientDirection: SortDirection | undefined =
136
+ clientSortColumn === undefined
137
+ ? undefined
138
+ : directionOf(sort, clientSortColumn.key) ?? undefined
139
+ const displayedRows =
140
+ clientSortColumn === undefined || clientDirection === undefined || rows === undefined
141
+ ? rows
142
+ : sortRowsBy(rows, clientSortColumn.getSortValue!, clientDirection)
143
+ const displayedGroups =
144
+ clientSortColumn === undefined || clientDirection === undefined || groups === undefined
145
+ ? groups
146
+ : groups.map((group) => ({
147
+ ...group,
148
+ rows: sortRowsBy(group.rows, clientSortColumn.getSortValue!, clientDirection),
149
+ }))
78
150
 
79
151
  if (flatRows.length === 0) {
80
152
  return (
@@ -99,20 +171,21 @@ export function DataTable<TRow>({
99
171
  <table className="w-full border-collapse text-left text-[13px]">
100
172
  <thead>
101
173
  <tr className="border-b border-border">
102
- {columns.map((column) => {
103
- const direction = directionOf(sort, column.sortKey)
104
- const sortable = column.sortKey !== undefined && onSortChange !== undefined
174
+ {displayedColumns.map((column) => {
175
+ const field = sortFieldOf(column)
176
+ const direction = directionOf(sort, field)
177
+ const sortable = field !== undefined && onSortChange !== undefined
105
178
 
106
179
  return (
107
180
  <th
108
181
  key={column.key}
109
182
  className={`px-3 py-2 text-[11px] font-medium text-ink-faint ${column.className ?? ''}`}
110
183
  >
111
- {sortable ? (
184
+ {sortable && field !== undefined ? (
112
185
  <button
113
186
  type="button"
114
187
  onClick={() => {
115
- onSortChange?.(nextSort(sort, column.sortKey ?? ''))
188
+ onSortChange?.(nextSort(sort, field))
116
189
  }}
117
190
  className="inline-flex items-center gap-1 transition hover:text-ink"
118
191
  >
@@ -130,26 +203,32 @@ export function DataTable<TRow>({
130
203
  </tr>
131
204
  </thead>
132
205
  <tbody>
133
- {groups === undefined
134
- ? (rows ?? []).map((row) => (
206
+ {displayedGroups === undefined
207
+ ? (displayedRows ?? []).map((row) => (
135
208
  <DataRow
136
209
  key={getRowId(row)}
137
210
  row={row}
138
- columns={columns}
211
+ columns={displayedColumns}
139
212
  onRowClick={onRowClick}
140
213
  />
141
214
  ))
142
- : groups.map((group) => (
215
+ : displayedGroups.map((group) => (
143
216
  <GroupRows
144
217
  key={group.id}
145
218
  group={group}
146
- columns={columns}
219
+ columns={displayedColumns}
147
220
  onRowClick={onRowClick}
148
221
  getRowId={getRowId}
149
222
  />
150
223
  ))}
151
224
  </tbody>
152
225
  </table>
226
+ {clientSortColumn !== undefined && (
227
+ <p className="border-t border-border bg-surface-sunken/40 px-3 py-1.5 text-[11px] text-ink-faint">
228
+ Sorted by {clientSortColumn.header.toLowerCase()} on this page. Other pages sort by their
229
+ own defaults.
230
+ </p>
231
+ )}
153
232
  </div>
154
233
  )
155
234
  }
@@ -7,6 +7,7 @@ import { useTimezone } from '../api/resources/account.ts'
7
7
  import { useCreateDecision, useDecisions, useDeleteDecision } from '../api/resources/decisions.ts'
8
8
  import { useMembers } from '../api/resources/members.ts'
9
9
  import { formatDate } from '../lib/dates.ts'
10
+ import { Paginator } from './Paginator.tsx'
10
11
  import { ErrorPanel } from './QueryState.tsx'
11
12
  import { SectionHeader } from './SectionHeader.tsx'
12
13
 
@@ -35,8 +36,8 @@ export function DecisionsPanel({ targetType, targetId }: DecisionsPanelProps): R
35
36
 
36
37
  // Due date first, decided date otherwise, newest down — the mockup's order.
37
38
  // The list arrives in `-decided_at` order and the API cannot sort on the
38
- // nullable `due_at`, so this reorders what is loaded; across a "Load more"
39
- // boundary the interleave can be wrong, which is the cheaper wrong than a
39
+ // nullable `due_at`, so this reorders what is on this page; across page
40
+ // boundaries the interleave can be wrong, which is the cheaper wrong than a
40
41
  // second request per panel.
41
42
  const ordered = [...decisions.records].sort(
42
43
  (left, right) =>
@@ -167,16 +168,7 @@ export function DecisionsPanel({ targetType, targetId }: DecisionsPanelProps): R
167
168
  )
168
169
  )}
169
170
 
170
- {decisions.hasMore && (
171
- <button
172
- type="button"
173
- onClick={decisions.loadMore}
174
- disabled={decisions.isLoadingMore}
175
- className="mt-3 rounded-md border border-border px-3 py-1.5 text-[12px] font-medium text-ink-muted transition hover:border-border-strong hover:text-ink"
176
- >
177
- {decisions.isLoadingMore ? 'Loading…' : 'Load more'}
178
- </button>
179
- )}
171
+ <Paginator list={decisions} />
180
172
 
181
173
  <p className="mt-2 text-[11px] text-ink-faint">
182
174
  <Link to="/decisions" className="text-accent hover:underline">
@@ -50,10 +50,16 @@ export function EntitySearch({
50
50
  }: EntitySearchProps): React.JSX.Element {
51
51
  const listId = useId()
52
52
  const rootRef = useRef<HTMLDivElement>(null)
53
- const selected = options.find((option) => option.id === value)
54
53
  const [query, setQuery] = useState('')
55
54
  const [open, setOpen] = useState(false)
56
55
  const [highlight, setHighlight] = useState(0)
56
+ // A picked option must keep rendering even when the current `options` list
57
+ // no longer contains it — for a server-backed search, clearing the query on
58
+ // pick swaps the answer to a different page that may not include the pick.
59
+ const [remembered, setRemembered] = useState<SearchOption | null>(null)
60
+ const selected =
61
+ options.find((option) => option.id === value) ??
62
+ (remembered !== null && remembered.id === value ? remembered : undefined)
57
63
 
58
64
  const filtered = useMemo(() => {
59
65
  const term = query.trim().toLowerCase()
@@ -105,8 +111,9 @@ export function EntitySearch({
105
111
  onQueryChange?.(next)
106
112
  }
107
113
 
108
- function pick(id: string): void {
109
- onChange(id)
114
+ function pick(option: SearchOption): void {
115
+ setRemembered(option)
116
+ onChange(option.id)
110
117
  setOpen(false)
111
118
  search('')
112
119
  }
@@ -183,7 +190,7 @@ export function EntitySearch({
183
190
  const hit = filtered[highlight]
184
191
 
185
192
  if (hit !== undefined) {
186
- pick(hit.id)
193
+ pick(hit)
187
194
  } else if (showCreate) {
188
195
  create()
189
196
  }
@@ -219,7 +226,7 @@ export function EntitySearch({
219
226
  setHighlight(index)
220
227
  }}
221
228
  onClick={() => {
222
- pick(option.id)
229
+ pick(option)
223
230
  }}
224
231
  className={[
225
232
  'flex w-full flex-col px-3 py-1.5 text-left',
@@ -0,0 +1,176 @@
1
+ import { RECORD_TARGET_TYPE_LABELS } from '@kelpie/schemas'
2
+ import type { List, ListMembership, RecordTargetType } from '@kelpie/schemas'
3
+ import { useState } from 'react'
4
+ import { Link } from 'react-router'
5
+
6
+ import {
7
+ useAddListMember,
8
+ useListMembershipsFor,
9
+ useRemoveListMember,
10
+ } from '../api/resources/listMembers.ts'
11
+ import { useLists } from '../api/resources/lists.ts'
12
+ import { EntitySearch } from './EntitySearch.tsx'
13
+ import type { SearchOption } from './EntitySearch.tsx'
14
+ import { ErrorPanel, LoadingPanel } from './QueryState.tsx'
15
+ import { SectionHeader } from './SectionHeader.tsx'
16
+
17
+ /**
18
+ * "What lists is this record on?" panel, for any record's detail page.
19
+ *
20
+ * A record can only join a list whose `target_type` matches its own, so the
21
+ * picker is filtered accordingly. Removing a chip calls
22
+ * `DELETE /v1/lists/{id}/members/{memberId}` and the cached counts on the
23
+ * lists index refresh with it.
24
+ */
25
+ export function ListsPanel({
26
+ targetType,
27
+ targetId,
28
+ }: {
29
+ readonly targetType: RecordTargetType
30
+ readonly targetId: string
31
+ }): React.JSX.Element {
32
+ const memberships = useListMembershipsFor(targetType, targetId)
33
+ const [adding, setAdding] = useState(false)
34
+
35
+ return (
36
+ <section>
37
+ <SectionHeader
38
+ title="Lists"
39
+ onAdd={() => {
40
+ setAdding((current) => !current)
41
+ }}
42
+ addLabel="Add to list"
43
+ />
44
+
45
+ {adding && (
46
+ <div className="mb-3 flex gap-2">
47
+ <div className="min-w-0 flex-1">
48
+ <ListPicker
49
+ targetType={targetType}
50
+ targetId={targetId}
51
+ excludeListIds={new Set(memberships.memberships.map((row) => row.listId))}
52
+ onAdded={() => {
53
+ setAdding(false)
54
+ }}
55
+ />
56
+ </div>
57
+ <button
58
+ type="button"
59
+ onClick={() => {
60
+ setAdding(false)
61
+ }}
62
+ className="shrink-0 rounded-md px-2.5 py-1.5 text-[12px] font-medium text-ink-muted hover:text-ink"
63
+ >
64
+ Cancel
65
+ </button>
66
+ </div>
67
+ )}
68
+
69
+ {memberships.error !== null && <ErrorPanel error={memberships.error} />}
70
+
71
+ {memberships.isLoading ? (
72
+ <LoadingPanel label="Loading lists…" />
73
+ ) : memberships.memberships.length === 0 && !adding ? (
74
+ <p className="text-[13px] text-ink-faint">
75
+ Not on any lists yet. Add this {RECORD_TARGET_TYPE_LABELS[targetType].toLowerCase()} to
76
+ a list to group it with others.
77
+ </p>
78
+ ) : (
79
+ <ul className="overflow-hidden rounded-md border border-border">
80
+ {memberships.memberships.map((membership) => (
81
+ <MembershipRow key={membership.id} membership={membership} />
82
+ ))}
83
+ </ul>
84
+ )}
85
+ </section>
86
+ )
87
+ }
88
+
89
+ function MembershipRow({
90
+ membership,
91
+ }: {
92
+ readonly membership: ListMembership
93
+ }): React.JSX.Element {
94
+ const remove = useRemoveListMember()
95
+
96
+ return (
97
+ <li className="border-b border-border px-4 py-3 last:border-0">
98
+ <div className="flex flex-wrap items-center justify-between gap-3">
99
+ <Link
100
+ to={`/lists/${membership.listId}`}
101
+ className="text-left text-[13px] font-medium text-ink hover:text-accent"
102
+ >
103
+ {membership.listName}
104
+ </Link>
105
+ <button
106
+ type="button"
107
+ disabled={remove.isPending}
108
+ onClick={() => {
109
+ remove.run({ listId: membership.listId, id: membership.id })
110
+ }}
111
+ className="text-[11px] font-medium text-danger hover:underline disabled:opacity-50"
112
+ >
113
+ Remove
114
+ </button>
115
+ </div>
116
+
117
+ {remove.error !== null && (
118
+ <div className="mt-2">
119
+ <ErrorPanel error={remove.error} />
120
+ </div>
121
+ )}
122
+ </li>
123
+ )
124
+ }
125
+
126
+ function ListPicker({
127
+ targetType,
128
+ targetId,
129
+ excludeListIds,
130
+ onAdded,
131
+ }: {
132
+ readonly targetType: RecordTargetType
133
+ readonly targetId: string
134
+ readonly excludeListIds: ReadonlySet<string>
135
+ readonly onAdded: () => void
136
+ }): React.JSX.Element {
137
+ const [search, setSearch] = useState('')
138
+ const term = search.trim()
139
+ // Only lists whose type matches — the DB will refuse a mismatched member,
140
+ // but the picker filters up front so the surface never offers one.
141
+ const lists = useLists({
142
+ targetType,
143
+ term: term.length > 0 ? term : undefined,
144
+ })
145
+ const addMember = useAddListMember()
146
+
147
+ const options: readonly SearchOption[] = lists.records
148
+ .filter((row: List) => !excludeListIds.has(row.id))
149
+ .map((row: List) => ({ id: row.id, label: row.name }))
150
+
151
+ function pick(listId: string): void {
152
+ addMember
153
+ .runAsync({ listId, input: { targetType, targetId } })
154
+ .then(onAdded)
155
+ .catch(() => undefined)
156
+ }
157
+
158
+ return (
159
+ <div>
160
+ <EntitySearch
161
+ options={options}
162
+ value=""
163
+ onChange={pick}
164
+ onQueryChange={setSearch}
165
+ placeholder="Search lists…"
166
+ emptyMessage="No matching lists"
167
+ size="md"
168
+ />
169
+ {addMember.error !== null && (
170
+ <div className="mt-2">
171
+ <ErrorPanel error={addMember.error} />
172
+ </div>
173
+ )}
174
+ </div>
175
+ )
176
+ }
@@ -6,6 +6,7 @@ import { useTimezone } from '../api/resources/account.ts'
6
6
  import { useMembers } from '../api/resources/members.ts'
7
7
  import { useCreateNote, useNotes } from '../api/resources/notes.ts'
8
8
  import { formatDateTime } from '../lib/dates.ts'
9
+ import { Paginator } from './Paginator.tsx'
9
10
  import { ErrorPanel } from './QueryState.tsx'
10
11
  import { SectionHeader } from './SectionHeader.tsx'
11
12
 
@@ -33,10 +34,10 @@ export function NotesPanel({ targetType, targetId }: NotesPanelProps): React.JSX
33
34
  const [body, setBody] = useState('')
34
35
 
35
36
  // Pinned first, then newest. The list arrives in `-created_at` order and the
36
- // API has no two-column sort, so this reorders what is loaded. Across a
37
- // "Load more" boundary a pinned note on a later page sorts after unpinned
38
- // ones already shown; pinned notes are few enough that this is the cheaper
39
- // wrong than a second request per panel.
37
+ // API has no two-column sort, so this reorders what is on this page. Across
38
+ // page boundaries a pinned note on a later page sorts after unpinned ones on
39
+ // an earlier one; pinned notes are few enough that this is the cheaper wrong
40
+ // than a second request per panel.
40
41
  const ordered = [...notes.records].sort((left, right) => Number(right.pinned) - Number(left.pinned))
41
42
 
42
43
  function reset(): void {
@@ -116,16 +117,7 @@ export function NotesPanel({ targetType, targetId }: NotesPanelProps): React.JSX
116
117
  </ul>
117
118
  )}
118
119
 
119
- {notes.hasMore && (
120
- <button
121
- type="button"
122
- onClick={notes.loadMore}
123
- disabled={notes.isLoadingMore}
124
- className="mt-3 rounded-md border border-border px-3 py-1.5 text-[12px] font-medium text-ink-muted transition hover:border-border-strong hover:text-ink"
125
- >
126
- {notes.isLoadingMore ? 'Loading…' : 'Load more'}
127
- </button>
128
- )}
120
+ <Paginator list={notes} />
129
121
  </section>
130
122
  )
131
123
  }
@@ -0,0 +1,78 @@
1
+ import type { Paged } from '../api/resource.ts'
2
+
3
+ /**
4
+ * Prev / Next with a page indicator and an optional page size selector.
5
+ *
6
+ * The API is cursor-only (`api.md`), so there is no total count and no jump
7
+ * to page N. `pageIndex` is one-based on screen but zero-based in `Paged` —
8
+ * the label adds one so page 1 does not read as page 0.
9
+ *
10
+ * `Prev` disables when nothing came before, `Next` when the server said the
11
+ * current page was the last one. A cached prior page is instant; going past
12
+ * the fetched set kicks off exactly one fetch, and both buttons hold their
13
+ * disabled state until it settles so a fast double click does not skip two.
14
+ */
15
+
16
+ export interface PaginatorProps {
17
+ readonly list: Paged
18
+ /** Options offered by the page size selector. Omit to hide the selector. */
19
+ readonly pageSizes?: readonly number[]
20
+ /**
21
+ * Where on the list this paginator sits. `top` places it above the rows —
22
+ * the per-page selector then defers to the `bottom` copy so the two do not
23
+ * duplicate the same control side by side.
24
+ */
25
+ readonly placement?: 'top' | 'bottom'
26
+ }
27
+
28
+ const DEFAULT_PAGE_SIZES: readonly number[] = [25, 50, 100, 200]
29
+
30
+ const buttonClass =
31
+ 'rounded-md border border-border bg-surface-raised px-2.5 py-1 text-[12px] font-medium text-ink transition hover:border-border-strong hover:bg-surface-sunken disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-border disabled:hover:bg-surface-raised'
32
+
33
+ export function Paginator({
34
+ list,
35
+ pageSizes = DEFAULT_PAGE_SIZES,
36
+ placement = 'bottom',
37
+ }: PaginatorProps): React.JSX.Element {
38
+ const prevDisabled = !list.hasPrev || list.isChangingPage
39
+ const nextDisabled = !list.hasNext || list.isChangingPage
40
+ const options = pageSizes.includes(list.pageSize) ? pageSizes : [...pageSizes, list.pageSize].sort((a, b) => a - b)
41
+ const showPageSize = placement === 'bottom' && pageSizes.length > 0
42
+ const wrapperClass =
43
+ placement === 'top'
44
+ ? 'mt-3 mb-3 flex flex-wrap items-center gap-3 text-[12px] text-ink-muted'
45
+ : 'mt-3 flex flex-wrap items-center gap-3 text-[12px] text-ink-muted'
46
+
47
+ return (
48
+ <div className={wrapperClass}>
49
+ <div className="flex items-center gap-1">
50
+ <button type="button" onClick={list.prevPage} disabled={prevDisabled} className={buttonClass}>
51
+ Prev
52
+ </button>
53
+ <button type="button" onClick={list.nextPage} disabled={nextDisabled} className={buttonClass}>
54
+ {list.isChangingPage ? 'Loading…' : 'Next'}
55
+ </button>
56
+ </div>
57
+ <span aria-live="polite">Page {list.pageIndex + 1}</span>
58
+ {showPageSize && (
59
+ <label className="ml-auto flex items-center gap-2">
60
+ <span>Per page</span>
61
+ <select
62
+ value={list.pageSize}
63
+ onChange={(event) => {
64
+ list.setPageSize(Number(event.target.value))
65
+ }}
66
+ className="rounded-md border border-border bg-surface-raised px-2 py-1 text-[12px] outline-none focus:border-accent"
67
+ >
68
+ {options.map((size) => (
69
+ <option key={size} value={size}>
70
+ {size}
71
+ </option>
72
+ ))}
73
+ </select>
74
+ </label>
75
+ )}
76
+ </div>
77
+ )
78
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import { formatDay } from '../lib/dates.ts'
14
14
  import { planStatusTone } from '../lib/plan.ts'
15
15
  import { Chip } from './Chip.tsx'
16
+ import { Paginator } from './Paginator.tsx'
16
17
  import { ErrorPanel } from './QueryState.tsx'
17
18
  import { SectionHeader } from './SectionHeader.tsx'
18
19
 
@@ -102,16 +103,7 @@ export function PlanPanel({ targetType, targetId }: PlanPanelProps): React.JSX.E
102
103
  </ul>
103
104
  )}
104
105
 
105
- {items.hasMore && (
106
- <button
107
- type="button"
108
- onClick={items.loadMore}
109
- disabled={items.isLoadingMore}
110
- className="mt-3 rounded-md border border-border px-3 py-1.5 text-[12px] font-medium text-ink-muted transition hover:border-border-strong hover:text-ink"
111
- >
112
- {items.isLoadingMore ? 'Loading…' : 'Load more'}
113
- </button>
114
- )}
106
+ <Paginator list={items} />
115
107
  </section>
116
108
  )
117
109
  }
@@ -38,6 +38,7 @@ const CORE_NAV: readonly NavItem[] = [
38
38
  { id: 'opportunities', label: 'Opportunities', to: '/opportunities', order: 350 },
39
39
  { id: 'fundraising', label: 'Fundraising', to: '/fundraising', order: 360 },
40
40
  { id: 'partnerships', label: 'Partnerships', to: '/partnerships', order: 375 },
41
+ { id: 'lists', label: 'Lists', to: '/lists', order: 378 },
41
42
  { id: 'forms', label: 'Forms', to: '/forms', order: 380 },
42
43
  { id: 'handbook', label: 'Handbook', to: '/handbook', order: 390 },
43
44
  { id: 'planning', label: 'Planning', to: '/planning', order: 400 },
package/src/index.ts CHANGED
@@ -13,10 +13,12 @@ export type { ApiProviderProps } from './api/ApiProvider.tsx'
13
13
  export { useApiClient } from './api/context.ts'
14
14
  export { toError } from './api/errors.ts'
15
15
  export { createQueryClient } from './api/queryClient.ts'
16
- export { createResourceHooks } from './api/resource.ts'
16
+ export { createResourceHooks, DEFAULT_PAGE_SIZE, usePagedList } from './api/resource.ts'
17
17
  export type {
18
18
  ListOptions,
19
19
  MutationResult,
20
+ Paged,
21
+ PagedListInput,
20
22
  RecordListResult,
21
23
  RecordResult,
22
24
  ResourceDefinition,
@@ -88,6 +90,8 @@ export { AgentTasks } from './components/AgentTasks.tsx'
88
90
  export type { AgentTasksProps } from './components/AgentTasks.tsx'
89
91
  export { Chip } from './components/Chip.tsx'
90
92
  export type { ChipProps, ChipTone } from './components/Chip.tsx'
93
+ export { ColumnPicker } from './components/ColumnPicker.tsx'
94
+ export type { ColumnPickerOption, ColumnPickerProps } from './components/ColumnPicker.tsx'
91
95
  export { DataTable } from './components/DataTable.tsx'
92
96
  export type { Column, DataTableGroup, DataTableProps } from './components/DataTable.tsx'
93
97
  export { DecisionsPanel } from './components/DecisionsPanel.tsx'
@@ -100,6 +104,8 @@ export { InlineEdit } from './components/InlineEdit.tsx'
100
104
  export type { InlineEditProps } from './components/InlineEdit.tsx'
101
105
  export { FilterBar, PageHeader } from './components/PageHeader.tsx'
102
106
  export type { FilterBarProps, PageHeaderProps } from './components/PageHeader.tsx'
107
+ export { Paginator } from './components/Paginator.tsx'
108
+ export type { PaginatorProps } from './components/Paginator.tsx'
103
109
  export { PhonesField } from './components/PhonesField.tsx'
104
110
  export type { PhonesFieldProps } from './components/PhonesField.tsx'
105
111
  export { PlanAttention, RelatedPlanAttention } from './components/PlanAttention.tsx'
@@ -151,6 +157,9 @@ export {
151
157
  } from './lib/plan.ts'
152
158
  export type { DueBucketId, PlanAttention as PlanAttentionSummary } from './lib/plan.ts'
153
159
 
160
+ export { useListView } from './lib/listView.ts'
161
+ export type { ListViewController } from './lib/listView.ts'
162
+
154
163
  export { applyTheme, getStoredTheme, resolveTheme, setStoredTheme, watchSystemTheme } from './lib/theme.ts'
155
164
  export type { ThemePreference } from './lib/theme.ts'
156
165