Load more search results, maintain scroll position

master
Tanner Collin 3 years ago
parent 42ad1ac327
commit 4af72a43e5
  1. 4
      apiserver/apiserver/api/utils.py
  2. 97
      apiserver/apiserver/api/views.py
  3. 126
      webclient/src/Members.js

@ -146,6 +146,8 @@ def gen_search_strings():
'''
Generate a cache dict of names to member ids for rapid string matching
'''
start = time.time()
search_strings = {}
for m in models.Member.objects.order_by('-expire_date'):
string = '{} {} | {} {}'.format(
@ -165,6 +167,8 @@ def gen_search_strings():
search_strings[string] = m.id
cache.set('search_strings', search_strings)
logger.info('Generated search strings in %s s.', time.time() - start)
LARGE_SIZE = 1080
MEDIUM_SIZE = 220

@ -4,7 +4,7 @@ logger = logging.getLogger(__name__)
from django.contrib.auth.models import User, Group
from django.shortcuts import get_object_or_404, redirect
from django.db import transaction
from django.db.models import Max, F
from django.db.models import Max, F, Count
from django.db.utils import OperationalError
from django.http import HttpResponse, Http404, FileResponse
from django.core.files.base import File
@ -43,7 +43,7 @@ Create = mixins.CreateModelMixin
Update = mixins.UpdateModelMixin
Destroy = mixins.DestroyModelMixin
NUM_SEARCH_RESULTS = 20
NUM_RESULTS = 100
class SearchViewSet(Base, Retrieve):
@ -79,54 +79,57 @@ class SearchViewSet(Base, Retrieve):
if len(results) == 0 and len(search) >= 3 and '@' not in search:
# then get fuzzy matches, but not for emails
fuzzy_results = process.extract(search, choices, limit=NUM_SEARCH_RESULTS, scorer=fuzz.token_set_ratio)
fuzzy_results = process.extract(search, choices, limit=20, scorer=fuzz.token_set_ratio)
results += [x[0] for x in fuzzy_results]
# remove dupes, truncate list
results = list(OrderedDict.fromkeys(results))[:NUM_SEARCH_RESULTS]
results = list(OrderedDict.fromkeys(results))[:20]
result_ids = [search_strings[x] for x in results]
result_objects = [queryset.get(id=x) for x in result_ids]
queryset = result_objects
logging.info('Search for: {}, results: {}'.format(search, len(queryset)))
elif self.action == 'create' and sort == 'recently_vetted':
utils.gen_search_strings() # update cache
queryset = queryset.order_by('-vetted_date')
elif self.action == 'create' and sort == 'newest_active':
queryset = queryset.filter(paused_date__isnull=True)
queryset = queryset.order_by('-application_date')
elif self.action == 'create' and sort == 'newest_overall':
queryset = queryset.order_by('-application_date')
elif self.action == 'create' and sort == 'oldest_active':
queryset = queryset.filter(paused_date__isnull=True)
queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'oldest_overall':
queryset = queryset.filter(application_date__isnull=False)
queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'recently_inactive':
queryset = queryset.filter(paused_date__isnull=False)
queryset = queryset.order_by('-paused_date')
elif self.action == 'create' and sort == 'is_director':
queryset = queryset.filter(is_director=True)
queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'is_instructor':
queryset = queryset.filter(is_instructor=True)
queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'due':
queryset = queryset.filter(status='Due')
queryset = queryset.order_by('expire_date')
elif self.action == 'create' and sort == 'overdue':
queryset = queryset.filter(status='Overdue')
queryset = queryset.order_by('expire_date')
elif self.action == 'create' and sort == 'last_scanned':
if self.request.user.member.allow_last_scanned:
queryset = queryset.filter(allow_last_scanned=True)
queryset = queryset.order_by('-user__cards__last_seen')
else:
elif self.action == 'create':
if sort == 'recently_vetted':
queryset = queryset.filter(vetted_date__isnull=False)
queryset = queryset.order_by('-vetted_date')
elif sort == 'newest_active':
queryset = queryset.filter(paused_date__isnull=True)
queryset = queryset.order_by('-application_date')
elif sort == 'newest_overall':
queryset = queryset.order_by('-application_date')
elif sort == 'oldest_active':
queryset = queryset.filter(paused_date__isnull=True)
queryset = queryset.order_by('application_date')
elif sort == 'oldest_overall':
queryset = queryset.filter(application_date__isnull=False)
queryset = queryset.order_by('application_date')
elif sort == 'recently_inactive':
queryset = queryset.filter(paused_date__isnull=False)
queryset = queryset.order_by('-paused_date')
elif sort == 'is_director':
queryset = queryset.filter(is_director=True)
queryset = queryset.order_by('application_date')
elif sort == 'is_instructor':
queryset = queryset.filter(is_instructor=True)
queryset = queryset.order_by('application_date')
elif sort == 'due':
queryset = queryset.filter(status='Due')
queryset = queryset.order_by('expire_date')
elif sort == 'overdue':
queryset = queryset.filter(status='Overdue')
queryset = queryset.order_by('expire_date')
elif sort == 'last_scanned':
if self.request.user.member.allow_last_scanned:
queryset = queryset.filter(allow_last_scanned=True)
queryset = queryset.order_by('-user__cards__last_seen')
else:
queryset = []
elif sort == 'everyone':
queryset = queryset.annotate(Count('user__transactions')).order_by('-user__transactions__count')
elif sort == 'best_looking':
queryset = []
elif self.action == 'create' and sort == 'best_looking':
queryset = []
return queryset
@ -139,19 +142,19 @@ class SearchViewSet(Base, Retrieve):
seq = 0
search = self.request.data.get('q', '').lower()
if search:
num_results = NUM_SEARCH_RESULTS
else:
num_results = 100
page = self.request.data.get('page', 0)
queryset = self.get_queryset()
total = len(queryset)
queryset = self.get_queryset()[:num_results]
start = int(page) * NUM_RESULTS - 80
queryset = queryset[max(start,0):start+NUM_RESULTS]
if self.request.user.member.vetted_date:
serializer = serializers.VettedSearchSerializer(queryset, many=True)
else:
serializer = serializers.SearchSerializer(queryset, many=True)
return Response({'seq': seq, 'results': serializer.data})
return Response({'seq': seq, 'results': serializer.data, 'total': total})
class MemberViewSet(Base, Retrieve, Update):
@ -168,11 +171,13 @@ class MemberViewSet(Base, Retrieve, Update):
member = serializer.save()
utils.tally_membership_months(member)
utils.gen_member_forms(member)
utils.gen_search_strings()
def perform_update(self, serializer):
member = serializer.save()
utils.tally_membership_months(member)
utils.gen_member_forms(member)
utils.gen_search_strings()
@action(detail=True, methods=['post'])
def pause(self, request, pk=None):

@ -16,11 +16,12 @@ const memberSorts = {
//newest_overall: 'Newest Overall',
oldest_active: 'Oldest',
//oldest_overall: 'Oldest Overall',
recently_inactive: 'Inactive',
recently_inactive: 'Recently Inactive',
is_director: 'Directors',
is_instructor: 'Instructors',
due: 'Due',
overdue: 'Overdue',
everyone: 'Everyone',
};
export function MembersDropdown(props) {
@ -65,37 +66,49 @@ export function MembersDropdown(props) {
);
};
let numShowCache = 20;
let responseCache = false;
let pageCache = 0;
let sortCache = '';
let searchCache = '';
const loadMoreStrings = [
'Load More',
'Load EVEN More',
'Load WAY More',
'Why did you stop? LOAD MORE!',
'GIVE ME MORE NAMES!!',
'Shower me with names, baby',
'I don\'t care about the poor server, MORE NAMES!',
'Names make me hotter than two rats in a wool sock',
'Holy shit, I can\'t get enough names',
'I don\'t have anything better to do than LOAD NAMES!',
'I need names because I love N̶a̸M̸E̵S̴ it\'s not to late to stop but I can\'t because it feels so good god help me',
'The One who loads the names will liquify the NERVES of the sentient whilst I o̴̭̐b̴̙̾s̷̺͝ē̶̟r̷̦̓v̸͚̐ę̸̈́ ̷̞̒t̸͘ͅh̴͂͜e̵̜̕i̶̾͜r̷̃͜ ̵̹͊Ḷ̷͝Ȍ̸͚Ä̶̘́D̴̰́I̸̧̚N̵͖̎G̷̣͒',
'The Song of Names will will e̶̟̤͋x̷̜̀͘͜t̴̳̀i̸̪͑̇n̷̘̍g̵̥̗̓ṳ̴̑̈́i̷͚̿s̸̨̪̓ḣ̶̡̓ ̷̲͊ṫ̴̫h̸̙͕͗ḛ̸̡̃̈́ ̷̘̫̉̏v̸̧̟͗̕o̴͕̾͜i̷̢͛̿ͅc̴͕̥̈́̂ȅ̵͕s̶̹͋̀ ̶̰́͜͠ǒ̷̰̯f̵̛̥̊ ̸̟̟̒͝m̸̯̀̂o̶̝͛̌͜r̸̞̀ṫ̴̥͗ä̶̢́l̶̯̄͘ ̵̫̈́m̷̦̑̂ą̶͕͝ṋ̴̎͝ from the sphere I can see it can you see it it is beautiful',
'The final suffering of T̷̯̂͝H̴̰̏̉Ḛ̸̀̓ ̷̟̒ͅN̷̠̾Ą̵̟̈́M̶̡̾͝E̸̥̟̐͐S̸̖̍ are lies all is lost the pony he come h̷̲̺͂̾͒̔͝ḙ̶̻͒͠ ̷̙̘͈̬̰̽̽̈́̒͘c̵͎̺̞̰͝ơ̷͚̱̺̰̺͐̏͑͠m̴̖̰̓̈͝ĕ̷̜s̶̛̹̤̦͉̓͝ the í̵̠̞̙̦̱̠̅̊͒̌͊̓͠͠c̴̻̺̙͕̲͚͔̩̥͑ḩ̷̦̰̠̯̳̖̘́̉̾̾͠o̴͈̯̟̣̲͙̦̖̖͍̞̞̻̎͐̊͊̇͋̒͛̅͆̌͂̈̕r̷̡̝̲̜͇͉̣̹̖͕̻̐̑̉̋͋̉͒͋̍́̒͐͐͘ͅ ̵̳̖͕̩̝̮͈̻̣̤͎̟͓̜̄̿̓̈́p̴̰̝͓̣͍̫̞͓̑͌͊͑̓̂̽͑͝e̶̛̪̜̐̋́̆͊͌̋̄́͘r̶̫̬͈͌̔̽m̶̛̱̣͍͌̈́͋̾̈̀͑̽̋̏̊͋͝ę̶̋̀̈̃͠ą̵̡̣̫̮͙͈͚̞̰̠̥͇̣̽̿̉́̔̒͌̓͌̂̌̕͜͠t̷̯͚̭̮̠̐͋͆́͛̿́̏̆̚ě̶̢̨̩̞ş̸̢͍̱̻͕̪̗̻͖͇̱̳̽̈́̚͠ ̴͉̝̖̤͚̖̩̻̪̒ͅà̸̙̥̩̠̝̪̰͋́̊̓͌́͒̕͝ĺ̵̖̖͚̱͎̤̟̲̺͎͑͋̐̈́̓͂͆̅̈́̎̆̋̇l̸̢̧̟͉̞͇̱͉̙͇͊̏͐͠ͅ',
];
export function Members(props) {
const history = useHistory();
const qs = useLocation().search;
const params = new URLSearchParams(qs);
const sort = params.get('sort') || 'recently_vetted';
const search = params.get('q') || '';
const [response, setResponse] = useState(false);
const [numShow, setNumShow] = useState(numShowCache);
const [response, setResponse] = useState(responseCache);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(pageCache);
const [sort, setSort] = useState(sortCache);
const [search, setSearch] = useState(searchCache);
const [controller, setController] = useState(false);
const { token, user } = props;
const doSearch = (q) => {
console.log('doing search', q);
if (q.length) {
const qs = queryString.stringify({ 'q': q });
history.replace('/members?' + qs);
const makeRequest = ({loadPage, q, sort_key}) => {
let pageNum = 0;
if (loadPage) {
pageNum = page + 1;
setPage(pageNum);
pageCache = pageNum;
} else {
setResponse(false);
history.replace('/members');
setPage(0);
pageCache = 0;
}
};
const handleChange = (event) => {
const q = event.target.value;
doSearch(q);
};
useEffect(() => {
if (controller) {
controller.abort();
}
@ -103,19 +116,57 @@ export function Members(props) {
setController(ctl);
const signal = ctl.signal;
const data = {q: search, sort: sort};
const data = {page: pageNum};
if (q) data.q = q;
if (sort_key) data.sort = sort_key;
requester('/search/', 'POST', token, data, signal)
.then(res => {
setResponse(res);
const r = loadPage ? {...response, results: [...response.results, ...res.results]} : res;
setResponse(r);
responseCache = r;
setLoading(false);
})
.catch(err => {
;
console.log('Aborted.');
});
}, [search, sort]);
}
const loadMore = () => {
setLoading(true);
makeRequest({loadPage: true, q: search, sort_key: sort});
};
const doSort = (sort_key) => {
setSort(sort_key);
sortCache = sort_key;
setSearch('');
searchCache = '';
makeRequest({loadPage: false, sort_key: sort_key});
};
const doSearch = (q) => {
if (q) {
setSearch(q);
searchCache = q;
setSort('');
sortCache = '';
makeRequest({loadPage: false, q: q});
} else {
doSort('recently_vetted');
}
};
const handleChange = (event) => {
const q = event.target.value;
doSearch(q);
};
useEffect(() => {
setResponse(false);
}, [sort]);
if (!responseCache) {
doSort('recently_vetted');
}
}, []);
return (
<Container>
@ -144,7 +195,7 @@ export function Members(props) {
Sort by{' '}
{Object.entries(memberSorts).map((x, i) =>
<>
<Link to={'/members?sort='+x[0]} replace>{x[1]}</Link>
<a href='javascript:void(0)' onClick={() => doSort(x[0])}>{x[1]}</a>
{i < Object.keys(memberSorts).length - 1 && ', '}
</>
)}.
@ -164,9 +215,11 @@ export function Members(props) {
{response ?
<>
<p>{response.total} results:</p>
<Item.Group unstackable divided>
{response.results.length ?
response.results.slice(0, numShow).map((x, i) =>
{!!response.results.length &&
response.results.map((x, i) =>
<Item key={x.member.id} as={Link} to={'/members/'+x.member.id}>
<div className='list-num'>{i+1}</div>
<Item.Image size='tiny' src={x.member.photo_small ? staticUrl + '/' + x.member.photo_small : '/nophoto.png'} />
@ -181,16 +234,11 @@ export function Members(props) {
</Item.Content>
</Item>
)
:
<p>No Results</p>
}
</Item.Group>
{response.results.length > 20 && numShow !== 100 ?
<Button
content='Load More'
onClick={() => {setNumShow(100); numShowCache = 100;}}
/> : ''
{!search && response.total !== response.results.length &&
<Button content={loading ? 'Reticulating splines...' : loadMoreStrings[page]} onClick={loadMore} disabled={loading} />
}
</>
:

Loading…
Cancel
Save