@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.
- package/dist/api/resource.d.ts +58 -5
- package/dist/api/resource.d.ts.map +1 -1
- package/dist/api/resource.js +106 -24
- package/dist/api/resource.js.map +1 -1
- package/dist/api/resources/companies.d.ts +2 -0
- package/dist/api/resources/companies.d.ts.map +1 -1
- package/dist/api/resources/companies.js +5 -1
- package/dist/api/resources/companies.js.map +1 -1
- package/dist/api/resources/decisions.d.ts +2 -0
- package/dist/api/resources/decisions.d.ts.map +1 -1
- package/dist/api/resources/decisions.js +1 -0
- package/dist/api/resources/decisions.js.map +1 -1
- package/dist/api/resources/emailDomainLinker.d.ts +16 -0
- package/dist/api/resources/emailDomainLinker.d.ts.map +1 -0
- package/dist/api/resources/emailDomainLinker.js +16 -0
- package/dist/api/resources/emailDomainLinker.js.map +1 -0
- package/dist/api/resources/forms.d.ts.map +1 -1
- package/dist/api/resources/forms.js +6 -20
- package/dist/api/resources/forms.js.map +1 -1
- package/dist/api/resources/handbookPages.d.ts +1 -1
- package/dist/api/resources/handbookPages.js +1 -1
- package/dist/api/resources/listMembers.d.ts +27 -0
- package/dist/api/resources/listMembers.d.ts.map +1 -0
- package/dist/api/resources/listMembers.js +108 -0
- package/dist/api/resources/listMembers.js.map +1 -0
- package/dist/api/resources/lists.d.ts +14 -0
- package/dist/api/resources/lists.d.ts.map +1 -0
- package/dist/api/resources/lists.js +34 -0
- package/dist/api/resources/lists.js.map +1 -0
- package/dist/api/resources/people.d.ts.map +1 -1
- package/dist/api/resources/people.js +4 -1
- package/dist/api/resources/people.js.map +1 -1
- package/dist/api/resources/roles.d.ts +2 -0
- package/dist/api/resources/roles.d.ts.map +1 -1
- package/dist/api/resources/roles.js +1 -1
- package/dist/api/resources/roles.js.map +1 -1
- package/dist/api/resources/sampleData.d.ts +17 -0
- package/dist/api/resources/sampleData.d.ts.map +1 -0
- package/dist/api/resources/sampleData.js +30 -0
- package/dist/api/resources/sampleData.js.map +1 -0
- package/dist/api/resources/webhooks.d.ts.map +1 -1
- package/dist/api/resources/webhooks.js +7 -22
- package/dist/api/resources/webhooks.js.map +1 -1
- package/dist/app/KelpieApp.d.ts.map +1 -1
- package/dist/app/KelpieApp.js +3 -1
- package/dist/app/KelpieApp.js.map +1 -1
- package/dist/components/ActivitiesPanel.d.ts.map +1 -1
- package/dist/components/ActivitiesPanel.js +2 -1
- package/dist/components/ActivitiesPanel.js.map +1 -1
- package/dist/components/ColumnPicker.d.ts +24 -0
- package/dist/components/ColumnPicker.d.ts.map +1 -0
- package/dist/components/ColumnPicker.js +56 -0
- package/dist/components/ColumnPicker.js.map +1 -0
- package/dist/components/DataTable.d.ts +16 -1
- package/dist/components/DataTable.d.ts.map +1 -1
- package/dist/components/DataTable.js +51 -10
- package/dist/components/DataTable.js.map +1 -1
- package/dist/components/DecisionsPanel.d.ts.map +1 -1
- package/dist/components/DecisionsPanel.js +4 -3
- package/dist/components/DecisionsPanel.js.map +1 -1
- package/dist/components/EntitySearch.d.ts.map +1 -1
- package/dist/components/EntitySearch.js +11 -5
- package/dist/components/EntitySearch.js.map +1 -1
- package/dist/components/ListsPanel.d.ts +14 -0
- package/dist/components/ListsPanel.d.ts.map +1 -0
- package/dist/components/ListsPanel.js +56 -0
- package/dist/components/ListsPanel.js.map +1 -0
- package/dist/components/NotesPanel.d.ts.map +1 -1
- package/dist/components/NotesPanel.js +6 -5
- package/dist/components/NotesPanel.js.map +1 -1
- package/dist/components/Paginator.d.ts +26 -0
- package/dist/components/Paginator.d.ts.map +1 -0
- package/dist/components/Paginator.js +16 -0
- package/dist/components/Paginator.js.map +1 -0
- package/dist/components/PlanPanel.d.ts.map +1 -1
- package/dist/components/PlanPanel.js +2 -1
- package/dist/components/PlanPanel.js.map +1 -1
- package/dist/components/Shell.d.ts.map +1 -1
- package/dist/components/Shell.js +1 -0
- package/dist/components/Shell.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/listView.d.ts +15 -0
- package/dist/lib/listView.d.ts.map +1 -0
- package/dist/lib/listView.js +66 -0
- package/dist/lib/listView.js.map +1 -0
- package/dist/lib/sort.d.ts +43 -0
- package/dist/lib/sort.d.ts.map +1 -0
- package/dist/lib/sort.js +102 -0
- package/dist/lib/sort.js.map +1 -0
- package/dist/pages/CompaniesPage.d.ts.map +1 -1
- package/dist/pages/CompaniesPage.js +88 -3
- package/dist/pages/CompaniesPage.js.map +1 -1
- package/dist/pages/CompanyDetail.d.ts.map +1 -1
- package/dist/pages/CompanyDetail.js +17 -14
- package/dist/pages/CompanyDetail.js.map +1 -1
- package/dist/pages/DealDetail.d.ts.map +1 -1
- package/dist/pages/DealDetail.js +3 -1
- package/dist/pages/DealDetail.js.map +1 -1
- package/dist/pages/DealsPage.d.ts.map +1 -1
- package/dist/pages/DealsPage.js +134 -14
- package/dist/pages/DealsPage.js.map +1 -1
- package/dist/pages/DecisionsPage.d.ts.map +1 -1
- package/dist/pages/DecisionsPage.js +50 -8
- package/dist/pages/DecisionsPage.js.map +1 -1
- package/dist/pages/FormsPage.d.ts.map +1 -1
- package/dist/pages/FormsPage.js +53 -2
- package/dist/pages/FormsPage.js.map +1 -1
- package/dist/pages/FundraisingPage.d.ts.map +1 -1
- package/dist/pages/FundraisingPage.js +116 -14
- package/dist/pages/FundraisingPage.js.map +1 -1
- package/dist/pages/HiringPage.d.ts.map +1 -1
- package/dist/pages/HiringPage.js +27 -4
- package/dist/pages/HiringPage.js.map +1 -1
- package/dist/pages/ListDetail.d.ts +11 -0
- package/dist/pages/ListDetail.d.ts.map +1 -0
- package/dist/pages/ListDetail.js +188 -0
- package/dist/pages/ListDetail.js.map +1 -0
- package/dist/pages/ListsPage.d.ts +9 -0
- package/dist/pages/ListsPage.d.ts.map +1 -0
- package/dist/pages/ListsPage.js +125 -0
- package/dist/pages/ListsPage.js.map +1 -0
- package/dist/pages/OpportunitiesPage.d.ts.map +1 -1
- package/dist/pages/OpportunitiesPage.js +97 -14
- package/dist/pages/OpportunitiesPage.js.map +1 -1
- package/dist/pages/OpportunityDetail.d.ts.map +1 -1
- package/dist/pages/OpportunityDetail.js +3 -1
- package/dist/pages/OpportunityDetail.js.map +1 -1
- package/dist/pages/PartnershipDetail.d.ts.map +1 -1
- package/dist/pages/PartnershipDetail.js +3 -1
- package/dist/pages/PartnershipDetail.js.map +1 -1
- package/dist/pages/PartnershipsPage.d.ts.map +1 -1
- package/dist/pages/PartnershipsPage.js +111 -14
- package/dist/pages/PartnershipsPage.js.map +1 -1
- package/dist/pages/PeoplePage.d.ts.map +1 -1
- package/dist/pages/PeoplePage.js +102 -3
- package/dist/pages/PeoplePage.js.map +1 -1
- package/dist/pages/PersonDetail.d.ts.map +1 -1
- package/dist/pages/PersonDetail.js +5 -3
- package/dist/pages/PersonDetail.js.map +1 -1
- package/dist/pages/PlanningPage.d.ts.map +1 -1
- package/dist/pages/PlanningPage.js +85 -17
- package/dist/pages/PlanningPage.js.map +1 -1
- package/dist/pages/RaiseDetail.d.ts.map +1 -1
- package/dist/pages/RaiseDetail.js +3 -1
- package/dist/pages/RaiseDetail.js.map +1 -1
- package/dist/pages/RoleDetail.d.ts.map +1 -1
- package/dist/pages/RoleDetail.js +5 -4
- package/dist/pages/RoleDetail.js.map +1 -1
- package/dist/pages/admin/DataPage.d.ts.map +1 -1
- package/dist/pages/admin/DataPage.js +110 -12
- package/dist/pages/admin/DataPage.js.map +1 -1
- package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
- package/dist/pages/admin/WebhookDeliveries.js +3 -2
- package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
- package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
- package/dist/pages/admin/WebhooksPage.js +3 -2
- package/dist/pages/admin/WebhooksPage.js.map +1 -1
- package/dist/pages/admin/WorkspaceSettingsPage.d.ts.map +1 -1
- package/dist/pages/admin/WorkspaceSettingsPage.js +45 -2
- package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
- package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
- package/dist/pages/forms/SubmissionsTable.js +2 -1
- package/dist/pages/forms/SubmissionsTable.js.map +1 -1
- package/dist/pages/handbook/HandbookPage.js +1 -1
- package/dist/pages/hiringDirectory.js +2 -2
- package/dist/pages/onboarding/WorkspaceStepPage.d.ts.map +1 -1
- package/dist/pages/onboarding/WorkspaceStepPage.js +15 -2
- package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
- package/dist/pages/positionDirectory.js +2 -2
- package/package.json +2 -2
- package/src/api/resource.ts +149 -31
- package/src/api/resources/companies.ts +7 -1
- package/src/api/resources/decisions.ts +3 -0
- package/src/api/resources/emailDomainLinker.ts +43 -0
- package/src/api/resources/forms.ts +5 -23
- package/src/api/resources/handbookPages.ts +1 -1
- package/src/api/resources/listMembers.ts +173 -0
- package/src/api/resources/lists.ts +61 -0
- package/src/api/resources/people.ts +4 -1
- package/src/api/resources/roles.ts +3 -1
- package/src/api/resources/sampleData.ts +56 -0
- package/src/api/resources/webhooks.ts +8 -24
- package/src/app/KelpieApp.tsx +4 -0
- package/src/components/ActivitiesPanel.tsx +2 -10
- package/src/components/ColumnPicker.tsx +124 -0
- package/src/components/DataTable.tsx +89 -10
- package/src/components/DecisionsPanel.tsx +4 -12
- package/src/components/EntitySearch.tsx +12 -5
- package/src/components/ListsPanel.tsx +176 -0
- package/src/components/NotesPanel.tsx +6 -14
- package/src/components/Paginator.tsx +78 -0
- package/src/components/PlanPanel.tsx +2 -10
- package/src/components/Shell.tsx +1 -0
- package/src/index.ts +10 -1
- package/src/lib/listView.ts +149 -0
- package/src/lib/sort.ts +143 -0
- package/src/pages/CompaniesPage.tsx +125 -11
- package/src/pages/CompanyDetail.tsx +62 -62
- package/src/pages/DealDetail.tsx +3 -0
- package/src/pages/DealsPage.tsx +178 -24
- package/src/pages/DecisionsPage.tsx +73 -18
- package/src/pages/FormsPage.tsx +81 -17
- package/src/pages/FundraisingPage.tsx +152 -24
- package/src/pages/HiringPage.tsx +39 -11
- package/src/pages/ListDetail.tsx +408 -0
- package/src/pages/ListsPage.tsx +281 -0
- package/src/pages/OpportunitiesPage.tsx +130 -24
- package/src/pages/OpportunityDetail.tsx +3 -0
- package/src/pages/PartnershipDetail.tsx +3 -0
- package/src/pages/PartnershipsPage.tsx +149 -24
- package/src/pages/PeoplePage.tsx +137 -11
- package/src/pages/PersonDetail.tsx +5 -3
- package/src/pages/PlanningPage.tsx +256 -29
- package/src/pages/RaiseDetail.tsx +3 -0
- package/src/pages/RoleDetail.tsx +21 -26
- package/src/pages/admin/DataPage.tsx +276 -13
- package/src/pages/admin/WebhookDeliveries.tsx +10 -15
- package/src/pages/admin/WebhooksPage.tsx +14 -19
- package/src/pages/admin/WorkspaceSettingsPage.tsx +134 -2
- package/src/pages/forms/SubmissionsTable.tsx +3 -10
- package/src/pages/handbook/HandbookPage.tsx +1 -1
- package/src/pages/hiringDirectory.ts +2 -2
- package/src/pages/onboarding/WorkspaceStepPage.tsx +31 -3
- package/src/pages/positionDirectory.ts +2 -2
- package/src/styles.css +3 -3
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { ListViewPreference } from '@kelpie/schemas'
|
|
2
|
+
import { useCallback, useMemo } from 'react'
|
|
3
|
+
|
|
4
|
+
import { useAccountPreferences, useUpdateAccountPreferences } from '../api/resources/account.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The saved view state a list or board page shows the signed-in person.
|
|
8
|
+
*
|
|
9
|
+
* `defaultVisible` is the columns a person sees before they have ever
|
|
10
|
+
* customised this view. `useListView` hides no column until preferences load —
|
|
11
|
+
* a request that has not answered yet is not the same as "the person chose to
|
|
12
|
+
* hide everything," and drawing an empty table under a spinner reads as
|
|
13
|
+
* broken.
|
|
14
|
+
*
|
|
15
|
+
* `mode`, `grouping`, `scope`, and `sort` mirror the four knobs a pipeline
|
|
16
|
+
* page carries: the board-vs-list toggle, the list grouping, the pipeline
|
|
17
|
+
* scope, and the current sort. Each returns `undefined` when the person has
|
|
18
|
+
* not made a choice; the page decides its own default and passes it to the
|
|
19
|
+
* relevant control. A setter called with `undefined` clears the stored
|
|
20
|
+
* value, which is how a person returns a knob to "let the page decide".
|
|
21
|
+
*
|
|
22
|
+
* The set of column ids the page supports is the source of truth for what is
|
|
23
|
+
* saveable. A stored id the page no longer recognises is ignored, and a page
|
|
24
|
+
* column added since the last save falls back to `defaultVisible`.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* A partial update to the stored preference. A key set to `undefined` removes
|
|
28
|
+
* it, so a setter can return a knob to "use the page's default".
|
|
29
|
+
*/
|
|
30
|
+
interface ListViewPatch {
|
|
31
|
+
readonly columns?: readonly string[] | undefined
|
|
32
|
+
readonly mode?: 'list' | 'columns' | undefined
|
|
33
|
+
readonly grouping?: string | undefined
|
|
34
|
+
readonly scope?: string | undefined
|
|
35
|
+
readonly sort?: string | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ListViewController {
|
|
39
|
+
readonly visibleKeys: readonly string[]
|
|
40
|
+
readonly setVisibleKeys: (keys: readonly string[]) => void
|
|
41
|
+
readonly mode: 'list' | 'columns' | undefined
|
|
42
|
+
readonly setMode: (mode: 'list' | 'columns' | undefined) => void
|
|
43
|
+
readonly grouping: string | undefined
|
|
44
|
+
readonly setGrouping: (grouping: string | undefined) => void
|
|
45
|
+
readonly scope: string | undefined
|
|
46
|
+
readonly setScope: (scope: string | undefined) => void
|
|
47
|
+
readonly sort: string | undefined
|
|
48
|
+
readonly setSort: (sort: string | undefined) => void
|
|
49
|
+
readonly isLoading: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useListView(
|
|
53
|
+
viewId: string,
|
|
54
|
+
supportedKeys: readonly string[],
|
|
55
|
+
defaultVisible: readonly string[],
|
|
56
|
+
): ListViewController {
|
|
57
|
+
const { preferences, isLoading } = useAccountPreferences()
|
|
58
|
+
const update = useUpdateAccountPreferences()
|
|
59
|
+
const stored = preferences?.listViews[viewId]
|
|
60
|
+
|
|
61
|
+
const visibleKeys = useMemo(() => {
|
|
62
|
+
const cols = stored?.columns
|
|
63
|
+
|
|
64
|
+
if (cols === undefined) {
|
|
65
|
+
return defaultVisible
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A stored id the page no longer knows about is dropped, and if the filter
|
|
69
|
+
// strips the whole list the default fills in — a page that lost every
|
|
70
|
+
// recognised column to a rename should not render as empty.
|
|
71
|
+
const kept = cols.filter((key) => supportedKeys.includes(key))
|
|
72
|
+
|
|
73
|
+
return kept.length === 0 ? defaultVisible : kept
|
|
74
|
+
}, [stored, supportedKeys, defaultVisible])
|
|
75
|
+
|
|
76
|
+
const patchView = useCallback(
|
|
77
|
+
(patch: ListViewPatch): void => {
|
|
78
|
+
// Merge into the existing entry so a change to one knob does not wipe
|
|
79
|
+
// the other four. A key set to `undefined` in `patch` is removed from
|
|
80
|
+
// the stored entry, so a setter can return a knob to "use the default".
|
|
81
|
+
const merged: Record<string, unknown> = { ...(stored ?? {}) }
|
|
82
|
+
|
|
83
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
84
|
+
if (value === undefined) {
|
|
85
|
+
delete merged[key]
|
|
86
|
+
} else {
|
|
87
|
+
merged[key] = value
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const nextViews: Record<string, ListViewPreference> = {
|
|
92
|
+
...(preferences?.listViews ?? {}),
|
|
93
|
+
[viewId]: merged as ListViewPreference,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
update.run({ listViews: nextViews })
|
|
97
|
+
},
|
|
98
|
+
[preferences, stored, update, viewId],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const setVisibleKeys = useCallback(
|
|
102
|
+
(keys: readonly string[]) => {
|
|
103
|
+
patchView({ columns: keys })
|
|
104
|
+
},
|
|
105
|
+
[patchView],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const setMode = useCallback(
|
|
109
|
+
(mode: 'list' | 'columns' | undefined) => {
|
|
110
|
+
patchView({ mode })
|
|
111
|
+
},
|
|
112
|
+
[patchView],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const setGrouping = useCallback(
|
|
116
|
+
(grouping: string | undefined) => {
|
|
117
|
+
patchView({ grouping })
|
|
118
|
+
},
|
|
119
|
+
[patchView],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const setScope = useCallback(
|
|
123
|
+
(scope: string | undefined) => {
|
|
124
|
+
patchView({ scope })
|
|
125
|
+
},
|
|
126
|
+
[patchView],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const setSort = useCallback(
|
|
130
|
+
(sort: string | undefined) => {
|
|
131
|
+
patchView({ sort })
|
|
132
|
+
},
|
|
133
|
+
[patchView],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
visibleKeys,
|
|
138
|
+
setVisibleKeys,
|
|
139
|
+
mode: stored?.mode,
|
|
140
|
+
setMode,
|
|
141
|
+
grouping: stored?.grouping,
|
|
142
|
+
setGrouping,
|
|
143
|
+
scope: stored?.scope,
|
|
144
|
+
setScope,
|
|
145
|
+
sort: stored?.sort,
|
|
146
|
+
setSort,
|
|
147
|
+
isLoading,
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/lib/sort.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comparators for the DataTable's client-side sort path.
|
|
3
|
+
*
|
|
4
|
+
* The server-side path is a keyset over an indexed column, so a page-local sort
|
|
5
|
+
* is different in kind: it only orders the records the current page holds.
|
|
6
|
+
* Anything on another page keeps the resource's own default order — which is
|
|
7
|
+
* why a "sorted on this page" hint sits under any table sorted this way.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type SortDirection = 'asc' | 'desc'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns the sort string that should reach the server, dropping any that
|
|
14
|
+
* names a client-only column. A page holds the active sort as a single string;
|
|
15
|
+
* this lets it pass an unfiltered `sort` to `<DataTable>` (so the header shows
|
|
16
|
+
* an arrow either way) while only forwarding server-sortable choices to its
|
|
17
|
+
* `useX({ sort: serverSortOnly(sort, sortKeys) })` call.
|
|
18
|
+
*
|
|
19
|
+
* `sortKeys` is the set of `Column.sortKey` values on the page — the fields
|
|
20
|
+
* the resource's `_SORTS` map accepts. A `sort` naming anything else answers
|
|
21
|
+
* `undefined`, which drops back to the resource's own default order.
|
|
22
|
+
*/
|
|
23
|
+
export function serverSortOnly(
|
|
24
|
+
sort: string | undefined,
|
|
25
|
+
sortKeys: readonly string[],
|
|
26
|
+
): string | undefined {
|
|
27
|
+
if (sort === undefined) {
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bare = sort.startsWith('-') ? sort.slice(1) : sort
|
|
32
|
+
|
|
33
|
+
return sortKeys.includes(bare) ? sort : undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A value the sort knows how to compare. `null` (and `undefined`) always sort
|
|
38
|
+
* to the bottom, regardless of direction — a missing value is not "less than"
|
|
39
|
+
* or "greater than" anything, and pinning it to one end reads better than
|
|
40
|
+
* scattering the blanks through the middle.
|
|
41
|
+
*/
|
|
42
|
+
export type SortValue = string | number | Date | boolean | readonly string[] | null | undefined
|
|
43
|
+
|
|
44
|
+
function typeRank(value: SortValue): number {
|
|
45
|
+
if (value === null || value === undefined) {
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof value === 'string') {
|
|
50
|
+
return value.length === 0 ? 0 : 1
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return value.length === 0 ? 0 : 1
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Returns a negative number if `a` sorts before `b`, positive if after, 0 for equal. */
|
|
61
|
+
export function compareValues(a: SortValue, b: SortValue): number {
|
|
62
|
+
const rankA = typeRank(a)
|
|
63
|
+
const rankB = typeRank(b)
|
|
64
|
+
|
|
65
|
+
// Missing values sink to the bottom of the ascending order and stay there
|
|
66
|
+
// when reversed. The direction reversal is the caller's job — a null being
|
|
67
|
+
// "always last" is a property of the value, not of the direction.
|
|
68
|
+
if (rankA === 0 && rankB === 0) {
|
|
69
|
+
return 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (rankA === 0) {
|
|
73
|
+
return 1
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (rankB === 0) {
|
|
77
|
+
return -1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (a instanceof Date && b instanceof Date) {
|
|
81
|
+
return a.getTime() - b.getTime()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
85
|
+
return a - b
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
|
89
|
+
return a === b ? 0 : a ? -1 : 1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
93
|
+
return a.join(', ').localeCompare(b.join(', '), undefined, { sensitivity: 'base', numeric: true })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stable sort by a caller-supplied accessor. `Array.prototype.sort` is not
|
|
101
|
+
* guaranteed stable across every engine JS runs on, so ties fall back to the
|
|
102
|
+
* original index — otherwise switching direction on a column with many ties
|
|
103
|
+
* would reshuffle equal rows on every click.
|
|
104
|
+
*
|
|
105
|
+
* Missing values (null, undefined, empty string, empty array) pin to the
|
|
106
|
+
* bottom in both directions. Reversing "asc null-last" as a signed compare
|
|
107
|
+
* would put nulls on top under desc, which reads as broken — the direction
|
|
108
|
+
* flip is applied only after the null pin is resolved.
|
|
109
|
+
*/
|
|
110
|
+
export function sortRowsBy<TRow>(
|
|
111
|
+
rows: readonly TRow[],
|
|
112
|
+
getValue: (row: TRow) => SortValue,
|
|
113
|
+
direction: SortDirection,
|
|
114
|
+
): TRow[] {
|
|
115
|
+
const indexed = rows.map((row, index) => ({ row, index, value: getValue(row) }))
|
|
116
|
+
|
|
117
|
+
indexed.sort((left, right) => {
|
|
118
|
+
const leftMissing = typeRank(left.value) === 0
|
|
119
|
+
const rightMissing = typeRank(right.value) === 0
|
|
120
|
+
|
|
121
|
+
if (leftMissing && rightMissing) {
|
|
122
|
+
return left.index - right.index
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (leftMissing) {
|
|
126
|
+
return 1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (rightMissing) {
|
|
130
|
+
return -1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const compared = compareValues(left.value, right.value)
|
|
134
|
+
|
|
135
|
+
if (compared !== 0) {
|
|
136
|
+
return direction === 'asc' ? compared : -compared
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return left.index - right.index
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return indexed.map((entry) => entry.row)
|
|
143
|
+
}
|
|
@@ -2,13 +2,19 @@ import type { Company, IcpFit } from '@kelpie/schemas'
|
|
|
2
2
|
import { useState } from 'react'
|
|
3
3
|
import { useNavigate } from 'react-router'
|
|
4
4
|
|
|
5
|
+
import { useTimezone } from '../api/resources/account.ts'
|
|
5
6
|
import { useCompanies, useCreateCompany } from '../api/resources/companies.ts'
|
|
6
7
|
import { Chip } from '../components/Chip.tsx'
|
|
7
8
|
import type { ChipTone } from '../components/Chip.tsx'
|
|
9
|
+
import { ColumnPicker } from '../components/ColumnPicker.tsx'
|
|
8
10
|
import { DataTable } from '../components/DataTable.tsx'
|
|
9
11
|
import type { Column } from '../components/DataTable.tsx'
|
|
10
12
|
import { FilterBar, PageHeader } from '../components/PageHeader.tsx'
|
|
13
|
+
import { Paginator } from '../components/Paginator.tsx'
|
|
11
14
|
import { ErrorPanel, LoadingPanel } from '../components/QueryState.tsx'
|
|
15
|
+
import { formatDate } from '../lib/dates.ts'
|
|
16
|
+
import { useListView } from '../lib/listView.ts'
|
|
17
|
+
import { serverSortOnly } from '../lib/sort.ts'
|
|
12
18
|
import { useCompanyHeadcounts } from './positionDirectory.ts'
|
|
13
19
|
|
|
14
20
|
/**
|
|
@@ -26,12 +32,20 @@ const ICP_TONES: Readonly<Record<IcpFit, ChipTone>> = {
|
|
|
26
32
|
unknown: 'neutral',
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
const DEFAULT_VISIBLE_KEYS: readonly string[] = ['name', 'domain', 'hq', 'type', 'updatedAt']
|
|
36
|
+
|
|
37
|
+
const SERVER_SORT_KEYS: readonly string[] = ['name', 'created_at', 'updated_at']
|
|
38
|
+
|
|
29
39
|
export function CompaniesPage(): React.JSX.Element {
|
|
30
40
|
const [term, setTerm] = useState('')
|
|
31
41
|
const [sort, setSort] = useState<string | undefined>(undefined)
|
|
32
42
|
const navigate = useNavigate()
|
|
43
|
+
const timezone = useTimezone()
|
|
33
44
|
const hasFilter = term.trim().length > 0
|
|
34
|
-
const companies = useCompanies({
|
|
45
|
+
const companies = useCompanies({
|
|
46
|
+
term: hasFilter ? term.trim() : undefined,
|
|
47
|
+
sort: serverSortOnly(sort, SERVER_SORT_KEYS),
|
|
48
|
+
})
|
|
35
49
|
const headcounts = useCompanyHeadcounts(companies.records.map((company) => company.id))
|
|
36
50
|
const createCompany = useCreateCompany()
|
|
37
51
|
|
|
@@ -51,30 +65,130 @@ export function CompaniesPage(): React.JSX.Element {
|
|
|
51
65
|
{
|
|
52
66
|
key: 'domain',
|
|
53
67
|
header: 'Domain',
|
|
68
|
+
getSortValue: (company) => company.domain,
|
|
54
69
|
render: (company) => (
|
|
55
70
|
<span className="font-mono text-[12px] text-ink-muted">{company.domain ?? '—'}</span>
|
|
56
71
|
),
|
|
57
72
|
},
|
|
73
|
+
{
|
|
74
|
+
key: 'website',
|
|
75
|
+
header: 'Website',
|
|
76
|
+
getSortValue: (company) => company.website,
|
|
77
|
+
render: (company) => (
|
|
78
|
+
<span className="font-mono text-[12px] text-ink-muted">{company.website ?? '—'}</span>
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: 'industry',
|
|
83
|
+
header: 'Industry',
|
|
84
|
+
getSortValue: (company) => company.industry,
|
|
85
|
+
render: (company) => company.industry ?? '—',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: 'hq',
|
|
89
|
+
header: 'HQ',
|
|
90
|
+
getSortValue: (company) => company.hq,
|
|
91
|
+
render: (company) => company.hq ?? '—',
|
|
92
|
+
},
|
|
58
93
|
{
|
|
59
94
|
key: 'type',
|
|
60
95
|
header: 'Type',
|
|
96
|
+
getSortValue: (company) => company.accountType,
|
|
61
97
|
render: (company) => <span className="capitalize">{company.accountType}</span>,
|
|
62
98
|
},
|
|
99
|
+
{
|
|
100
|
+
key: 'stage',
|
|
101
|
+
header: 'Stage',
|
|
102
|
+
getSortValue: (company) => company.stage,
|
|
103
|
+
render: (company) => <span className="capitalize">{company.stage}</span>,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: 'sizeBand',
|
|
107
|
+
header: 'Size',
|
|
108
|
+
getSortValue: (company) => company.sizeBand,
|
|
109
|
+
render: (company) => company.sizeBand,
|
|
110
|
+
},
|
|
63
111
|
{
|
|
64
112
|
key: 'icp',
|
|
65
113
|
header: 'ICP fit',
|
|
114
|
+
getSortValue: (company) => company.icpFit,
|
|
66
115
|
render: (company) => <Chip tone={ICP_TONES[company.icpFit]}>{company.icpFit}</Chip>,
|
|
67
116
|
},
|
|
117
|
+
{
|
|
118
|
+
key: 'techStack',
|
|
119
|
+
header: 'Tech stack',
|
|
120
|
+
getSortValue: (company) => company.techStack.join(', ') || null,
|
|
121
|
+
render: (company) =>
|
|
122
|
+
company.techStack.length === 0 ? '—' : (
|
|
123
|
+
<span className="flex flex-wrap gap-1">
|
|
124
|
+
{company.techStack.map((entry) => (
|
|
125
|
+
<Chip key={entry}>
|
|
126
|
+
<span className="text-[10px]">{entry}</span>
|
|
127
|
+
</Chip>
|
|
128
|
+
))}
|
|
129
|
+
</span>
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: 'description',
|
|
134
|
+
header: 'Description',
|
|
135
|
+
getSortValue: (company) => company.description || null,
|
|
136
|
+
render: (company) =>
|
|
137
|
+
company.description.length === 0 ? '—' : (
|
|
138
|
+
<span className="text-ink-muted">{company.description}</span>
|
|
139
|
+
),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: 'summary',
|
|
143
|
+
header: 'Summary',
|
|
144
|
+
getSortValue: (company) => company.summary || null,
|
|
145
|
+
render: (company) =>
|
|
146
|
+
company.summary.length === 0 ? '—' : (
|
|
147
|
+
<span className="text-ink-muted">{company.summary}</span>
|
|
148
|
+
),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
key: 'tags',
|
|
152
|
+
header: 'Tags',
|
|
153
|
+
getSortValue: (company) => company.tags.join(', ') || null,
|
|
154
|
+
render: (company) =>
|
|
155
|
+
company.tags.length === 0 ? '—' : (
|
|
156
|
+
<span className="flex flex-wrap gap-1">
|
|
157
|
+
{company.tags.map((tag) => (
|
|
158
|
+
<Chip key={tag}>
|
|
159
|
+
<span className="text-[10px]">{tag}</span>
|
|
160
|
+
</Chip>
|
|
161
|
+
))}
|
|
162
|
+
</span>
|
|
163
|
+
),
|
|
164
|
+
},
|
|
68
165
|
{
|
|
69
166
|
key: 'people',
|
|
70
167
|
header: 'People',
|
|
71
168
|
className: 'w-20',
|
|
169
|
+
getSortValue: (company) => headcounts.countFor(company.id),
|
|
72
170
|
render: (company) => (
|
|
73
171
|
<span className="font-mono text-[12px]">{headcounts.countFor(company.id)}</span>
|
|
74
172
|
),
|
|
75
173
|
},
|
|
174
|
+
{
|
|
175
|
+
key: 'createdAt',
|
|
176
|
+
header: 'Created',
|
|
177
|
+
sortKey: 'created_at',
|
|
178
|
+
render: (company) => formatDate(company.createdAt, timezone),
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
key: 'updatedAt',
|
|
182
|
+
header: 'Updated',
|
|
183
|
+
sortKey: 'updated_at',
|
|
184
|
+
render: (company) => formatDate(company.updatedAt, timezone),
|
|
185
|
+
},
|
|
76
186
|
]
|
|
77
187
|
|
|
188
|
+
const supportedKeys = columns.map((column) => column.key)
|
|
189
|
+
const listView = useListView('companies', supportedKeys, DEFAULT_VISIBLE_KEYS)
|
|
190
|
+
const pickerOptions = columns.map((column) => ({ key: column.key, label: column.header }))
|
|
191
|
+
|
|
78
192
|
return (
|
|
79
193
|
<div className="animate-fade-in">
|
|
80
194
|
<PageHeader
|
|
@@ -83,6 +197,13 @@ export function CompaniesPage(): React.JSX.Element {
|
|
|
83
197
|
void addCompany()
|
|
84
198
|
}}
|
|
85
199
|
addLabel="Add company"
|
|
200
|
+
actions={
|
|
201
|
+
<ColumnPicker
|
|
202
|
+
options={pickerOptions}
|
|
203
|
+
visibleKeys={listView.visibleKeys}
|
|
204
|
+
onChange={listView.setVisibleKeys}
|
|
205
|
+
/>
|
|
206
|
+
}
|
|
86
207
|
/>
|
|
87
208
|
<FilterBar value={term} onChange={setTerm} placeholder="Filter by name, type, tags…" />
|
|
88
209
|
|
|
@@ -98,6 +219,7 @@ export function CompaniesPage(): React.JSX.Element {
|
|
|
98
219
|
<LoadingPanel label="Loading companies…" />
|
|
99
220
|
) : (
|
|
100
221
|
<>
|
|
222
|
+
<Paginator list={companies} placement="top" />
|
|
101
223
|
<DataTable
|
|
102
224
|
columns={columns}
|
|
103
225
|
rows={companies.records}
|
|
@@ -119,6 +241,7 @@ export function CompaniesPage(): React.JSX.Element {
|
|
|
119
241
|
}
|
|
120
242
|
sort={sort}
|
|
121
243
|
onSortChange={setSort}
|
|
244
|
+
visibleColumnKeys={listView.visibleKeys}
|
|
122
245
|
/>
|
|
123
246
|
{!headcounts.isComplete && !headcounts.isLoading && (
|
|
124
247
|
<p className="mt-2 text-[11px] text-ink-faint">
|
|
@@ -126,16 +249,7 @@ export function CompaniesPage(): React.JSX.Element {
|
|
|
126
249
|
a floor rather than a total.
|
|
127
250
|
</p>
|
|
128
251
|
)}
|
|
129
|
-
{companies
|
|
130
|
-
<button
|
|
131
|
-
type="button"
|
|
132
|
-
onClick={companies.loadMore}
|
|
133
|
-
disabled={companies.isLoadingMore}
|
|
134
|
-
className="mt-3 rounded-md border border-border px-3 py-1.5 text-[12px] font-medium text-ink transition hover:border-border-strong hover:bg-surface-sunken disabled:opacity-50"
|
|
135
|
-
>
|
|
136
|
-
{companies.isLoadingMore ? 'Loading…' : 'Load more'}
|
|
137
|
-
</button>
|
|
138
|
-
)}
|
|
252
|
+
<Paginator list={companies} />
|
|
139
253
|
</>
|
|
140
254
|
)}
|
|
141
255
|
</div>
|