Skip to content

Commit bcf7d50

Browse files
authored
Merge pull request #367 from developmentseed/add/searchable-org-members-table
Add search to members and staff tables in organization page
2 parents d38adda + 96d708c commit bcf7d50

File tree

12 files changed

+295
-83
lines changed

12 files changed

+295
-83
lines changed

cypress/e2e/organizations/pagination.cy.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ describe('Organization page', () => {
7979
cy.get('[data-cy=org-staff-table-pagination]').within(() => {
8080
cy.contains('Showing 1-3 of 3')
8181
})
82+
83+
// Perform search by username
84+
cy.get('[data-cy=org-staff-table-search-input]').type('2')
85+
cy.get('[data-cy=org-staff-table-search-submit]').click()
86+
cy.get('[data-cy=org-staff-table-pagination]').contains('Showing 1-1 of 1')
8287
})
8388

8489
it('Organization teams and members tables are populated and paginated', () => {
@@ -184,5 +189,16 @@ describe('Organization page', () => {
184189

185190
// Item from page 2 is present
186191
cy.get('[data-cy=org-members-table]').contains('User 207')
192+
193+
// Perform search by username
194+
cy.get('[data-cy=org-members-table-search-input]').type('User 2')
195+
cy.get('[data-cy=org-members-table-search-submit]').click()
196+
cy.get('[data-cy=org-members-table-pagination]').contains(
197+
'Showing 1-10 of 20'
198+
)
199+
200+
// Check empty results message after timeout
201+
cy.get('[data-cy=org-members-table-search-input]').clear().type('aaaa')
202+
cy.get('[data-cy=org-members-table]').contains('Search returned no results')
187203
})
188204
})
Lines changed: 5 additions & 0 deletions
Loading

src/components/add-member-form.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default function AddMemberForm({ onSubmit }) {
3131
id='osmId'
3232
placeholder='OSM ID'
3333
value={values.osmId}
34+
style={{ width: '6rem' }}
3435
/>
3536
{status && status.msg && <div>{status.msg}</div>}
3637
<Button type='submit' variant='submit' disabled={isSubmitting}>

src/components/button.js

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Link from 'next/link'
66

77
const URL = process.env.APP_URL
88

9-
const style = css.global`
9+
const ButtonStyles = css.global`
1010
.button {
1111
display: inline-block;
1212
text-align: center;
@@ -100,7 +100,7 @@ const style = css.global`
100100
}
101101
102102
.button.submit {
103-
background: ${theme.colors.primaryLite};
103+
background-color: ${theme.colors.primaryLite};
104104
}
105105
106106
.button.disabled {
@@ -149,6 +149,7 @@ export default function Button({
149149
if (type === 'submit') {
150150
return (
151151
<button
152+
data-cy={dataCy}
152153
type='submit'
153154
className={classNames}
154155
disabled={disabled}
@@ -158,7 +159,30 @@ export default function Button({
158159
value='value'
159160
>
160161
{children || value}
161-
<style jsx>{style}</style>
162+
<style jsx>{ButtonStyles}</style>
163+
<style jsx>{`
164+
.button {
165+
box-shadow: ${flat && 'none'};
166+
position: relative;
167+
}
168+
.button::after {
169+
content: '';
170+
position: absolute;
171+
top: 0;
172+
left: 0;
173+
width: 100%;
174+
height: 100%;
175+
mask: ${useIcon
176+
? `url(${join(URL, `/static/icon-${useIcon}.svg`)})`
177+
: 'none'};
178+
mask-repeat: no-repeat;
179+
mask-position: center;
180+
z-index: 2;
181+
background-color: ${useIcon
182+
? theme.colors.primaryColor
183+
: 'initial'};
184+
}
185+
`}</style>
162186
</button>
163187
)
164188
}
@@ -172,7 +196,30 @@ export default function Button({
172196
id={id}
173197
>
174198
{children || value}
175-
<style jsx>{style}</style>
199+
<style jsx>{ButtonStyles}</style>
200+
<style jsx>{`
201+
.button {
202+
box-shadow: ${flat && 'none'};
203+
position: relative;
204+
}
205+
.button::after {
206+
content: '';
207+
position: absolute;
208+
top: 0;
209+
left: 0;
210+
width: 100%;
211+
height: 100%;
212+
mask: ${useIcon
213+
? `url(${join(URL, `/static/icon-${useIcon}.svg`)})`
214+
: 'none'};
215+
mask-repeat: no-repeat;
216+
mask-position: center;
217+
z-index: 2;
218+
background-color: ${useIcon
219+
? theme.colors.primaryColor
220+
: 'initial'};
221+
}
222+
`}</style>
176223
</Link>
177224
)
178225
}
@@ -184,15 +231,28 @@ export default function Button({
184231
disabled={disabled}
185232
>
186233
{children}
187-
<style jsx>{style}</style>
234+
<style jsx>{ButtonStyles}</style>
188235
<style jsx>{`
189236
.button {
190237
box-shadow: ${flat && 'none'};
191-
background-image: ${useIcon
238+
position: relative;
239+
min-width: 1.75rem;
240+
min-height: 1.75rem;
241+
}
242+
.button::after {
243+
content: '';
244+
position: absolute;
245+
top: 0;
246+
left: 0;
247+
width: 100%;
248+
height: 100%;
249+
mask: ${useIcon
192250
? `url(${join(URL, `/static/icon-${useIcon}.svg`)})`
193251
: 'none'};
194-
min-height: ${useIcon && '1.75rem'};
195-
min-width: ${useIcon && '1.75rem'};
252+
mask-repeat: no-repeat;
253+
mask-position: center;
254+
z-index: 2;
255+
background-color: ${useIcon ? theme.colors.primaryColor : 'initial'};
196256
}
197257
`}</style>
198258
</div>

src/components/layout.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const globalStyles = css.global`
8989
display: flex;
9090
flex-direction: column;
9191
justify-content: space-between;
92-
margin-bottom: ${theme.layout.globalSpacing};
92+
margin-bottom: 0.5rem;
9393
}
9494
9595
@media (min-width: ${theme.mediaRanges.medium}) {
@@ -99,7 +99,7 @@ export const globalStyles = css.global`
9999
}
100100
.section-actions {
101101
flex-direction: row;
102-
align-items: center;
102+
align-items: baseline;
103103
}
104104
}
105105
@@ -182,7 +182,7 @@ export const globalStyles = css.global`
182182
}
183183
184184
.form-control {
185-
margin-bottom: 1rem;
185+
margin-bottom: 0.5rem;
186186
display: flex;
187187
justify-content: space-between;
188188
align-items: center;
@@ -192,7 +192,12 @@ export const globalStyles = css.global`
192192
flex-direction: column;
193193
align-items: flex-start;
194194
}
195-
195+
.justify-start {
196+
justify-content: flex-start;
197+
}
198+
.justify-end {
199+
justify-content: flex-end;
200+
}
196201
.form-control :global(label) {
197202
font-size: 0.875rem;
198203
margin-bottom: 0.5rem;
@@ -202,7 +207,7 @@ export const globalStyles = css.global`
202207
.form-control :global(textarea) {
203208
min-width: 6rem;
204209
padding: 0.5rem 1rem 0.5rem 0.25rem;
205-
margin-right: 1rem;
210+
margin-right: 0.5rem;
206211
border: 2px solid ${theme.colors.primaryColor};
207212
}
208213
@@ -211,6 +216,14 @@ export const globalStyles = css.global`
211216
height: 2.5rem;
212217
}
213218
219+
.form-control input#search {
220+
margin: 0;
221+
padding: 0.375rem 1rem 0.375rem 0.5rem;
222+
}
223+
224+
.form-control input#search + .button {
225+
margin-left: -2px;
226+
}
214227
.status--alert {
215228
font-size: 0.875rem;
216229
color: ${theme.colors.secondaryColor};

src/components/tables/table.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,16 @@ export default function Table({
117117
}
118118
119119
thead th {
120-
padding: 0 1rem 1rem;
120+
padding: 0.5rem 1rem;
121121
vertical-align: middle;
122122
position: relative;
123123
text-transform: uppercase;
124124
text-align: left;
125125
font-family: ${theme.typography.headingFontFamily};
126126
font-weight: ${theme.typography.baseFontWeight};
127-
font-size: 0.875rem 1rem;
127+
font-size: 0.875rem;
128128
letter-spacing: 0.125rem;
129+
background: ${theme.colors.primaryLite};
129130
border-bottom: 4px solid ${theme.colors.primaryColor};
130131
}
131132

src/components/tables/users.js

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,91 @@
11
import T from 'prop-types'
22
import Table from './table'
33
import { useFetchList } from '../../hooks/use-fetch-list'
4-
import { useState } from 'react'
4+
import { useEffect, useRef, useState } from 'react'
55
import Pagination from '../pagination'
6+
import qs from 'qs'
7+
import { Field, Form, Formik, useFormikContext } from 'formik'
8+
import Button from '../button'
69

7-
function UsersTable({ type, orgId, onRowClick }) {
10+
/**
11+
* This is a helper component to auto-submit search values after a timeout
12+
*/
13+
const AutoSubmitSearch = () => {
14+
const timerRef = useRef(null)
15+
16+
const { values, touched, submitForm } = useFormikContext()
17+
18+
useEffect(() => {
19+
// Check if input was touched. Formik behavior is to update 'touched'
20+
// flag on input blur or submit, but we want to submit changes also on
21+
// key press. 'touched' is not a useEffect dependency because it is
22+
// constantly updated without value changes.
23+
const isTouched = touched.search || values?.search.length > 0
24+
25+
// If search is touched
26+
if (isTouched) {
27+
// Clear previous timeout, if exists
28+
if (timerRef.current) {
29+
clearTimeout(timerRef.current)
30+
}
31+
// Define new timeout
32+
timerRef.current = setTimeout(submitForm, 1000)
33+
}
34+
35+
// Clear timeout on unmount
36+
return () => timerRef.current && clearTimeout(timerRef.current)
37+
}, [values])
38+
39+
return null
40+
}
41+
42+
/**
43+
* The search input
44+
*/
45+
const SearchInput = ({ onSearch, 'data-cy': dataCy }) => {
46+
return (
47+
<Formik
48+
initialValues={{ search: '' }}
49+
onSubmit={({ search }) => onSearch(search)}
50+
>
51+
<Form
52+
className='form-control justify-start'
53+
style={{ alignItems: 'stretch' }}
54+
>
55+
<Field
56+
data-cy={`${dataCy}-search-input`}
57+
type='search'
58+
name='search'
59+
id='search'
60+
placeholder='Search username...'
61+
style={{ width: '12rem' }}
62+
/>
63+
<Button
64+
data-cy={`${dataCy}-search-submit`}
65+
type='submit'
66+
variant='submit'
67+
useIcon='magnifier-left'
68+
flat
69+
/>
70+
<AutoSubmitSearch />
71+
</Form>
72+
</Formik>
73+
)
74+
}
75+
76+
function UsersTable({ type, orgId, onRowClick, isSearchable }) {
877
const [page, setPage] = useState(1)
78+
const [search, setSearch] = useState(null)
979

1080
let apiBasePath
1181
let emptyMessage
1282
let columns
1383

84+
const querystring = qs.stringify({
85+
search,
86+
page,
87+
})
88+
1489
switch (type) {
1590
case 'org-members':
1691
apiBasePath = `/organizations/${orgId}/members`
@@ -33,10 +108,24 @@ function UsersTable({ type, orgId, onRowClick }) {
33108
const {
34109
result: { data, pagination },
35110
isLoading,
36-
} = useFetchList(`${apiBasePath}?page=${page}`)
111+
} = useFetchList(`${apiBasePath}?${querystring}`)
112+
113+
if (!isLoading && search?.length > 0) {
114+
emptyMessage = 'Search returned no results.'
115+
}
37116

38117
return (
39118
<>
119+
{isSearchable && (
120+
<SearchInput
121+
data-cy={`${type}-table`}
122+
onSearch={(search) => {
123+
// Reset to page 1 and search
124+
setPage(1)
125+
setSearch(search)
126+
}}
127+
/>
128+
)}
40129
<Table
41130
data-cy={`${type}-table`}
42131
rows={data}

0 commit comments

Comments
 (0)