Skip to content

Commit a22f92d

Browse files
authored
Merge pull request #1021 from ysangkok/js-browse-and-search
Rewrite search/browse pages
2 parents e7570cc + 400d9af commit a22f92d

File tree

16 files changed

+1308
-469
lines changed

16 files changed

+1308
-469
lines changed

datafiles/static/browse.js

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
const d = document;
2+
3+
const initialParams = new URL(d.location).searchParams;
4+
// This parameter is named 'terms' because it is from before filters were
5+
// introduced. But we will parse it as a normal search string (including filters)
6+
const initialSearchQuery = initialParams.has('terms') ? initialParams.get('terms') : ''
7+
d.querySelector("#searchQuery").value = initialSearchQuery;
8+
9+
class Model {
10+
page = 0
11+
numberOfResults = 0
12+
column = 'default'
13+
direction = 'ascending'
14+
searchQuery = initialSearchQuery
15+
flipDirection() {
16+
if (this.direction === 'ascending') {
17+
return ['ascending', this.direction = 'descending'];
18+
} else {
19+
return ['descending', this.direction = 'ascending'];
20+
}
21+
}
22+
}
23+
24+
const state = new Model();
25+
26+
addEventListener('popstate', async (evt) => {
27+
if (evt.state === null) {
28+
return;
29+
}
30+
state.page = evt.state.page;
31+
state.column = evt.state.column;
32+
state.direction = evt.state.direction;
33+
state.searchQuery = evt.state.searchQuery;
34+
d.querySelector("#searchQuery").value = evt.state.searchQuery;
35+
await refresh();
36+
});
37+
38+
const get = () => new Promise((resolve,reject) => {
39+
const formData = new FormData();
40+
const obj =
41+
{ page: state.page
42+
, sort: {column: state.column, direction: state.direction}
43+
, searchQuery: state.searchQuery
44+
};
45+
formData.append('browseOptions', JSON.stringify(obj));
46+
fetch('/packages/search', {method:'POST', body: formData}).then(async (response) => {
47+
if (!response.ok) {
48+
const el = d.querySelector("#fatalError");
49+
el.style.display = "block";
50+
const err = await response.text();
51+
el.textContent = "Error with Hackage server: " + err;
52+
console.log(obj);
53+
reject(new Error("fetch failed: " + err));
54+
} else {
55+
resolve(response.json());
56+
}
57+
});
58+
});
59+
60+
const createName = (nameDict) => {
61+
const name = d.createElement("td");
62+
const nameLink = d.createElement("a");
63+
nameLink.setAttribute("href", nameDict.uri);
64+
nameLink.appendChild(d.createTextNode(nameDict.display));
65+
name.appendChild(nameLink);
66+
return name;
67+
}
68+
69+
const createSimpleText = (text) => {
70+
const el = d.createElement("td");
71+
el.appendChild(d.createTextNode(text));
72+
return el;
73+
}
74+
75+
// Used with renderUser and renderTag results from backend
76+
const createCommaList = (arr) => {
77+
const ul = d.createElement("ul");
78+
ul.classList.add("commaList");
79+
for (const dict of arr) {
80+
const li = d.createElement("li");
81+
const a = d.createElement("a");
82+
a.setAttribute("href", dict.uri);
83+
a.appendChild(d.createTextNode(dict.display));
84+
li.appendChild(a);
85+
ul.appendChild(li);
86+
}
87+
return ul;
88+
}
89+
90+
const createTags = (tagsArr) => {
91+
const el = d.createElement("td");
92+
if (tagsArr === []) {
93+
return el;
94+
}
95+
el.appendChild(d.createTextNode("("));
96+
const ul = createCommaList(tagsArr);
97+
el.appendChild(ul);
98+
el.appendChild(d.createTextNode(")"));
99+
return el;
100+
};
101+
102+
const createLastUpload = (lastUploadISO8601) => {
103+
const el = d.createElement("td");
104+
const date = lastUploadISO8601.substr(0, "0000-00-00".length);
105+
el.setAttribute("title", new Date(lastUploadISO8601).toLocaleString());
106+
el.classList.add("lastUpload");
107+
el.appendChild(d.createTextNode(date));
108+
return el;
109+
};
110+
111+
const createMaintainers = (maintainersArr) => {
112+
const el = d.createElement("td");
113+
if (maintainersArr === []) {
114+
return el;
115+
}
116+
const ul = createCommaList(maintainersArr);
117+
el.appendChild(ul);
118+
return el;
119+
};
120+
121+
const replaceRows = (response) => {
122+
const l = d.querySelector("#listing");
123+
l.replaceChildren();
124+
for (const row of response.pageContents) {
125+
const tr = d.createElement("tr");
126+
tr.appendChild(createName(row.name));
127+
tr.appendChild(createSimpleText(row.downloads));
128+
tr.appendChild(createSimpleText(row.votes));
129+
tr.appendChild(createSimpleText(row.description));
130+
tr.appendChild(createTags(row.tags));
131+
tr.appendChild(createLastUpload(row.lastUpload));
132+
tr.appendChild(createMaintainers(row.maintainers));
133+
l.appendChild(tr);
134+
}
135+
};
136+
137+
const removeSortIndicator = () => {
138+
// No column is actually visible for the default sort mode,
139+
// so there is nothing to do in that case.
140+
if (state.column !== 'default') {
141+
const columnHeader = d.querySelector("#arrow-" + state.column);
142+
columnHeader.removeAttribute("aria-sort");
143+
const oldClasses = columnHeader.classList;
144+
oldClasses.remove('ascending');
145+
oldClasses.remove('descending');
146+
}
147+
}
148+
149+
export const sort = async (column) => {
150+
if (state.column === column) {
151+
const [oldCls, newCls] = state.flipDirection();
152+
const columnHeader = d.querySelector("#arrow-" + column);
153+
const classes = columnHeader.classList;
154+
classes.toggle(oldCls);
155+
classes.toggle(newCls);
156+
columnHeader.setAttribute("aria-sort", newCls);
157+
} else {
158+
removeSortIndicator();
159+
160+
state.direction = 'ascending';
161+
state.column = column;
162+
163+
// Add sort indicator on new column
164+
const columnHeader = d.querySelector("#arrow-" + column);
165+
columnHeader.classList.add("ascending");
166+
columnHeader.setAttribute("aria-sort", "ascending");
167+
}
168+
state.page = 0;
169+
await refresh();
170+
};
171+
172+
const pageSize = 50; // make sure it is kept in sync with backend
173+
174+
const pageAvailable = (page) => {
175+
if (page < 0) return false;
176+
if (page === 0) return true;
177+
return page * pageSize < state.numberOfResults;
178+
}
179+
180+
const changePage = async (candidate) => {
181+
if (!pageAvailable(candidate)) {
182+
return;
183+
}
184+
state.page = candidate;
185+
history.pushState(state, d.title);
186+
await refresh();
187+
scrollTo(0, d.body.scrollHeight);
188+
};
189+
190+
const createIndexIndicator = () => {
191+
const el = d.createElement("div");
192+
const minIdx = state.page * pageSize + 1;
193+
let maxIdx = (state.page + 1) * pageSize;
194+
maxIdx = Math.min(maxIdx, state.numberOfResults);
195+
let fullMsg;
196+
if (state.numberOfResults === 0) {
197+
fullMsg = "No results found.";
198+
} else {
199+
const entriesText = state.numberOfResults === 1 ? "entry" : "entries";
200+
fullMsg = `Showing ${minIdx} to ${maxIdx} of ${state.numberOfResults} ${entriesText}`;
201+
}
202+
el.appendChild(d.createTextNode(fullMsg));
203+
return el;
204+
};
205+
206+
const refresh = async () => {
207+
const res = await get();
208+
state.numberOfResults = res.numberOfResults;
209+
replaceRows(res);
210+
const container = d.querySelector("#paginatorContainer");
211+
container.replaceChildren();
212+
container.appendChild(createIndexIndicator());
213+
container.appendChild(createPaginator());
214+
if (state.searchQuery.trim() === "") {
215+
d.querySelector("#browseFooter").style.display = "none";
216+
} else {
217+
d.querySelector("#browseFooter").style.display = "block";
218+
const url = new URL(hoogleNoParam);
219+
url.searchParams.set("hoogle", state.searchQuery);
220+
d.querySelector("#hoogleLink").setAttribute("href", url);
221+
}
222+
};
223+
224+
export const submitSearch = async (evt) => {
225+
if (evt) evt.preventDefault();
226+
state.searchQuery = d.querySelector("#searchQuery").value;
227+
removeSortIndicator();
228+
state.column = 'default';
229+
state.direction = 'ascending';
230+
state.page = 0;
231+
232+
const url = new URL(d.location);
233+
url.searchParams.set('terms', state.searchQuery);
234+
history.pushState(state, d.title, url);
235+
236+
await refresh();
237+
};
238+
239+
const createPageLink = (num) => {
240+
const a = d.createElement("a");
241+
if (state.page == num) a.classList.add("current");
242+
a.setAttribute("href", "#");
243+
a.addEventListener('click', (evt) => {
244+
evt.preventDefault();
245+
changePage(num);
246+
});
247+
a.appendChild(d.createTextNode(num + 1));
248+
return a;
249+
};
250+
251+
const createPrevNext = (prevNextNum, cond, txt) => {
252+
const el = d.createElement(cond ? "span" : "a");
253+
el.setAttribute("href", "#");
254+
el.addEventListener('click', (evt) => {
255+
evt.preventDefault();
256+
changePage(prevNextNum);
257+
});
258+
if (cond) el.classList.add("disabled");
259+
el.appendChild(d.createTextNode(txt));
260+
return el;
261+
};
262+
263+
const createEllipsis = () => {
264+
const el = d.createElement("span");
265+
el.innerHTML = "&hellip;";
266+
return el;
267+
};
268+
269+
const createPaginator = () => {
270+
const maxPage = maxAvailablePage(state.numberOfResults);
271+
272+
const pag = d.createElement("div");
273+
pag.classList.add("paginator");
274+
pag.appendChild(createPrevNext(state.page - 1, state.page === 0, "Previous"));
275+
// note that page is zero-indexed
276+
if (maxPage <= 4) {
277+
// No ellipsis
278+
for (let i = 0; i <= maxPage; i++) {
279+
pag.appendChild(createPageLink(i));
280+
}
281+
} else if (state.page <= 3) {
282+
// One ellipsis, at the end
283+
for (let i = 0; i <= 4; i++) {
284+
pag.appendChild(createPageLink(i));
285+
}
286+
pag.appendChild(createEllipsis());
287+
pag.appendChild(createPageLink(maxPage));
288+
} else if (state.page + 3 >= maxPage) {
289+
// One ellipsis, at the start
290+
pag.appendChild(createPageLink(0));
291+
pag.appendChild(createEllipsis());
292+
for (let i = maxPage - 4; i <= maxPage; i++) {
293+
pag.appendChild(createPageLink(i));
294+
}
295+
} else {
296+
// Two ellipses, at both ends
297+
pag.appendChild(createPageLink(0));
298+
pag.appendChild(createEllipsis());
299+
for (let i = state.page - 1; i <= state.page + 1; i++) {
300+
pag.appendChild(createPageLink(i));
301+
}
302+
pag.appendChild(createEllipsis());
303+
pag.appendChild(createPageLink(maxPage));
304+
}
305+
const isNowOnLastPage = state.page === maxPage;
306+
pag.appendChild(createPrevNext(state.page + 1, isNowOnLastPage, "Next"));
307+
308+
return pag;
309+
};
310+
311+
const maxAvailablePage = (numberOfResults) => {
312+
if (numberOfResults === 0) numberOfResults++;
313+
return Math.floor((numberOfResults - 1) / pageSize);
314+
};
315+
316+
const hoogleNoParam = "https://hoogle.haskell.org";
317+
318+
let expanded = false;
319+
320+
export const toggleAdvanced = () => {
321+
if (expanded) {
322+
d.querySelector("#toggleAdvanced").setAttribute("aria-expanded", "false");
323+
d.querySelector("#chevron").innerHTML = "&#x25B8;";
324+
d.querySelector("#advancedForm").style.display = "none";
325+
} else {
326+
d.querySelector("#toggleAdvanced").setAttribute("aria-expanded", "true");
327+
d.querySelector("#chevron").innerHTML = "&#x25BE;";
328+
d.querySelector("#advancedForm").style.display = "block";
329+
}
330+
expanded = !expanded;
331+
};
332+
333+
export const appendDeprecated = async (evt) => {
334+
if (evt) evt.preventDefault();
335+
d.querySelector("#searchQuery").value += " (deprecated:any)";
336+
await submitSearch();
337+
};
338+
339+
const isNonNegativeFloatString = (n) => {
340+
// If there is a decimal separator, digits before it are required.
341+
const parsed = parseFloat(n.match(/^\d+(\.\d+)?$/));
342+
return parsed >= 0;
343+
};
344+
345+
export const validateAgeOfLastUL = () => {
346+
const el = d.querySelector("#advAgeLastUL");
347+
const duration = el.value.trim();
348+
if (duration === ""
349+
|| !(["d", "w", "m", "y"].includes(duration.substr(-1, 1)))
350+
|| !isNonNegativeFloatString(duration.substr(0, duration.length - 1))) {
351+
el.setCustomValidity("Must be positive and end in d(ay), w(eek), m(onth) or y(ear)");
352+
return false;
353+
}
354+
el.setCustomValidity("");
355+
return duration;
356+
};
357+
358+
export const appendAgeOfLastUL = async (evt) => {
359+
if (evt) evt.preventDefault();
360+
const maybeDuration = validateAgeOfLastUL();
361+
if (maybeDuration === false) {
362+
return;
363+
}
364+
const duration = maybeDuration;
365+
d.querySelector("#searchQuery").value += ` (ageOfLastUpload < ${duration})`;
366+
await submitSearch();
367+
};
368+
369+
export const validateTag = () => {
370+
const el = d.querySelector("#advTag");
371+
const tag = el.value.trim();
372+
if (tag === "" || !(/^[a-z0-9]+$/i.test(tag))) {
373+
el.setCustomValidity("Tag cannot be empty and must be alphanumeric and ASCII");
374+
return false;
375+
}
376+
el.setCustomValidity("");
377+
return tag;
378+
}
379+
380+
export const appendTag = async (evt) => {
381+
if (evt) evt.preventDefault();
382+
const maybeTag = validateTag();
383+
if (maybeTag === false) {
384+
return;
385+
}
386+
const tag = maybeTag;
387+
d.querySelector("#searchQuery").value += ` (tag:${tag})`;
388+
await submitSearch();
389+
};
390+
391+
export const appendRating = async (evt) => {
392+
if (evt) evt.preventDefault();
393+
const rating = d.querySelector("#advRatingSlider").value;
394+
d.querySelector("#searchQuery").value += ` (rating >= ${rating})`;
395+
await submitSearch();
396+
};
397+
398+
await refresh();

0 commit comments

Comments
 (0)