Skip to content

Commit 0236a2c

Browse files
waldyriousCopilot
andcommitted
feat: sync course discovery filters with URL for shareable/bookmarkable search
Read facet filters (language, org, topic, etc.) from URL query params and update the browser address bar after each search or filter change so the URL always reflects the current filters. This allows searches to be shared or bookmarked via URLs like /courses?search_query=foo&language=pt Co-authored-by: Claude Opus 4.6, through GitHub Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7bcf9f8 commit 0236a2c

File tree

2 files changed

+128
-1
lines changed

2 files changed

+128
-1
lines changed

lms/static/js/discovery/discovery_factory.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,23 @@
2121
userLanguage: userLanguage,
2222
userTimezone: userTimezone
2323
};
24-
if (setDefaultFilter && userLanguage) {
24+
// Read facet filters from URL query parameters and apply them.
25+
var urlParams = new URLSearchParams(window.location.search);
26+
urlParams.forEach(function(value, key) {
27+
if (key === 'search_query') {
28+
return; // handled separately via the searchQuery argument
29+
}
30+
if (key in meanings) {
31+
filters.add({
32+
type: key,
33+
query: value,
34+
name: refineSidebar.termName(key, value)
35+
});
36+
}
37+
});
38+
39+
// Apply the default language filter, except when provided via URL parameters.
40+
if (setDefaultFilter && userLanguage && !filters.get('language')) {
2541
filters.add({
2642
type: 'language',
2743
query: userLanguage,
@@ -30,6 +46,20 @@
3046
}
3147
listing = new CoursesListing({model: courseListingModel});
3248

49+
function updateUrl() {
50+
var params = new URLSearchParams();
51+
filters.each(function(filter) {
52+
if (filter.id === 'search_query') {
53+
params.set('search_query', filter.get('query'));
54+
} else {
55+
params.set(filter.id, filter.get('query'));
56+
}
57+
});
58+
var qs = params.toString();
59+
var newUrl = window.location.pathname + (qs ? '?' + qs : '');
60+
history.replaceState(null, '', newUrl);
61+
}
62+
3363
dispatcher.listenTo(form, "search", function (query) {
3464
form.showLoadingIndicator();
3565
if (!query || query.trim() === "") {
@@ -79,6 +109,7 @@
79109
form.hideLoadingIndicator();
80110
listing.render();
81111
refineSidebar.render();
112+
updateUrl();
82113
});
83114

84115
dispatcher.listenTo(search, 'error', function() {

lms/static/js/spec/discovery/discovery_factory_spec.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,100 @@ define([
225225
expect(request.requestBody).toMatch(/language=en/);
226226
});
227227
});
228+
229+
describe('discovery.DiscoveryFactory URL sync', function() {
230+
var originalSearch;
231+
var replaceStateSpy;
232+
233+
function setUrlSearch(search) {
234+
// Override window.location.search for the factory to read
235+
originalSearch = window.location.search;
236+
// Use history.replaceState to set query parameters without navigation
237+
history.replaceState(null, '', window.location.pathname + search);
238+
}
239+
240+
beforeEach(function() {
241+
originalSearch = window.location.search;
242+
replaceStateSpy = spyOn(history, 'replaceState').and.callThrough();
243+
loadFixtures('js/fixtures/discovery.html');
244+
TemplateHelpers.installTemplates([
245+
'templates/discovery/course_card',
246+
'templates/discovery/facet',
247+
'templates/discovery/facet_option',
248+
'templates/discovery/filter',
249+
'templates/discovery/filter_bar'
250+
]);
251+
jasmine.clock().install();
252+
});
253+
254+
afterEach(function() {
255+
jasmine.clock().uninstall();
256+
// Restore original URL
257+
history.replaceState(null, '', window.location.pathname + originalSearch);
258+
});
259+
260+
it('pre-populates filters from URL query params', function() {
261+
setUrlSearch('?modes=honor');
262+
DiscoveryFactory(MEANINGS);
263+
264+
var requests = AjaxHelpers.requests(this);
265+
$('.discovery-submit').trigger('click');
266+
var request = AjaxHelpers.currentRequest(requests);
267+
expect(request.requestBody).toMatch(/modes=honor/);
268+
expect($('.active-filter').length).toBe(1);
269+
});
270+
271+
it('overrides default language filter with URL param', function() {
272+
// Set URL with language filter different from the default
273+
setUrlSearch('?language=hr');
274+
// Call DiscoveryFactory with userLanguage='en' and setDefaultFilter=true
275+
DiscoveryFactory(MEANINGS, '', 'en', 'Europe/Lisbon', true);
276+
277+
var requests = AjaxHelpers.requests(this);
278+
$('.discovery-submit').trigger('click');
279+
var request = AjaxHelpers.currentRequest(requests);
280+
expect(request.requestBody).toMatch(/language=hr/);
281+
expect(request.requestBody).not.toMatch(/language=en/);
282+
});
283+
284+
it('ignores unrecognized URL params', function() {
285+
setUrlSearch('?bogus=value');
286+
DiscoveryFactory(MEANINGS);
287+
288+
var requests = AjaxHelpers.requests(this);
289+
$('.discovery-submit').trigger('click');
290+
var request = AjaxHelpers.currentRequest(requests);
291+
expect(request.requestBody).not.toMatch(/bogus/);
292+
expect($('.active-filter').length).toBe(0);
293+
});
294+
295+
it('updates URL after search completes', function() {
296+
setUrlSearch('');
297+
DiscoveryFactory(MEANINGS);
298+
299+
var requests = AjaxHelpers.requests(this);
300+
$('.discovery-input').val('test');
301+
$('.discovery-submit').trigger('click');
302+
AjaxHelpers.respondWithJson(requests, JSON_RESPONSE);
303+
expect(replaceStateSpy).toHaveBeenCalled();
304+
var lastCall = replaceStateSpy.calls.mostRecent();
305+
expect(lastCall.args[2]).toMatch(/search_query=test/);
306+
});
307+
308+
it('clears URL params when filters are reset', function() {
309+
setUrlSearch('?language=en');
310+
DiscoveryFactory(MEANINGS);
311+
312+
var requests = AjaxHelpers.requests(this);
313+
// Trigger initial search to load results
314+
$('.discovery-submit').trigger('click');
315+
AjaxHelpers.respondWithJson(requests, JSON_RESPONSE);
316+
// Clear all filters
317+
$('#clear-all-filters').trigger('click');
318+
// The clear triggers a new search with empty query
319+
AjaxHelpers.respondWithJson(requests, JSON_RESPONSE);
320+
var lastCall = replaceStateSpy.calls.mostRecent();
321+
expect(lastCall.args[2]).toBe(window.location.pathname);
322+
});
323+
});
228324
});

0 commit comments

Comments
 (0)