@kelpie/ui 0.6.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 (243) 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/importJobs.d.ts +3 -1
  23. package/dist/api/resources/importJobs.d.ts.map +1 -1
  24. package/dist/api/resources/importJobs.js +1 -0
  25. package/dist/api/resources/importJobs.js.map +1 -1
  26. package/dist/api/resources/listMembers.d.ts +27 -0
  27. package/dist/api/resources/listMembers.d.ts.map +1 -0
  28. package/dist/api/resources/listMembers.js +108 -0
  29. package/dist/api/resources/listMembers.js.map +1 -0
  30. package/dist/api/resources/lists.d.ts +14 -0
  31. package/dist/api/resources/lists.d.ts.map +1 -0
  32. package/dist/api/resources/lists.js +34 -0
  33. package/dist/api/resources/lists.js.map +1 -0
  34. package/dist/api/resources/people.d.ts.map +1 -1
  35. package/dist/api/resources/people.js +4 -1
  36. package/dist/api/resources/people.js.map +1 -1
  37. package/dist/api/resources/publicConfig.d.ts +8 -0
  38. package/dist/api/resources/publicConfig.d.ts.map +1 -0
  39. package/dist/api/resources/publicConfig.js +22 -0
  40. package/dist/api/resources/publicConfig.js.map +1 -0
  41. package/dist/api/resources/roles.d.ts +2 -0
  42. package/dist/api/resources/roles.d.ts.map +1 -1
  43. package/dist/api/resources/roles.js +1 -1
  44. package/dist/api/resources/roles.js.map +1 -1
  45. package/dist/api/resources/sampleData.d.ts +17 -0
  46. package/dist/api/resources/sampleData.d.ts.map +1 -0
  47. package/dist/api/resources/sampleData.js +30 -0
  48. package/dist/api/resources/sampleData.js.map +1 -0
  49. package/dist/api/resources/webhooks.d.ts.map +1 -1
  50. package/dist/api/resources/webhooks.js +7 -22
  51. package/dist/api/resources/webhooks.js.map +1 -1
  52. package/dist/app/KelpieApp.d.ts.map +1 -1
  53. package/dist/app/KelpieApp.js +5 -2
  54. package/dist/app/KelpieApp.js.map +1 -1
  55. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  56. package/dist/components/ActivitiesPanel.js +2 -1
  57. package/dist/components/ActivitiesPanel.js.map +1 -1
  58. package/dist/components/ColumnPicker.d.ts +24 -0
  59. package/dist/components/ColumnPicker.d.ts.map +1 -0
  60. package/dist/components/ColumnPicker.js +56 -0
  61. package/dist/components/ColumnPicker.js.map +1 -0
  62. package/dist/components/DataTable.d.ts +16 -1
  63. package/dist/components/DataTable.d.ts.map +1 -1
  64. package/dist/components/DataTable.js +51 -10
  65. package/dist/components/DataTable.js.map +1 -1
  66. package/dist/components/DecisionsPanel.d.ts.map +1 -1
  67. package/dist/components/DecisionsPanel.js +4 -3
  68. package/dist/components/DecisionsPanel.js.map +1 -1
  69. package/dist/components/EntitySearch.d.ts.map +1 -1
  70. package/dist/components/EntitySearch.js +11 -5
  71. package/dist/components/EntitySearch.js.map +1 -1
  72. package/dist/components/EnvironmentBanner.d.ts +14 -0
  73. package/dist/components/EnvironmentBanner.d.ts.map +1 -0
  74. package/dist/components/EnvironmentBanner.js +23 -0
  75. package/dist/components/EnvironmentBanner.js.map +1 -0
  76. package/dist/components/ListsPanel.d.ts +14 -0
  77. package/dist/components/ListsPanel.d.ts.map +1 -0
  78. package/dist/components/ListsPanel.js +56 -0
  79. package/dist/components/ListsPanel.js.map +1 -0
  80. package/dist/components/NotesPanel.d.ts.map +1 -1
  81. package/dist/components/NotesPanel.js +6 -5
  82. package/dist/components/NotesPanel.js.map +1 -1
  83. package/dist/components/Paginator.d.ts +26 -0
  84. package/dist/components/Paginator.d.ts.map +1 -0
  85. package/dist/components/Paginator.js +16 -0
  86. package/dist/components/Paginator.js.map +1 -0
  87. package/dist/components/PlanPanel.d.ts.map +1 -1
  88. package/dist/components/PlanPanel.js +2 -1
  89. package/dist/components/PlanPanel.js.map +1 -1
  90. package/dist/components/Shell.d.ts.map +1 -1
  91. package/dist/components/Shell.js +1 -0
  92. package/dist/components/Shell.js.map +1 -1
  93. package/dist/index.d.ts +8 -2
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +4 -1
  96. package/dist/index.js.map +1 -1
  97. package/dist/lib/listView.d.ts +15 -0
  98. package/dist/lib/listView.d.ts.map +1 -0
  99. package/dist/lib/listView.js +66 -0
  100. package/dist/lib/listView.js.map +1 -0
  101. package/dist/lib/sort.d.ts +43 -0
  102. package/dist/lib/sort.d.ts.map +1 -0
  103. package/dist/lib/sort.js +102 -0
  104. package/dist/lib/sort.js.map +1 -0
  105. package/dist/pages/CompaniesPage.d.ts.map +1 -1
  106. package/dist/pages/CompaniesPage.js +88 -3
  107. package/dist/pages/CompaniesPage.js.map +1 -1
  108. package/dist/pages/CompanyDetail.d.ts.map +1 -1
  109. package/dist/pages/CompanyDetail.js +17 -14
  110. package/dist/pages/CompanyDetail.js.map +1 -1
  111. package/dist/pages/DealDetail.d.ts.map +1 -1
  112. package/dist/pages/DealDetail.js +3 -1
  113. package/dist/pages/DealDetail.js.map +1 -1
  114. package/dist/pages/DealsPage.d.ts.map +1 -1
  115. package/dist/pages/DealsPage.js +134 -14
  116. package/dist/pages/DealsPage.js.map +1 -1
  117. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  118. package/dist/pages/DecisionsPage.js +50 -8
  119. package/dist/pages/DecisionsPage.js.map +1 -1
  120. package/dist/pages/FormsPage.d.ts.map +1 -1
  121. package/dist/pages/FormsPage.js +53 -2
  122. package/dist/pages/FormsPage.js.map +1 -1
  123. package/dist/pages/FundraisingPage.d.ts.map +1 -1
  124. package/dist/pages/FundraisingPage.js +116 -14
  125. package/dist/pages/FundraisingPage.js.map +1 -1
  126. package/dist/pages/HiringPage.d.ts.map +1 -1
  127. package/dist/pages/HiringPage.js +27 -4
  128. package/dist/pages/HiringPage.js.map +1 -1
  129. package/dist/pages/ListDetail.d.ts +11 -0
  130. package/dist/pages/ListDetail.d.ts.map +1 -0
  131. package/dist/pages/ListDetail.js +188 -0
  132. package/dist/pages/ListDetail.js.map +1 -0
  133. package/dist/pages/ListsPage.d.ts +9 -0
  134. package/dist/pages/ListsPage.d.ts.map +1 -0
  135. package/dist/pages/ListsPage.js +125 -0
  136. package/dist/pages/ListsPage.js.map +1 -0
  137. package/dist/pages/OpportunitiesPage.d.ts.map +1 -1
  138. package/dist/pages/OpportunitiesPage.js +97 -14
  139. package/dist/pages/OpportunitiesPage.js.map +1 -1
  140. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  141. package/dist/pages/OpportunityDetail.js +3 -1
  142. package/dist/pages/OpportunityDetail.js.map +1 -1
  143. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  144. package/dist/pages/PartnershipDetail.js +3 -1
  145. package/dist/pages/PartnershipDetail.js.map +1 -1
  146. package/dist/pages/PartnershipsPage.d.ts.map +1 -1
  147. package/dist/pages/PartnershipsPage.js +111 -14
  148. package/dist/pages/PartnershipsPage.js.map +1 -1
  149. package/dist/pages/PeoplePage.d.ts.map +1 -1
  150. package/dist/pages/PeoplePage.js +102 -3
  151. package/dist/pages/PeoplePage.js.map +1 -1
  152. package/dist/pages/PersonDetail.d.ts.map +1 -1
  153. package/dist/pages/PersonDetail.js +5 -3
  154. package/dist/pages/PersonDetail.js.map +1 -1
  155. package/dist/pages/PlanningPage.d.ts.map +1 -1
  156. package/dist/pages/PlanningPage.js +85 -17
  157. package/dist/pages/PlanningPage.js.map +1 -1
  158. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  159. package/dist/pages/RaiseDetail.js +3 -1
  160. package/dist/pages/RaiseDetail.js.map +1 -1
  161. package/dist/pages/RoleDetail.d.ts.map +1 -1
  162. package/dist/pages/RoleDetail.js +5 -4
  163. package/dist/pages/RoleDetail.js.map +1 -1
  164. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  165. package/dist/pages/admin/DataPage.js +126 -16
  166. package/dist/pages/admin/DataPage.js.map +1 -1
  167. package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
  168. package/dist/pages/admin/WebhookDeliveries.js +3 -2
  169. package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
  170. package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
  171. package/dist/pages/admin/WebhooksPage.js +3 -2
  172. package/dist/pages/admin/WebhooksPage.js.map +1 -1
  173. package/dist/pages/admin/WorkspaceSettingsPage.d.ts.map +1 -1
  174. package/dist/pages/admin/WorkspaceSettingsPage.js +45 -2
  175. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  176. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  177. package/dist/pages/forms/SubmissionsTable.js +2 -1
  178. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  179. package/dist/pages/handbook/HandbookPage.js +1 -1
  180. package/dist/pages/hiringDirectory.js +2 -2
  181. package/dist/pages/onboarding/WorkspaceStepPage.d.ts.map +1 -1
  182. package/dist/pages/onboarding/WorkspaceStepPage.js +15 -2
  183. package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
  184. package/dist/pages/positionDirectory.js +2 -2
  185. package/package.json +2 -2
  186. package/src/api/resource.ts +149 -31
  187. package/src/api/resources/companies.ts +7 -1
  188. package/src/api/resources/decisions.ts +3 -0
  189. package/src/api/resources/emailDomainLinker.ts +43 -0
  190. package/src/api/resources/forms.ts +5 -23
  191. package/src/api/resources/handbookPages.ts +1 -1
  192. package/src/api/resources/importJobs.ts +4 -0
  193. package/src/api/resources/listMembers.ts +173 -0
  194. package/src/api/resources/lists.ts +61 -0
  195. package/src/api/resources/people.ts +4 -1
  196. package/src/api/resources/publicConfig.ts +33 -0
  197. package/src/api/resources/roles.ts +3 -1
  198. package/src/api/resources/sampleData.ts +56 -0
  199. package/src/api/resources/webhooks.ts +8 -24
  200. package/src/app/KelpieApp.tsx +11 -0
  201. package/src/components/ActivitiesPanel.tsx +2 -10
  202. package/src/components/ColumnPicker.tsx +124 -0
  203. package/src/components/DataTable.tsx +89 -10
  204. package/src/components/DecisionsPanel.tsx +4 -12
  205. package/src/components/EntitySearch.tsx +12 -5
  206. package/src/components/EnvironmentBanner.tsx +30 -0
  207. package/src/components/ListsPanel.tsx +176 -0
  208. package/src/components/NotesPanel.tsx +6 -14
  209. package/src/components/Paginator.tsx +78 -0
  210. package/src/components/PlanPanel.tsx +2 -10
  211. package/src/components/Shell.tsx +1 -0
  212. package/src/index.ts +10 -1
  213. package/src/lib/listView.ts +149 -0
  214. package/src/lib/sort.ts +143 -0
  215. package/src/pages/CompaniesPage.tsx +125 -11
  216. package/src/pages/CompanyDetail.tsx +62 -62
  217. package/src/pages/DealDetail.tsx +3 -0
  218. package/src/pages/DealsPage.tsx +178 -24
  219. package/src/pages/DecisionsPage.tsx +73 -18
  220. package/src/pages/FormsPage.tsx +81 -17
  221. package/src/pages/FundraisingPage.tsx +152 -24
  222. package/src/pages/HiringPage.tsx +39 -11
  223. package/src/pages/ListDetail.tsx +408 -0
  224. package/src/pages/ListsPage.tsx +281 -0
  225. package/src/pages/OpportunitiesPage.tsx +130 -24
  226. package/src/pages/OpportunityDetail.tsx +3 -0
  227. package/src/pages/PartnershipDetail.tsx +3 -0
  228. package/src/pages/PartnershipsPage.tsx +149 -24
  229. package/src/pages/PeoplePage.tsx +137 -11
  230. package/src/pages/PersonDetail.tsx +5 -3
  231. package/src/pages/PlanningPage.tsx +256 -29
  232. package/src/pages/RaiseDetail.tsx +3 -0
  233. package/src/pages/RoleDetail.tsx +21 -26
  234. package/src/pages/admin/DataPage.tsx +359 -36
  235. package/src/pages/admin/WebhookDeliveries.tsx +10 -15
  236. package/src/pages/admin/WebhooksPage.tsx +14 -19
  237. package/src/pages/admin/WorkspaceSettingsPage.tsx +134 -2
  238. package/src/pages/forms/SubmissionsTable.tsx +3 -10
  239. package/src/pages/handbook/HandbookPage.tsx +1 -1
  240. package/src/pages/hiringDirectory.ts +2 -2
  241. package/src/pages/onboarding/WorkspaceStepPage.tsx +31 -3
  242. package/src/pages/positionDirectory.ts +2 -2
  243. package/src/styles.css +3 -3
@@ -0,0 +1,173 @@
1
+ import {
2
+ addListMemberBody,
3
+ listMemberSchema,
4
+ listMembershipSchema,
5
+ } from '@kelpie/schemas'
6
+ import type {
7
+ AddListMemberInput,
8
+ ListMember,
9
+ ListMembership,
10
+ RecordTargetType,
11
+ } from '@kelpie/schemas'
12
+ import {
13
+ useMutation,
14
+ useQuery,
15
+ useQueryClient,
16
+ } from '@tanstack/react-query'
17
+ import type { InfiniteData } from '@tanstack/react-query'
18
+
19
+ import type { Page } from '../client.ts'
20
+ import { useApiClient } from '../context.ts'
21
+ import { toError } from '../errors.ts'
22
+ import { usePagedList } from '../resource.ts'
23
+ import type { MutationResult, RecordListResult } from '../resource.ts'
24
+
25
+ /**
26
+ * `/v1/lists/:id/members`. Nested per list, so the standard resource hook cannot
27
+ * mint the path. Built by hand with the same read/write cache invalidation
28
+ * pattern the other resources use.
29
+ */
30
+
31
+ type MembersData = InfiniteData<Page<ListMember>, string | null>
32
+
33
+ function membersKey(listId: string): readonly unknown[] {
34
+ return ['lists', 'members', listId]
35
+ }
36
+
37
+ function membershipsKey(targetType: string, targetId: string): readonly unknown[] {
38
+ return ['lists', 'memberships', targetType, targetId]
39
+ }
40
+
41
+ /** The list-level entries whose cached counts change when a member is added or removed. */
42
+ function listsInvalidations(): readonly unknown[] {
43
+ return ['lists', 'list']
44
+ }
45
+
46
+ export interface ListMembershipsResult {
47
+ readonly memberships: readonly ListMembership[]
48
+ readonly isLoading: boolean
49
+ readonly error: Error | null
50
+ }
51
+
52
+ /**
53
+ * "Which lists is this record on?"
54
+ *
55
+ * A plain `useQuery` rather than the paged read: the endpoint is not paged.
56
+ * The invalidation key overlaps with `useListMembers` so adding or removing on
57
+ * either side refreshes both views.
58
+ */
59
+ export function useListMembershipsFor(
60
+ targetType: RecordTargetType | undefined,
61
+ targetId: string | undefined,
62
+ ): ListMembershipsResult {
63
+ const client = useApiClient()
64
+ const enabled = targetType !== undefined && targetId !== undefined
65
+ const result = useQuery({
66
+ queryKey: membershipsKey(targetType ?? '', targetId ?? ''),
67
+ queryFn: async () => {
68
+ const page = await client.list(
69
+ '/list-memberships',
70
+ listMembershipSchema.parse,
71
+ { target_type: targetType, target_id: targetId },
72
+ )
73
+
74
+ return page.items
75
+ },
76
+ enabled,
77
+ })
78
+
79
+ return {
80
+ memberships: result.data ?? [],
81
+ isLoading: result.isPending && enabled,
82
+ error: toError(result.error),
83
+ }
84
+ }
85
+
86
+ export function useListMembers(listId: string | undefined): RecordListResult<ListMember> {
87
+ return usePagedList<ListMember>({
88
+ queryKey: membersKey(listId ?? ''),
89
+ path: `/lists/${listId ?? ''}/members`,
90
+ decode: listMemberSchema.parse,
91
+ query: {},
92
+ enabled: listId !== undefined,
93
+ })
94
+ }
95
+
96
+ export interface AddMemberArguments {
97
+ readonly listId: string
98
+ readonly input: AddListMemberInput
99
+ }
100
+
101
+ export function useAddListMember(): MutationResult<AddMemberArguments, ListMember> {
102
+ const client = useApiClient()
103
+ const queryClient = useQueryClient()
104
+ const mutation = useMutation({
105
+ mutationFn: ({ listId, input }: AddMemberArguments) =>
106
+ client.post(`/lists/${listId}/members`, addListMemberBody(input), listMemberSchema.parse),
107
+ onSettled: (_record, _error, { listId }) => {
108
+ void queryClient.invalidateQueries({ queryKey: membersKey(listId) })
109
+ void queryClient.invalidateQueries({ queryKey: listsInvalidations() })
110
+ void queryClient.invalidateQueries({ queryKey: ['lists', 'memberships'] })
111
+ },
112
+ })
113
+
114
+ return {
115
+ run: (input) => {
116
+ mutation.mutate(input)
117
+ },
118
+ runAsync: (input) => mutation.mutateAsync(input),
119
+ isPending: mutation.isPending,
120
+ error: toError(mutation.error),
121
+ }
122
+ }
123
+
124
+ export interface RemoveMemberArguments {
125
+ readonly listId: string
126
+ readonly id: string
127
+ }
128
+
129
+ export function useRemoveListMember(): MutationResult<RemoveMemberArguments, void> {
130
+ const client = useApiClient()
131
+ const queryClient = useQueryClient()
132
+ const mutation = useMutation({
133
+ mutationFn: ({ listId, id }: RemoveMemberArguments) =>
134
+ client.delete(`/lists/${listId}/members/${id}`),
135
+ onMutate: async ({ listId, id }) => {
136
+ await queryClient.cancelQueries({ queryKey: membersKey(listId) })
137
+ const previous = queryClient.getQueryData<MembersData>(membersKey(listId))
138
+
139
+ queryClient.setQueryData<MembersData>(membersKey(listId), (data) =>
140
+ data === undefined
141
+ ? data
142
+ : {
143
+ ...data,
144
+ pages: data.pages.map((page) => ({
145
+ ...page,
146
+ items: page.items.filter((item) => item.id !== id),
147
+ })),
148
+ },
149
+ )
150
+
151
+ return { previous }
152
+ },
153
+ onError: (_error, { listId }, context) => {
154
+ if (context?.previous !== undefined) {
155
+ queryClient.setQueryData(membersKey(listId), context.previous)
156
+ }
157
+ },
158
+ onSettled: (_record, _error, { listId }) => {
159
+ void queryClient.invalidateQueries({ queryKey: membersKey(listId) })
160
+ void queryClient.invalidateQueries({ queryKey: listsInvalidations() })
161
+ void queryClient.invalidateQueries({ queryKey: ['lists', 'memberships'] })
162
+ },
163
+ })
164
+
165
+ return {
166
+ run: (input) => {
167
+ mutation.mutate(input)
168
+ },
169
+ runAsync: (input) => mutation.mutateAsync(input),
170
+ isPending: mutation.isPending,
171
+ error: toError(mutation.error),
172
+ }
173
+ }
@@ -0,0 +1,61 @@
1
+ import { createListBody, listBody, listSchema } from '@kelpie/schemas'
2
+ import type { CreateListInput, List, ListInput, RecordTargetType } from '@kelpie/schemas'
3
+
4
+ import type { QueryParameters } from '../client.ts'
5
+ import { createResourceHooks } from '../resource.ts'
6
+ import type {
7
+ ListOptions,
8
+ MutationResult,
9
+ RecordListResult,
10
+ RecordResult,
11
+ UpdateArguments,
12
+ } from '../resource.ts'
13
+
14
+ /** `/v1/lists`. A list holds records of one type; the type is fixed at creation. */
15
+
16
+ const lists = createResourceHooks<List, CreateListInput, ListInput>({
17
+ name: 'lists',
18
+ path: '/lists',
19
+ decode: listSchema.parse,
20
+ createBody: createListBody,
21
+ updateBody: listBody,
22
+ })
23
+
24
+ export interface ListFilters {
25
+ readonly term?: string | undefined
26
+ readonly targetType?: RecordTargetType | undefined
27
+ readonly limit?: number | undefined
28
+ readonly sort?: string | undefined
29
+ }
30
+
31
+ function listsQuery(filters: ListFilters): QueryParameters {
32
+ return {
33
+ q: filters.term,
34
+ target_type: filters.targetType,
35
+ limit: filters.limit,
36
+ sort: filters.sort,
37
+ }
38
+ }
39
+
40
+ export function useLists(
41
+ filters: ListFilters = {},
42
+ options: ListOptions = {},
43
+ ): RecordListResult<List> {
44
+ return lists.useList(listsQuery(filters), options)
45
+ }
46
+
47
+ export function useList(id: string | undefined): RecordResult<List> {
48
+ return lists.useRecord(id)
49
+ }
50
+
51
+ export function useCreateList(): MutationResult<CreateListInput, List> {
52
+ return lists.useCreate()
53
+ }
54
+
55
+ export function useUpdateList(): MutationResult<UpdateArguments<ListInput>, List> {
56
+ return lists.useUpdate()
57
+ }
58
+
59
+ export function useDeleteList(): MutationResult<string, void> {
60
+ return lists.useRemove()
61
+ }
@@ -25,7 +25,10 @@ const people = createResourceHooks<Person, CreatePersonInput, PersonInput>({
25
25
  updateBody: personBody,
26
26
  // Every write here emits an activity server-side, in the same transaction, so
27
27
  // a timeline rendered on this record is stale the moment the write lands.
28
- alsoInvalidates: ['activities'],
28
+ // Setting or changing the email may also auto-link a Position, which changes
29
+ // both the person's position list and the `?person_id=` companies list that
30
+ // names the far side — without the latter, the new row renders as "Unknown".
31
+ alsoInvalidates: ['activities', 'positions', 'companies'],
29
32
  })
30
33
 
31
34
  /** The documented filters on `GET /v1/people`. There is no generic filter DSL. */
@@ -0,0 +1,33 @@
1
+ import { publicConfigSchema } from '@kelpie/schemas'
2
+ import type { PublicConfig } from '@kelpie/schemas'
3
+ import { useQuery } from '@tanstack/react-query'
4
+
5
+ import { useApiClient } from '../context.ts'
6
+ import { toError } from '../errors.ts'
7
+
8
+ /**
9
+ * The deployment metadata `GET /v1/public/config` reports.
10
+ *
11
+ * Read once, cached forever: neither `runtimeMode` nor `siteName` changes
12
+ * while the app is open. The endpoint is public, so this hook works before a
13
+ * session exists (the sign-in page reads it too).
14
+ */
15
+
16
+ const PUBLIC_CONFIG_KEY = ['public-config'] as const
17
+
18
+ export interface PublicConfigState {
19
+ readonly config: PublicConfig | undefined
20
+ readonly isLoading: boolean
21
+ readonly error: Error | null
22
+ }
23
+
24
+ export function usePublicConfig(): PublicConfigState {
25
+ const client = useApiClient()
26
+ const result = useQuery({
27
+ queryKey: PUBLIC_CONFIG_KEY,
28
+ queryFn: () => client.get('/public/config', publicConfigSchema.parse),
29
+ staleTime: Infinity,
30
+ })
31
+
32
+ return { config: result.data, isLoading: result.isPending, error: toError(result.error) }
33
+ }
@@ -35,10 +35,12 @@ export interface RoleFilters {
35
35
  /** Roles in any of these statuses. Repeats on the wire. */
36
36
  readonly statuses?: readonly RoleStatus[] | undefined
37
37
  readonly limit?: number | undefined
38
+ /** `field` ascending, `-field` descending. Only `title`, `created_at`, `updated_at` are sortable. */
39
+ readonly sort?: string | undefined
38
40
  }
39
41
 
40
42
  function roleQuery(filters: RoleFilters): QueryParameters {
41
- return { q: filters.term, status: filters.statuses, limit: filters.limit }
43
+ return { q: filters.term, status: filters.statuses, limit: filters.limit, sort: filters.sort }
42
44
  }
43
45
 
44
46
  export function useRoles(
@@ -0,0 +1,56 @@
1
+ import { sampleDataCountsSchema } from '@kelpie/schemas'
2
+ import type { SampleDataCounts } from '@kelpie/schemas'
3
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
4
+
5
+ import { useApiClient } from '../context.ts'
6
+ import type { MutationResult } from '../resource.ts'
7
+ import { asMutationResult } from './mutation.ts'
8
+
9
+ /**
10
+ * `POST /v1/workspaces/:id/sample-data`: install the sample workspace.
11
+ *
12
+ * The workspace id arrives as a mutation argument rather than being read from
13
+ * the session. That lets the onboarding wizard install into the workspace it
14
+ * just created, before the session cache catches up with it.
15
+ *
16
+ * On success, every CRM list is invalidated because the rows this just wrote
17
+ * are in all of them.
18
+ */
19
+
20
+ export interface InstallSampleDataInput {
21
+ readonly workspaceId: string
22
+ }
23
+
24
+ export function useInstallSampleData(): MutationResult<InstallSampleDataInput, SampleDataCounts> {
25
+ const client = useApiClient()
26
+ const cache = useQueryClient()
27
+ const mutation = useMutation({
28
+ mutationFn: ({ workspaceId }: InstallSampleDataInput) =>
29
+ client.post(
30
+ `/workspaces/${workspaceId}/sample-data`,
31
+ {},
32
+ sampleDataCountsSchema.parse,
33
+ ),
34
+ onSuccess: async () => {
35
+ await Promise.all(
36
+ [
37
+ 'people',
38
+ 'companies',
39
+ 'positions',
40
+ 'deals',
41
+ 'plans',
42
+ 'notes',
43
+ 'activities',
44
+ 'decisions',
45
+ 'opportunities',
46
+ 'raises',
47
+ 'partnerships',
48
+ 'roles',
49
+ 'candidates',
50
+ ].map((name) => cache.invalidateQueries({ queryKey: [name] })),
51
+ )
52
+ },
53
+ })
54
+
55
+ return asMutationResult(mutation)
56
+ }
@@ -14,12 +14,11 @@ import type {
14
14
  WebhookDelivery,
15
15
  WebhookInput,
16
16
  } from '@kelpie/schemas'
17
- import { keepPreviousData, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
17
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
18
18
 
19
19
  import type { QueryParameters } from '../client.ts'
20
20
  import { useApiClient } from '../context.ts'
21
- import { toError } from '../errors.ts'
22
- import { createResourceHooks } from '../resource.ts'
21
+ import { createResourceHooks, usePagedList } from '../resource.ts'
23
22
  import type { MutationResult, RecordListResult, UpdateArguments } from '../resource.ts'
24
23
  import { asMutationResult } from './mutation.ts'
25
24
 
@@ -79,29 +78,14 @@ export function useWebhookDeliveries(
79
78
  webhookId: string,
80
79
  filters: WebhookDeliveryFilters = {},
81
80
  ): RecordListResult<WebhookDelivery> {
82
- const client = useApiClient()
83
- const result = useInfiniteQuery({
81
+ const query: QueryParameters = { status: filters.status }
82
+
83
+ return usePagedList<WebhookDelivery>({
84
84
  queryKey: ['webhooks', 'deliveries', webhookId, filters.status ?? 'all'],
85
- queryFn: ({ pageParam }) =>
86
- client.list(`/webhooks/${webhookId}/deliveries`, webhookDeliverySchema.parse, {
87
- status: filters.status,
88
- ...(pageParam === null ? {} : { cursor: pageParam }),
89
- }),
90
- initialPageParam: null as string | null,
91
- getNextPageParam: (lastPage) => lastPage.nextCursor,
92
- placeholderData: keepPreviousData,
85
+ path: `/webhooks/${webhookId}/deliveries`,
86
+ decode: webhookDeliverySchema.parse,
87
+ query,
93
88
  })
94
-
95
- return {
96
- records: result.data?.pages.flatMap((page) => page.items) ?? [],
97
- isLoading: result.isPending,
98
- error: toError(result.error),
99
- hasMore: result.hasNextPage,
100
- isLoadingMore: result.isFetchingNextPage,
101
- loadMore: () => {
102
- void result.fetchNextPage()
103
- },
104
- }
105
89
  }
106
90
 
107
91
  /**
@@ -1,6 +1,7 @@
1
1
  import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
2
2
 
3
3
  import { ApiProvider } from '../api/ApiProvider.tsx'
4
+ import { EnvironmentBanner } from '../components/EnvironmentBanner.tsx'
4
5
  import { Shell } from '../components/Shell.tsx'
5
6
  import { CompaniesPage } from '../pages/CompaniesPage.tsx'
6
7
  import { CompanyDetail } from '../pages/CompanyDetail.tsx'
@@ -13,6 +14,8 @@ import { FormDetail } from '../pages/FormDetail.tsx'
13
14
  import { FormsPage } from '../pages/FormsPage.tsx'
14
15
  import { FundraisingPage } from '../pages/FundraisingPage.tsx'
15
16
  import { HiringPage } from '../pages/HiringPage.tsx'
17
+ import { ListDetail } from '../pages/ListDetail.tsx'
18
+ import { ListsPage } from '../pages/ListsPage.tsx'
16
19
  import { NotFoundPage } from '../pages/NotFoundPage.tsx'
17
20
  import { OpportunitiesPage } from '../pages/OpportunitiesPage.tsx'
18
21
  import { OpportunityDetail } from '../pages/OpportunityDetail.tsx'
@@ -78,6 +81,12 @@ export function KelpieApp({ extensions, baseUrl }: KelpieAppProps): React.JSX.El
78
81
  <ApiProvider baseUrl={baseUrl}>
79
82
  <UiExtensionProvider extensions={extensions}>
80
83
  <BrowserRouter>
84
+ {/* Above the router so the strip renders on the sign-in page as
85
+ well as the main app. Nothing forces a height here: on
86
+ production the banner returns null, and the Shell (or the sign-
87
+ in layout) fills the viewport as it did before. On non-
88
+ production the 20px strip stacks above them. */}
89
+ <EnvironmentBanner />
81
90
  <AppRoutes />
82
91
  </BrowserRouter>
83
92
  </UiExtensionProvider>
@@ -152,6 +161,8 @@ function AppRoutes(): React.JSX.Element {
152
161
  <Route path="partnerships" element={<PartnershipsPage />} />
153
162
  <Route path="partnerships/settings" element={<PartnershipStageSettingsPage />} />
154
163
  <Route path="partnerships/:id" element={<PartnershipDetail />} />
164
+ <Route path="lists" element={<ListsPage />} />
165
+ <Route path="lists/:id" element={<ListDetail />} />
155
166
  <Route path="planning" element={<PlanningPage />} />
156
167
  <Route path="decisions" element={<DecisionsPage />} />
157
168
  <Route path="forms" element={<FormsPage />} />
@@ -4,6 +4,7 @@ import { useTimezone } from '../api/resources/account.ts'
4
4
  import { useActivities } from '../api/resources/activities.ts'
5
5
  import { useMembers } from '../api/resources/members.ts'
6
6
  import { formatRelativeTime, monthLabel } from '../lib/dates.ts'
7
+ import { Paginator } from './Paginator.tsx'
7
8
  import { ErrorPanel } from './QueryState.tsx'
8
9
  import { SectionHeader } from './SectionHeader.tsx'
9
10
 
@@ -118,16 +119,7 @@ export function ActivitiesPanel({
118
119
  ))}
119
120
  </div>
120
121
 
121
- {activities.hasMore && (
122
- <button
123
- type="button"
124
- onClick={activities.loadMore}
125
- disabled={activities.isLoadingMore}
126
- 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"
127
- >
128
- {activities.isLoadingMore ? 'Loading…' : 'Load more'}
129
- </button>
130
- )}
122
+ <Paginator list={activities} />
131
123
  </section>
132
124
  )
133
125
  }
@@ -0,0 +1,124 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * A "Columns" button that opens a popover of checkboxes for the columns a list
5
+ * page can show.
6
+ *
7
+ * The picker owns nothing but the popover: the selection lives with the caller
8
+ * (usually `useListView`) so the checked state cannot fall out of step with the
9
+ * table beside it. `options` is the full set the page supports, in the display
10
+ * order the header uses.
11
+ *
12
+ * The first column is pinned. A list with no columns at all is not a preference
13
+ * anyone would make on purpose, and the primary column is what makes each row
14
+ * identifiable — losing it turns every row into a set of anonymous fields.
15
+ */
16
+ export interface ColumnPickerOption {
17
+ readonly key: string
18
+ readonly label: string
19
+ }
20
+
21
+ export interface ColumnPickerProps {
22
+ readonly options: readonly ColumnPickerOption[]
23
+ readonly visibleKeys: readonly string[]
24
+ readonly onChange: (keys: readonly string[]) => void
25
+ }
26
+
27
+ export function ColumnPicker({ options, visibleKeys, onChange }: ColumnPickerProps): React.JSX.Element {
28
+ const [open, setOpen] = useState(false)
29
+ const containerRef = useRef<HTMLDivElement>(null)
30
+ const pinnedKey = options[0]?.key
31
+
32
+ useEffect(() => {
33
+ if (!open) {
34
+ return
35
+ }
36
+
37
+ function onPointerDown(event: MouseEvent): void {
38
+ if (containerRef.current?.contains(event.target as Node) !== true) {
39
+ setOpen(false)
40
+ }
41
+ }
42
+
43
+ function onKeyDown(event: KeyboardEvent): void {
44
+ if (event.key === 'Escape') {
45
+ setOpen(false)
46
+ }
47
+ }
48
+
49
+ document.addEventListener('mousedown', onPointerDown)
50
+ document.addEventListener('keydown', onKeyDown)
51
+
52
+ return () => {
53
+ document.removeEventListener('mousedown', onPointerDown)
54
+ document.removeEventListener('keydown', onKeyDown)
55
+ }
56
+ }, [open])
57
+
58
+ function toggle(key: string, checked: boolean): void {
59
+ if (key === pinnedKey) {
60
+ return
61
+ }
62
+
63
+ // Preserve the order the caller passed in: the stored value is the visible
64
+ // set, and re-adding a column should put it back where the table draws it,
65
+ // not append it wherever the click landed.
66
+ const next = options.map((option) => option.key).filter((candidate) => {
67
+ if (candidate === key) {
68
+ return checked
69
+ }
70
+
71
+ return visibleKeys.includes(candidate)
72
+ })
73
+
74
+ onChange(next)
75
+ }
76
+
77
+ return (
78
+ <div className="relative" ref={containerRef}>
79
+ <button
80
+ type="button"
81
+ onClick={() => {
82
+ setOpen((current) => !current)
83
+ }}
84
+ aria-expanded={open}
85
+ aria-haspopup="menu"
86
+ className="rounded-md border border-border bg-surface-raised px-3 py-1.5 text-[12px] font-medium text-ink transition hover:border-border-strong hover:bg-surface-sunken"
87
+ >
88
+ Columns
89
+ </button>
90
+ {open && (
91
+ <div
92
+ role="menu"
93
+ className="absolute right-0 z-20 mt-1.5 max-h-80 w-56 overflow-y-auto rounded-md border border-border bg-surface-raised py-1"
94
+ >
95
+ {options.map((option) => {
96
+ const checked = visibleKeys.includes(option.key)
97
+ const pinned = option.key === pinnedKey
98
+
99
+ return (
100
+ <label
101
+ key={option.key}
102
+ className={[
103
+ 'flex items-center gap-2 px-3 py-1.5 text-[13px] text-ink',
104
+ pinned ? 'cursor-default text-ink-muted' : 'cursor-pointer hover:bg-surface-sunken',
105
+ ].join(' ')}
106
+ >
107
+ <input
108
+ type="checkbox"
109
+ checked={checked}
110
+ disabled={pinned}
111
+ onChange={(event) => {
112
+ toggle(option.key, event.target.checked)
113
+ }}
114
+ className="h-3.5 w-3.5"
115
+ />
116
+ <span>{option.label}</span>
117
+ </label>
118
+ )
119
+ })}
120
+ </div>
121
+ )}
122
+ </div>
123
+ )
124
+ }