@internetarchive/collection-browser 0.3.1-alpha.3 → 0.3.2-alpha.1

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.
Files changed (30) hide show
  1. package/dist/src/collection-browser.d.ts +6 -0
  2. package/dist/src/collection-browser.js +346 -341
  3. package/dist/src/collection-browser.js.map +1 -1
  4. package/dist/src/collection-facets/facets-template.js +150 -150
  5. package/dist/src/collection-facets/facets-template.js.map +1 -1
  6. package/dist/src/collection-facets/more-facets-content.js +134 -134
  7. package/dist/src/collection-facets/more-facets-content.js.map +1 -1
  8. package/dist/src/collection-facets.d.ts +2 -0
  9. package/dist/src/collection-facets.js +158 -147
  10. package/dist/src/collection-facets.js.map +1 -1
  11. package/dist/src/models.js.map +1 -1
  12. package/dist/src/restoration-state-handler.js.map +1 -1
  13. package/dist/src/tiles/list/tile-list.js +204 -204
  14. package/dist/src/tiles/list/tile-list.js.map +1 -1
  15. package/dist/src/utils/format-count.js.map +1 -1
  16. package/dist/test/collection-browser.test.js +26 -26
  17. package/dist/test/collection-browser.test.js.map +1 -1
  18. package/dist/test/collection-facets.test.js +2 -2
  19. package/dist/test/collection-facets.test.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/collection-browser.ts +1539 -1530
  22. package/src/collection-facets/facets-template.ts +294 -294
  23. package/src/collection-facets/more-facets-content.ts +518 -518
  24. package/src/collection-facets.ts +582 -569
  25. package/src/models.ts +216 -216
  26. package/src/restoration-state-handler.ts +302 -302
  27. package/src/tiles/list/tile-list.ts +509 -509
  28. package/src/utils/format-count.ts +96 -96
  29. package/test/collection-browser.test.ts +490 -490
  30. package/test/collection-facets.test.ts +510 -510
@@ -1,1530 +1,1539 @@
1
- /* eslint-disable import/no-duplicates */
2
- import {
3
- html,
4
- css,
5
- LitElement,
6
- PropertyValues,
7
- TemplateResult,
8
- nothing,
9
- } from 'lit';
10
- import { customElement, property, query, state } from 'lit/decorators.js';
11
- import { ifDefined } from 'lit/directives/if-defined.js';
12
-
13
- import type { AnalyticsManagerInterface } from '@internetarchive/analytics-manager';
14
- import type {
15
- InfiniteScroller,
16
- InfiniteScrollerCellProviderInterface,
17
- } from '@internetarchive/infinite-scroller';
18
- import {
19
- Aggregation,
20
- SearchParams,
21
- SearchResult,
22
- SearchServiceInterface,
23
- SearchType,
24
- SortDirection,
25
- SortParam,
26
- } from '@internetarchive/search-service';
27
- import type {
28
- SharedResizeObserverInterface,
29
- SharedResizeObserverResizeHandlerInterface,
30
- } from '@internetarchive/shared-resize-observer';
31
- import '@internetarchive/infinite-scroller';
32
- import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
33
- import type { ModalManagerInterface } from '@internetarchive/modal-manager';
34
- import './tiles/tile-dispatcher';
35
- import './tiles/collection-browser-loading-tile';
36
- import './sort-filter-bar/sort-filter-bar';
37
- import './collection-facets';
38
- import './circular-activity-indicator';
39
- import './sort-filter-bar/sort-filter-bar';
40
- import {
41
- SelectedFacets,
42
- SortField,
43
- SortFieldToMetadataField,
44
- CollectionBrowserContext,
45
- defaultSelectedFacets,
46
- TileModel,
47
- CollectionDisplayMode,
48
- FacetOption,
49
- } from './models';
50
- import {
51
- RestorationStateHandlerInterface,
52
- RestorationStateHandler,
53
- RestorationState,
54
- } from './restoration-state-handler';
55
- import chevronIcon from './assets/img/icons/chevron';
56
- import { LanguageCodeHandler } from './language-code-handler/language-code-handler';
57
- import type { PlaceholderType } from './empty-placeholder';
58
- import './empty-placeholder';
59
-
60
- import {
61
- analyticsActions,
62
- analyticsCategories,
63
- } from './utils/analytics-events';
64
-
65
- @customElement('collection-browser')
66
- export class CollectionBrowser
67
- extends LitElement
68
- implements
69
- InfiniteScrollerCellProviderInterface,
70
- SharedResizeObserverResizeHandlerInterface
71
- {
72
- @property({ type: String }) baseNavigationUrl?: string;
73
-
74
- @property({ type: String }) baseImageUrl: string = 'https://archive.org';
75
-
76
- @property({ type: Object }) searchService?: SearchServiceInterface;
77
-
78
- @property({ type: String }) searchType: SearchType = SearchType.METADATA;
79
-
80
- @property({ type: String }) baseQuery?: string;
81
-
82
- @property({ type: String }) displayMode?: CollectionDisplayMode;
83
-
84
- @property({ type: Object }) sortParam: SortParam | null = null;
85
-
86
- @property({ type: String }) selectedSort: SortField = SortField.relevance;
87
-
88
- @property({ type: String }) selectedTitleFilter: string | null = null;
89
-
90
- @property({ type: String }) selectedCreatorFilter: string | null = null;
91
-
92
- @property({ type: String }) sortDirection: SortDirection | null = null;
93
-
94
- @property({ type: String }) dateRangeQueryClause?: string;
95
-
96
- @property({ type: Number }) pageSize = 50;
97
-
98
- @property({ type: Object }) resizeObserver?: SharedResizeObserverInterface;
99
-
100
- @property({ type: String }) titleQuery?: string;
101
-
102
- @property({ type: String }) creatorQuery?: string;
103
-
104
- @property({ type: Number }) currentPage?: number;
105
-
106
- @property({ type: String }) minSelectedDate?: string;
107
-
108
- @property({ type: String }) maxSelectedDate?: string;
109
-
110
- @property({ type: Object }) selectedFacets?: SelectedFacets;
111
-
112
- @property({ type: Boolean }) showHistogramDatePicker = false;
113
-
114
- /** describes where this component is being used */
115
- @property({ type: String, reflect: true }) searchContext: string =
116
- analyticsCategories.default;
117
-
118
- @property({ type: Object })
119
- collectionNameCache?: CollectionNameCacheInterface;
120
-
121
- @property({ type: String }) pageContext: CollectionBrowserContext = 'search';
122
-
123
- @property({ type: Object })
124
- restorationStateHandler: RestorationStateHandlerInterface = new RestorationStateHandler(
125
- {
126
- context: this.pageContext,
127
- }
128
- );
129
-
130
- @property({ type: Number }) mobileBreakpoint = 600;
131
-
132
- @property({ type: Boolean }) loggedIn = false;
133
-
134
- @property({ type: Object }) modalManager?: ModalManagerInterface = undefined;
135
-
136
- /**
137
- * If item management UI active
138
- */
139
- @property({ type: Boolean }) isManageView = false;
140
-
141
- /**
142
- * The page that the consumer wants to load.
143
- */
144
- private initialPageNumber = 1;
145
-
146
- /**
147
- * This the the number of pages that we want to show.
148
- *
149
- * The data isn't necessarily loaded for all of the pages, but this lets us
150
- * know how many cells we should render.
151
- */
152
- @state() private pagesToRender = this.initialPageNumber;
153
-
154
- @state() private searchResultsLoading = false;
155
-
156
- @state() private facetsLoading = false;
157
-
158
- @state() private lendingFacetLoading = false;
159
-
160
- @state() private fullYearAggregationLoading = false;
161
-
162
- @state() private aggregations?: Record<string, Aggregation>;
163
-
164
- @state() private fullYearsHistogramAggregation: Aggregation | undefined;
165
-
166
- @state() private totalResults?: number;
167
-
168
- @state() private mobileView = false;
169
-
170
- @state() private mobileFacetsVisible = false;
171
-
172
- @state() private placeholderType: PlaceholderType = null;
173
-
174
- @query('#content-container') private contentContainer!: HTMLDivElement;
175
-
176
- private languageCodeHandler = new LanguageCodeHandler();
177
-
178
- @property({ type: Object, attribute: false })
179
- private analyticsHandler?: AnalyticsManagerInterface;
180
-
181
- /**
182
- * When we're animated scrolling to the page, we don't want to fetch
183
- * all of the pages as it scrolls so this lets us know if we're scrolling
184
- */
185
- private isScrollingToCell = false;
186
-
187
- /**
188
- * When we've reached the end of the data, stop trying to fetch more
189
- */
190
- private endOfDataReached = false;
191
-
192
- /**
193
- * When page width resizes from desktop to mobile, set true to
194
- * disable expand/collapse transition when loading.
195
- */
196
- private isResizeToMobile = false;
197
-
198
- private placeholderCellTemplate = html`<collection-browser-loading-tile></collection-browser-loading-tile>`;
199
-
200
- private tileModelAtCellIndex(index: number): TileModel | undefined {
201
- const pageNumber = Math.floor(index / this.pageSize) + 1;
202
- const itemIndex = index % this.pageSize;
203
- const model = this.dataSource[pageNumber]?.[itemIndex];
204
- /**
205
- * If we encounter a model we don't have yet and we're not in the middle of an
206
- * automated scroll, fetch the page and just return undefined.
207
- * The datasource will be updated once the page is loaded and the cell will be rendered.
208
- *
209
- * We disable it during the automated scroll since we may fetch pages for cells the
210
- * user may never see.
211
- */
212
- if (!model && !this.isScrollingToCell) {
213
- this.fetchPage(pageNumber);
214
- }
215
- return model;
216
- }
217
-
218
- private get sortFilterQueries(): string {
219
- const queries = [this.titleQuery, this.creatorQuery];
220
- return queries.filter(q => q).join(' AND ');
221
- }
222
-
223
- // this is the total number of tiles we expect if
224
- // the data returned is a full page worth
225
- // this is useful for putting in placeholders for the expected number of tiles
226
- private get estimatedTileCount(): number {
227
- return this.pagesToRender * this.pageSize;
228
- }
229
-
230
- // this is the actual number of tiles in the datasource,
231
- // which is useful for removing excess placeholder tiles
232
- // once we reached the end of the data
233
- private get actualTileCount(): number {
234
- return Object.keys(this.dataSource).reduce(
235
- (acc, page) => acc + this.dataSource[page].length,
236
- 0
237
- );
238
- }
239
-
240
- /**
241
- * The results per page so we can paginate.
242
- *
243
- * This allows us to start in the middle of the search results and
244
- * fetch data before or after the current page. If we don't have a key
245
- * for the previous/next page, we'll fetch the next/previous page to populate it
246
- */
247
- private dataSource: Record<string, TileModel[]> = {};
248
-
249
- @query('infinite-scroller')
250
- private infiniteScroller!: InfiniteScroller;
251
-
252
- /**
253
- * Go to the given page of results
254
- *
255
- * @param pageNumber
256
- */
257
- goToPage(pageNumber: number) {
258
- this.initialPageNumber = pageNumber;
259
- this.pagesToRender = pageNumber;
260
- this.scrollToPage(pageNumber);
261
- }
262
-
263
- clearFilters() {
264
- this.selectedFacets = defaultSelectedFacets;
265
- this.sortParam = null;
266
- this.selectedTitleFilter = null;
267
- this.selectedCreatorFilter = null;
268
- this.titleQuery = undefined;
269
- this.creatorQuery = undefined;
270
- this.selectedSort = SortField.relevance;
271
- this.sortDirection = null;
272
- }
273
-
274
- render() {
275
- this.setPlaceholderType();
276
- return html`
277
- <div
278
- id="content-container"
279
- class=${this.mobileView ? 'mobile' : 'desktop'}
280
- >
281
- ${this.placeholderType
282
- ? this.emptyPlaceholderTemplate
283
- : this.collectionBrowserTemplate}
284
- </div>
285
- `;
286
- }
287
-
288
- private setPlaceholderType() {
289
- this.placeholderType = null;
290
- if (!this.baseQuery) {
291
- this.placeholderType = 'empty-query';
292
- }
293
-
294
- if (!this.searchResultsLoading && this.totalResults === 0) {
295
- this.placeholderType = 'null-result';
296
- }
297
- }
298
-
299
- private get emptyPlaceholderTemplate() {
300
- return html`
301
- <empty-placeholder
302
- .placeholderType=${this.placeholderType}
303
- ?isMobileView=${this.mobileView}
304
- ></empty-placeholder>
305
- `;
306
- }
307
-
308
- private get collectionBrowserTemplate() {
309
- return html`<div
310
- id="left-column"
311
- class="column${this.isResizeToMobile ? ' preload' : ''}"
312
- >
313
- <div id="mobile-header-container">
314
- ${this.mobileView ? this.mobileFacetsTemplate : nothing}
315
- <div id="results-total">
316
- <span id="big-results-count">
317
- ${this.totalResults !== undefined
318
- ? this.totalResults.toLocaleString()
319
- : '-'}
320
- </span>
321
- <span id="big-results-label">
322
- ${this.totalResults === 1 ? 'Result' : 'Results'}
323
- </span>
324
- </div>
325
- </div>
326
- <div
327
- id="facets-container"
328
- class=${!this.mobileView || this.mobileFacetsVisible
329
- ? 'expanded'
330
- : ''}
331
- >
332
- ${this.facetsTemplate}
333
- </div>
334
- </div>
335
- <div id="right-column" class="column">
336
- ${this.searchResultsLoading ? this.loadingTemplate : nothing}
337
- ${this.sortFilterBarTemplate}
338
- ${this.displayMode === `list-compact`
339
- ? this.listHeaderTemplate
340
- : nothing}
341
- ${this.infiniteScrollerTemplate}
342
- </div>`;
343
- }
344
-
345
- private get infiniteScrollerTemplate() {
346
- return html`<infinite-scroller
347
- class="${ifDefined(this.displayMode)}"
348
- .cellProvider=${this}
349
- .placeholderCellTemplate=${this.placeholderCellTemplate}
350
- @scrollThresholdReached=${this.scrollThresholdReached}
351
- @visibleCellsChanged=${this.visibleCellsChanged}
352
- ></infinite-scroller>`;
353
- }
354
-
355
- private get sortFilterBarTemplate() {
356
- return html`
357
- <sort-filter-bar
358
- .selectedSort=${this.selectedSort}
359
- .sortDirection=${this.sortDirection}
360
- .displayMode=${this.displayMode}
361
- .selectedTitleFilter=${this.selectedTitleFilter}
362
- .selectedCreatorFilter=${this.selectedCreatorFilter}
363
- .resizeObserver=${this.resizeObserver}
364
- @sortChanged=${this.userChangedSort}
365
- @displayModeChanged=${this.displayModeChanged}
366
- @titleLetterChanged=${this.titleLetterSelected}
367
- @creatorLetterChanged=${this.creatorLetterSelected}
368
- >
369
- </sort-filter-bar>
370
- `;
371
- }
372
-
373
- private userChangedSort(
374
- e: CustomEvent<{
375
- selectedSort: SortField;
376
- sortDirection: SortDirection | null;
377
- }>
378
- ) {
379
- const { selectedSort, sortDirection } = e.detail;
380
- this.selectedSort = selectedSort;
381
- this.sortDirection = sortDirection;
382
-
383
- if ((this.currentPage ?? 1) > 1) {
384
- this.goToPage(1);
385
- }
386
- this.currentPage = 1;
387
- }
388
-
389
- private sendSortByAnalytics(prevSortDirection: SortDirection | null): void {
390
- const directionCleared = prevSortDirection && !this.sortDirection;
391
-
392
- this.analyticsHandler?.sendEventNoSampling({
393
- category: this.searchContext,
394
- action: analyticsActions.sortBy,
395
- label: `${this.selectedSort}${
396
- this.sortDirection || directionCleared ? `-${this.sortDirection}` : ''
397
- }`,
398
- });
399
- }
400
-
401
- private selectedSortChanged(): void {
402
- if (this.selectedSort === 'relevance' || this.sortDirection === null) {
403
- this.sortParam = null;
404
- return;
405
- }
406
- const sortField = SortFieldToMetadataField[this.selectedSort];
407
-
408
- if (!sortField) return;
409
- this.sortParam = { field: sortField, direction: this.sortDirection };
410
- }
411
-
412
- private displayModeChanged(
413
- e: CustomEvent<{ displayMode: CollectionDisplayMode }>
414
- ) {
415
- this.displayMode = e.detail.displayMode;
416
-
417
- if (this.displayMode) {
418
- this.analyticsHandler?.sendEventNoSampling({
419
- category: this.searchContext,
420
- action: analyticsActions.displayMode,
421
- label: this.displayMode,
422
- });
423
- }
424
- }
425
-
426
- /** Send Analytics when sorting by title's first letter
427
- * labels: 'start-<ToLetter>' | 'clear-<FromLetter>' | '<FromLetter>-<ToLetter>'
428
- * */
429
- private sendFilterByTitleAnalytics(prevSelectedLetter: string | null): void {
430
- if (!prevSelectedLetter && !this.selectedTitleFilter) {
431
- return;
432
- }
433
- const cleared = prevSelectedLetter && this.selectedTitleFilter === null;
434
-
435
- this.analyticsHandler?.sendEventNoSampling({
436
- category: this.searchContext,
437
- action: analyticsActions.filterByTitle,
438
- label: cleared
439
- ? `clear-${prevSelectedLetter}`
440
- : `${prevSelectedLetter || 'start'}-${this.selectedTitleFilter}`,
441
- });
442
- }
443
-
444
- private selectedTitleLetterChanged(): void {
445
- this.titleQuery = this.selectedTitleFilter
446
- ? `firstTitle:${this.selectedTitleFilter}`
447
- : undefined;
448
- }
449
-
450
- /** Send Analytics when filtering by creator's first letter
451
- * labels: 'start-<ToLetter>' | 'clear-<FromLetter>' | '<FromLetter>-<ToLetter>'
452
- * */
453
- private sendFilterByCreatorAnalytics(
454
- prevSelectedLetter: string | null
455
- ): void {
456
- if (!prevSelectedLetter && !this.selectedCreatorFilter) {
457
- return;
458
- }
459
- const cleared = prevSelectedLetter && this.selectedCreatorFilter === null;
460
-
461
- this.analyticsHandler?.sendEventNoSampling({
462
- category: this.searchContext,
463
- action: analyticsActions.filterByCreator,
464
- label: cleared
465
- ? `clear-${prevSelectedLetter}`
466
- : `${prevSelectedLetter || 'start'}-${this.selectedCreatorFilter}`,
467
- });
468
- }
469
-
470
- private selectedCreatorLetterChanged(): void {
471
- this.creatorQuery = this.selectedCreatorFilter
472
- ? `firstCreator:${this.selectedCreatorFilter}`
473
- : undefined;
474
- }
475
-
476
- private titleLetterSelected(e: CustomEvent<{ selectedLetter: string }>) {
477
- this.selectedCreatorFilter = null;
478
- this.selectedTitleFilter = e.detail.selectedLetter;
479
- }
480
-
481
- private creatorLetterSelected(e: CustomEvent<{ selectedLetter: string }>) {
482
- this.selectedTitleFilter = null;
483
- this.selectedCreatorFilter = e.detail.selectedLetter;
484
- }
485
-
486
- private get facetDataLoading(): boolean {
487
- return (
488
- this.facetsLoading ||
489
- this.lendingFacetLoading ||
490
- this.fullYearAggregationLoading
491
- );
492
- }
493
-
494
- private get mobileFacetsTemplate() {
495
- return html`
496
- <div id="mobile-filter-collapse">
497
- <h1
498
- @click=${() => {
499
- this.isResizeToMobile = false;
500
- this.mobileFacetsVisible = !this.mobileFacetsVisible;
501
- }}
502
- @keyup=${() => {
503
- this.isResizeToMobile = false;
504
- this.mobileFacetsVisible = !this.mobileFacetsVisible;
505
- }}
506
- >
507
- <span class="collapser ${this.mobileFacetsVisible ? 'open' : ''}">
508
- ${chevronIcon}
509
- </span>
510
- Filters
511
- </h1>
512
- </div>
513
- `;
514
- }
515
-
516
- private get facetsTemplate() {
517
- return html`
518
- ${this.facetsLoading ? this.loadingTemplate : nothing}
519
- <collection-facets
520
- @facetsChanged=${this.facetsChanged}
521
- @histogramDateRangeUpdated=${this.histogramDateRangeUpdated}
522
- .searchService=${this.searchService}
523
- .searchType=${this.searchType}
524
- .aggregations=${this.aggregations}
525
- .fullYearsHistogramAggregation=${this.fullYearsHistogramAggregation}
526
- .minSelectedDate=${this.minSelectedDate}
527
- .maxSelectedDate=${this.maxSelectedDate}
528
- .selectedFacets=${this.selectedFacets}
529
- .collectionNameCache=${this.collectionNameCache}
530
- .languageCodeHandler=${this.languageCodeHandler}
531
- .showHistogramDatePicker=${this.showHistogramDatePicker}
532
- .fullQuery=${this.fullQuery}
533
- .modalManager=${this.modalManager}
534
- ?collapsableFacets=${this.mobileView}
535
- ?facetsLoading=${this.facetDataLoading}
536
- ?fullYearAggregationLoading=${this.fullYearAggregationLoading}
537
- .onFacetClick=${this.facetClickHandler}
538
- .analyticsHandler=${this.analyticsHandler}
539
- >
540
- </collection-facets>
541
- `;
542
- }
543
-
544
- private get loadingTemplate() {
545
- return html`
546
- <div class="loading-cover">
547
- <circular-activity-indicator></circular-activity-indicator>
548
- </div>
549
- `;
550
- }
551
-
552
- private get listHeaderTemplate() {
553
- return html`
554
- <div id="list-header">
555
- <tile-dispatcher
556
- .tileDisplayMode=${'list-header'}
557
- .resizeObserver=${this.resizeObserver}
558
- .sortParam=${this.sortParam}
559
- .mobileBreakpoint=${this.mobileBreakpoint}
560
- .loggedIn=${this.loggedIn}
561
- >
562
- </tile-dispatcher>
563
- </div>
564
- `;
565
- }
566
-
567
- private get queryDebuggingTemplate() {
568
- return html`
569
- <div>
570
- <ul>
571
- <li>Base Query: ${this.baseQuery}</li>
572
- <li>Facet Query: ${this.facetQuery}</li>
573
- <li>Sort Filter Query: ${this.sortFilterQueries}</li>
574
- <li>Date Range Query: ${this.dateRangeQueryClause}</li>
575
- <li>Sort: ${this.sortParam?.field} ${this.sortParam?.direction}</li>
576
- <li>Full Query: ${this.fullQuery}</li>
577
- </ul>
578
- </div>
579
- `;
580
- }
581
-
582
- private histogramDateRangeUpdated(
583
- e: CustomEvent<{
584
- minDate: string;
585
- maxDate: string;
586
- }>
587
- ) {
588
- const { minDate, maxDate } = e.detail;
589
- this.dateRangeQueryClause = `year:[${minDate} TO ${maxDate}]`;
590
-
591
- if (this.dateRangeQueryClause) {
592
- this.analyticsHandler?.sendEventNoSampling({
593
- category: this.searchContext,
594
- action: analyticsActions.histogramChanged,
595
- label: this.dateRangeQueryClause,
596
- });
597
- }
598
- }
599
-
600
- firstUpdated(): void {
601
- this.setupStateRestorationObserver();
602
- this.restoreState();
603
- }
604
-
605
- updated(changed: PropertyValues) {
606
- if (
607
- changed.has('displayMode') ||
608
- changed.has('baseNavigationUrl') ||
609
- changed.has('baseImageUrl') ||
610
- changed.has('loggedIn')
611
- ) {
612
- this.infiniteScroller?.reload();
613
- }
614
- if (changed.has('baseQuery')) {
615
- this.emitBaseQueryChanged();
616
- }
617
- if (changed.has('currentPage') || changed.has('displayMode')) {
618
- this.persistState();
619
- }
620
- if (
621
- changed.has('baseQuery') ||
622
- changed.has('titleQuery') ||
623
- changed.has('creatorQuery') ||
624
- changed.has('dateRangeQueryClause') ||
625
- changed.has('sortParam') ||
626
- changed.has('selectedFacets') ||
627
- changed.has('searchService')
628
- ) {
629
- this.handleQueryChange();
630
- }
631
- if (changed.has('selectedSort') || changed.has('sortDirection')) {
632
- const prevSortDirection = changed.get('sortDirection') as SortDirection;
633
- this.sendSortByAnalytics(prevSortDirection);
634
- this.selectedSortChanged();
635
- }
636
- if (changed.has('selectedTitleFilter')) {
637
- this.sendFilterByTitleAnalytics(
638
- changed.get('selectedTitleFilter') as string
639
- );
640
- this.selectedTitleLetterChanged();
641
- }
642
- if (changed.has('selectedCreatorFilter')) {
643
- this.sendFilterByCreatorAnalytics(
644
- changed.get('selectedCreatorFilter') as string
645
- );
646
- this.selectedCreatorLetterChanged();
647
- }
648
- if (changed.has('pagesToRender')) {
649
- if (!this.endOfDataReached && this.infiniteScroller) {
650
- this.infiniteScroller.itemCount = this.estimatedTileCount;
651
- }
652
- }
653
- if (changed.has('resizeObserver')) {
654
- const oldObserver = changed.get(
655
- 'resizeObserver'
656
- ) as SharedResizeObserverInterface;
657
- if (oldObserver) this.disconnectResizeObserver(oldObserver);
658
- this.setupResizeObserver();
659
- }
660
- }
661
-
662
- disconnectedCallback(): void {
663
- if (this.resizeObserver) {
664
- this.disconnectResizeObserver(this.resizeObserver);
665
- }
666
- if (this.boundNavigationHandler) {
667
- window.removeEventListener('popstate', this.boundNavigationHandler);
668
- }
669
- }
670
-
671
- handleResize(entry: ResizeObserverEntry): void {
672
- const previousView = this.mobileView;
673
- if (entry.target === this.contentContainer) {
674
- this.mobileView = entry.contentRect.width < this.mobileBreakpoint;
675
- // If changing from desktop to mobile disable transition
676
- if (this.mobileView && !previousView) {
677
- this.isResizeToMobile = true;
678
- }
679
- }
680
- }
681
-
682
- private emitBaseQueryChanged() {
683
- this.dispatchEvent(
684
- new CustomEvent<{ baseQuery?: string }>('baseQueryChanged', {
685
- detail: {
686
- baseQuery: this.baseQuery,
687
- },
688
- })
689
- );
690
- }
691
-
692
- private disconnectResizeObserver(
693
- resizeObserver: SharedResizeObserverInterface
694
- ) {
695
- resizeObserver.removeObserver({
696
- target: this.contentContainer,
697
- handler: this,
698
- });
699
- }
700
-
701
- private setupResizeObserver() {
702
- if (!this.resizeObserver) return;
703
- this.resizeObserver.addObserver({
704
- target: this.contentContainer,
705
- handler: this,
706
- });
707
- }
708
-
709
- /**
710
- * When the visible cells change from the infinite scroller, we want to emit
711
- * which page is currently visible so the consumer can update its UI or the URL
712
- *
713
- * @param e
714
- * @returns
715
- */
716
- private visibleCellsChanged(
717
- e: CustomEvent<{ visibleCellIndices: number[] }>
718
- ) {
719
- if (this.isScrollingToCell) return;
720
- const { visibleCellIndices } = e.detail;
721
- if (visibleCellIndices.length === 0) return;
722
- const lastVisibleCellIndex =
723
- visibleCellIndices[visibleCellIndices.length - 1];
724
- const lastVisibleCellPage =
725
- Math.floor(lastVisibleCellIndex / this.pageSize) + 1;
726
- if (this.currentPage !== lastVisibleCellPage) {
727
- this.currentPage = lastVisibleCellPage;
728
- }
729
- const event = new CustomEvent('visiblePageChanged', {
730
- detail: {
731
- pageNumber: lastVisibleCellPage,
732
- },
733
- });
734
- this.dispatchEvent(event);
735
- }
736
-
737
- // we only want to scroll on the very first query change
738
- // so this keeps track of whether we've already set the initial query
739
- private initialQueryChangeHappened = false;
740
-
741
- private historyPopOccurred = false;
742
-
743
- // this lets us store the query key so we know if it's actually changed or not
744
- private previousQueryKey?: string;
745
-
746
- private async handleQueryChange() {
747
- // only reset if the query has actually changed
748
- if (!this.searchService || this.pageFetchQueryKey === this.previousQueryKey)
749
- return;
750
- this.previousQueryKey = this.pageFetchQueryKey;
751
-
752
- this.dataSource = {};
753
- this.pageFetchesInProgress = {};
754
- this.endOfDataReached = false;
755
- this.pagesToRender = this.initialPageNumber;
756
- if (!this.initialQueryChangeHappened && this.initialPageNumber > 1) {
757
- this.scrollToPage(this.initialPageNumber);
758
- }
759
- this.initialQueryChangeHappened = true;
760
- // if the query changed as part of a window.history pop event, we don't want to
761
- // persist the state because it overwrites the forward history
762
- if (!this.historyPopOccurred) {
763
- this.persistState();
764
- this.historyPopOccurred = false;
765
- }
766
-
767
- // Ensure lending aggregations don't carry over to non-metadata searches
768
- if (this.searchType !== SearchType.METADATA) {
769
- delete this.aggregations?.lending;
770
- }
771
-
772
- await Promise.all([
773
- this.doInitialPageFetch(),
774
- this.fetchFacets(),
775
- this.fetchLendingFacet(),
776
- this.fetchFullYearHistogram(),
777
- ]);
778
- }
779
-
780
- private setupStateRestorationObserver() {
781
- if (this.boundNavigationHandler) return;
782
- this.boundNavigationHandler = this.historyNavigationHandler.bind(this);
783
- // when the user navigates back, we want to update the UI to match the URL
784
- window.addEventListener('popstate', this.boundNavigationHandler);
785
- }
786
-
787
- private boundNavigationHandler?: () => void;
788
-
789
- private historyNavigationHandler() {
790
- this.historyPopOccurred = true;
791
- this.restoreState();
792
- }
793
-
794
- private restoreState() {
795
- const restorationState = this.restorationStateHandler.getRestorationState();
796
- this.displayMode = restorationState.displayMode;
797
- this.selectedSort = restorationState.selectedSort ?? SortField.relevance;
798
- this.sortDirection = restorationState.sortDirection ?? null;
799
- this.selectedTitleFilter = restorationState.selectedTitleFilter ?? null;
800
- this.selectedCreatorFilter = restorationState.selectedCreatorFilter ?? null;
801
- this.selectedFacets = restorationState.selectedFacets;
802
- this.baseQuery = restorationState.baseQuery;
803
- this.titleQuery = restorationState.titleQuery;
804
- this.creatorQuery = restorationState.creatorQuery;
805
- this.dateRangeQueryClause = restorationState.dateRangeQueryClause;
806
- this.sortParam = restorationState.sortParam ?? null;
807
- this.currentPage = restorationState.currentPage ?? 1;
808
- this.minSelectedDate = restorationState.minSelectedDate;
809
- this.maxSelectedDate = restorationState.maxSelectedDate;
810
- if (this.currentPage > 1) {
811
- this.goToPage(this.currentPage);
812
- }
813
- }
814
-
815
- private persistState() {
816
- const restorationState: RestorationState = {
817
- displayMode: this.displayMode,
818
- sortParam: this.sortParam ?? undefined,
819
- selectedSort: this.selectedSort,
820
- sortDirection: this.sortDirection ?? undefined,
821
- selectedFacets: this.selectedFacets ?? defaultSelectedFacets,
822
- baseQuery: this.baseQuery,
823
- currentPage: this.currentPage,
824
- dateRangeQueryClause: this.dateRangeQueryClause,
825
- titleQuery: this.titleQuery,
826
- creatorQuery: this.creatorQuery,
827
- minSelectedDate: this.minSelectedDate,
828
- maxSelectedDate: this.maxSelectedDate,
829
- selectedTitleFilter: this.selectedTitleFilter ?? undefined,
830
- selectedCreatorFilter: this.selectedCreatorFilter ?? undefined,
831
- };
832
- this.restorationStateHandler.persistState(restorationState);
833
- }
834
-
835
- private async doInitialPageFetch() {
836
- this.searchResultsLoading = true;
837
- await this.fetchPage(this.initialPageNumber);
838
- this.searchResultsLoading = false;
839
- }
840
-
841
- private get fullQuery(): string | undefined {
842
- let { fullQueryWithoutDate } = this;
843
- const { dateRangeQueryClause } = this;
844
- if (dateRangeQueryClause) {
845
- fullQueryWithoutDate += ` AND ${dateRangeQueryClause}`;
846
- }
847
- return fullQueryWithoutDate;
848
- }
849
-
850
- private get fullQueryWithoutDate(): string | undefined {
851
- if (!this.baseQuery) return undefined;
852
- let fullQuery = this.baseQuery;
853
- const { facetQuery, sortFilterQueries } = this;
854
- if (facetQuery) {
855
- fullQuery += ` AND ${facetQuery}`;
856
- }
857
- if (sortFilterQueries) {
858
- fullQuery += ` AND ${sortFilterQueries}`;
859
- }
860
- return fullQuery;
861
- }
862
-
863
- /**
864
- * Generates a query string for the given facets
865
- *
866
- * Example: `mediatype:("collection" OR "audio" OR -"etree") AND year:("2000" OR "2001")`
867
- */
868
- private get facetQuery(): string | undefined {
869
- if (!this.selectedFacets) return undefined;
870
- const facetQuery = [];
871
- for (const [facetName, facetValues] of Object.entries(
872
- this.selectedFacets
873
- )) {
874
- const facetEntries = Object.entries(facetValues);
875
- const facetQueryName =
876
- facetName === 'lending' ? 'lending___status' : facetName;
877
- // eslint-disable-next-line no-continue
878
- if (facetEntries.length === 0) continue;
879
- const facetValuesArray: string[] = [];
880
- for (const [key, facetData] of facetEntries) {
881
- const plusMinusPrefix = facetData.state === 'hidden' ? '-' : '';
882
-
883
- if (facetName === 'language') {
884
- const languages =
885
- this.languageCodeHandler.getCodeArrayFromCodeString(key);
886
- for (const language of languages) {
887
- facetValuesArray.push(`${plusMinusPrefix}"${language}"`);
888
- }
889
- } else {
890
- facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
891
- }
892
- }
893
- const valueQuery = facetValuesArray.join(` OR `);
894
- facetQuery.push(`${facetQueryName}:(${valueQuery})`);
895
- }
896
- return facetQuery.length > 0 ? `(${facetQuery.join(' AND ')})` : undefined;
897
- }
898
-
899
- facetsChanged(e: CustomEvent<SelectedFacets>) {
900
- this.selectedFacets = e.detail;
901
- }
902
-
903
- facetClickHandler(
904
- name: FacetOption,
905
- facetSelected: boolean,
906
- negative: boolean
907
- ): void {
908
- if (negative) {
909
- this.analyticsHandler?.sendEventNoSampling({
910
- category: this.searchContext,
911
- action: facetSelected
912
- ? analyticsActions.facetNegativeSelected
913
- : analyticsActions.facetNegativeDeselected,
914
- label: name,
915
- });
916
- } else {
917
- this.analyticsHandler?.sendEventNoSampling({
918
- category: this.searchContext,
919
- action: facetSelected
920
- ? analyticsActions.facetSelected
921
- : analyticsActions.facetDeselected,
922
- label: name,
923
- });
924
- }
925
- }
926
-
927
- private async fetchFacets() {
928
- if (!this.fullQuery) return;
929
-
930
- const params: SearchParams = {
931
- query: this.fullQuery,
932
- rows: 0,
933
- // Note: we don't need an aggregations param to fetch the default aggregations from the PPS.
934
- // The default aggregations for the search_results page type should be what we need here.
935
- };
936
-
937
- this.facetsLoading = true;
938
- const results = await this.searchService?.search(params, this.searchType);
939
- this.facetsLoading = false;
940
-
941
- this.aggregations = {
942
- ...this.aggregations,
943
- ...results?.success?.response.aggregations,
944
- };
945
- }
946
-
947
- private async fetchLendingFacet() {
948
- // Only retrieve lending facet for metadata searches
949
- if (this.searchType !== SearchType.METADATA) return;
950
- if (!this.fullQuery) return;
951
-
952
- const params: SearchParams = {
953
- query: this.fullQuery,
954
- rows: 0,
955
- aggregations: {
956
- simpleParams: ['lending___status'],
957
- },
958
- aggregationsSize: 10, // Larger size to ensure we get all possible statuses
959
- };
960
-
961
- this.lendingFacetLoading = true;
962
- const results = await this.searchService?.search(params, this.searchType);
963
- this.lendingFacetLoading = false;
964
-
965
- this.aggregations = {
966
- ...this.aggregations,
967
- ...results?.success?.response.aggregations,
968
- };
969
- }
970
-
971
- /**
972
- * If we haven't changed the query, we don't need to fetch the full year histogram
973
- *
974
- * @private
975
- * @type {string}
976
- * @memberof CollectionBrowser
977
- */
978
- private previousFullQueryNoDate?: string;
979
-
980
- /**
981
- * The query key is a string that uniquely identifies the current query
982
- * without the date range.
983
- *
984
- * If this doesn't change, we don't need to re-fetch the histogram date range
985
- */
986
- private get fullQueryNoDateKey() {
987
- return `${this.fullQueryWithoutDate}-${this.sortParam?.field}-${this.sortParam?.direction}`;
988
- }
989
-
990
- /**
991
- * This method is similar to fetching the facets above,
992
- * but only fetching the year histogram. There is a subtle difference
993
- * in how you have to fetch the year histogram where you can't use the
994
- * advanced JSON syntax like the other aggregations. It's a special
995
- * case that @ximm put it place.
996
- */
997
- private async fetchFullYearHistogram(): Promise<void> {
998
- const { fullQueryNoDateKey } = this;
999
- if (
1000
- !this.fullQueryWithoutDate ||
1001
- fullQueryNoDateKey === this.previousFullQueryNoDate
1002
- )
1003
- return;
1004
- this.previousFullQueryNoDate = fullQueryNoDateKey;
1005
-
1006
- const aggregations = {
1007
- simpleParams: ['year'],
1008
- };
1009
-
1010
- const params = {
1011
- query: this.fullQueryWithoutDate,
1012
- aggregations,
1013
- rows: 0,
1014
- };
1015
-
1016
- this.fullYearAggregationLoading = true;
1017
- const results = await this.searchService?.search(params, this.searchType);
1018
- this.fullYearAggregationLoading = false;
1019
-
1020
- this.fullYearsHistogramAggregation =
1021
- results?.success?.response?.aggregations?.year_histogram;
1022
- }
1023
-
1024
- private scrollToPage(pageNumber: number) {
1025
- const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
1026
- // without this setTimeout, Safari just pauses until the `fetchPage` is complete
1027
- // then scrolls to the cell
1028
- setTimeout(() => {
1029
- this.isScrollingToCell = true;
1030
- this.infiniteScroller.scrollToCell(cellIndexToScrollTo, true);
1031
- // This timeout is to give the scroll animation time to finish
1032
- // then updating the infinite scroller once we're done scrolling
1033
- // There's no scroll animation completion callback so we're
1034
- // giving it 0.5s to finish.
1035
- setTimeout(() => {
1036
- this.isScrollingToCell = false;
1037
- this.infiniteScroller.reload();
1038
- }, 500);
1039
- }, 0);
1040
- }
1041
-
1042
- /**
1043
- * The query key is a string that uniquely identifies the current query
1044
- *
1045
- * This lets us keep track of queries so we don't persist data that's
1046
- * no longer relevant.
1047
- */
1048
- private get pageFetchQueryKey() {
1049
- return `${this.fullQuery}-${this.sortParam?.field}-${this.sortParam?.direction}`;
1050
- }
1051
-
1052
- // this maps the query to the pages being fetched for that query
1053
- private pageFetchesInProgress: Record<string, Set<number>> = {};
1054
-
1055
- async fetchPage(pageNumber: number) {
1056
- if (!this.fullQuery) return;
1057
-
1058
- // if we already have data, don't fetch again
1059
- if (this.dataSource[pageNumber]) return;
1060
-
1061
- if (this.endOfDataReached) return;
1062
-
1063
- // if a fetch is already in progress for this query and page, don't fetch again
1064
- const { pageFetchQueryKey } = this;
1065
- const pageFetches =
1066
- this.pageFetchesInProgress[pageFetchQueryKey] ?? new Set();
1067
- if (pageFetches.has(pageNumber)) return;
1068
- pageFetches.add(pageNumber);
1069
- this.pageFetchesInProgress[pageFetchQueryKey] = pageFetches;
1070
-
1071
- const sortParams = this.sortParam ? [this.sortParam] : [];
1072
- const params: SearchParams = {
1073
- query: this.fullQuery,
1074
- fields: [
1075
- 'addeddate',
1076
- 'avg_rating',
1077
- 'collections_raw',
1078
- 'creator',
1079
- 'date',
1080
- 'description',
1081
- 'downloads',
1082
- 'identifier',
1083
- 'issue',
1084
- 'item_count',
1085
- 'mediatype',
1086
- 'num_favorites',
1087
- 'num_reviews',
1088
- 'publicdate',
1089
- 'reviewdate',
1090
- 'source',
1091
- 'subject', // topic
1092
- 'title',
1093
- 'volume',
1094
- ],
1095
- page: pageNumber,
1096
- rows: this.pageSize,
1097
- sort: sortParams,
1098
- aggregations: { omit: true },
1099
- };
1100
- const searchResponse = await this.searchService?.search(
1101
- params,
1102
- this.searchType
1103
- );
1104
- const success = searchResponse?.success;
1105
-
1106
- if (!success) return;
1107
-
1108
- this.totalResults = success.response.totalResults;
1109
-
1110
- // this is checking to see if the query has changed since the data was fetched
1111
- // if so, we just want to discard the data since there should be a new query
1112
- // right behind it
1113
- const searchQuery = success.request.clientParameters.user_query;
1114
- const searchSort = success.request.clientParameters.sort;
1115
- let sortChanged = false;
1116
- if (!searchSort || searchSort.length === 0) {
1117
- // if we went from no sort to sort, the sort has changed
1118
- if (this.sortParam) {
1119
- sortChanged = true;
1120
- }
1121
- } else {
1122
- // check if the sort has changed
1123
- for (const sortType of searchSort) {
1124
- const [field, direction] = sortType.split(':');
1125
- if (
1126
- field !== this.sortParam?.field ||
1127
- direction !== this.sortParam?.direction
1128
- ) {
1129
- sortChanged = true;
1130
- break;
1131
- }
1132
- }
1133
- }
1134
- const queryChangedSinceFetch =
1135
- searchQuery !== this.fullQuery || sortChanged;
1136
- if (queryChangedSinceFetch) return;
1137
-
1138
- const { results } = success.response;
1139
- if (results && results.length > 0) {
1140
- this.preloadCollectionNames(results);
1141
- this.updateDataSource(pageNumber, results);
1142
- }
1143
- if (results.length < this.pageSize) {
1144
- this.endOfDataReached = true;
1145
- // this updates the infinite scroller to show the actual size
1146
- if (this.infiniteScroller) {
1147
- this.infiniteScroller.itemCount = this.actualTileCount;
1148
- }
1149
- }
1150
- this.pageFetchesInProgress[pageFetchQueryKey]?.delete(pageNumber);
1151
- this.searchResultsLoading = false;
1152
- }
1153
-
1154
- private preloadCollectionNames(results: SearchResult[]) {
1155
- const collectionIds = results
1156
- .map(result => result.collection?.values)
1157
- .flat();
1158
- const collectionIdsArray = Array.from(new Set(collectionIds)) as string[];
1159
- this.collectionNameCache?.preloadIdentifiers(collectionIdsArray);
1160
- }
1161
-
1162
- /**
1163
- * This is useful for determining whether we need to reload the scroller.
1164
- *
1165
- * When the fetch completes, we need to reload the scroller if the cells for that
1166
- * page are visible, but if the page is not currenlty visible, we don't need to reload
1167
- */
1168
- private get currentVisiblePageNumbers(): number[] {
1169
- const visibleCells = this.infiniteScroller.getVisibleCellIndices();
1170
- const visiblePages = new Set<number>();
1171
- visibleCells.forEach(cellIndex => {
1172
- const visiblePage = Math.floor(cellIndex / this.pageSize) + 1;
1173
- visiblePages.add(visiblePage);
1174
- });
1175
- return Array.from(visiblePages);
1176
- }
1177
-
1178
- /**
1179
- * Update the datasource from the fetch response
1180
- *
1181
- * @param pageNumber
1182
- * @param results
1183
- */
1184
- private updateDataSource(pageNumber: number, results: SearchResult[]) {
1185
- // copy our existing datasource so when we set it below, it gets set
1186
- // instead of modifying the existing dataSource since object changes
1187
- // don't trigger a re-render
1188
- const datasource = { ...this.dataSource };
1189
- const tiles: TileModel[] = [];
1190
- results?.forEach(result => {
1191
- if (!result.identifier) return;
1192
-
1193
- let loginRequired = false;
1194
- let contentWarning = false;
1195
- // Check if item and item in "modifying" collection, setting above flags
1196
- if (
1197
- result.collection?.values.length &&
1198
- result.mediatype?.value !== 'collection'
1199
- ) {
1200
- for (const collection of result.collection?.values ?? []) {
1201
- if (collection === 'loggedin') {
1202
- loginRequired = true;
1203
- if (contentWarning) break;
1204
- }
1205
- if (collection === 'no-preview') {
1206
- contentWarning = true;
1207
- if (loginRequired) break;
1208
- }
1209
- }
1210
- }
1211
-
1212
- tiles.push({
1213
- // TODO the commented items are not currently being returned by the PPS and
1214
- // we will need to have them added to the PPS hit schemas where appropriate
1215
-
1216
- // averageRating: result.avg_rating?.value,
1217
- collections: result.collection?.values ?? [],
1218
- commentCount: result.num_reviews?.value ?? 0,
1219
- creator: result.creator?.value,
1220
- creators: result.creator?.values ?? [],
1221
- // dateAdded: result.addeddate?.value,
1222
- dateArchived: result.publicdate?.value,
1223
- datePublished: result.date?.value,
1224
- dateReviewed: result.reviewdate?.value,
1225
- description: result.description?.value,
1226
- favCount: result.num_favorites?.value ?? 0,
1227
- identifier: result.identifier,
1228
- // issue: result.issue?.value,
1229
- itemCount: 0, // result.item_count?.value ?? 0,
1230
- mediatype: result.mediatype?.value ?? 'data',
1231
- snippets: result.highlight?.values ?? [],
1232
- // source: result.source?.value,
1233
- subjects: result.subject?.values ?? [],
1234
- title: this.etreeTitle(
1235
- result.title?.value,
1236
- result.mediatype?.value,
1237
- result.collection?.values
1238
- ),
1239
- volume: result.volume?.value,
1240
- viewCount: result.downloads?.value ?? 0,
1241
- loginRequired,
1242
- contentWarning,
1243
- });
1244
- });
1245
- datasource[pageNumber] = tiles;
1246
- this.dataSource = datasource;
1247
- const visiblePages = this.currentVisiblePageNumbers;
1248
- const needsReload = visiblePages.includes(pageNumber);
1249
- if (needsReload) {
1250
- this.infiniteScroller.reload();
1251
- }
1252
- }
1253
-
1254
- /*
1255
- * Convert etree titles
1256
- * "[Creator] Live at [Place] on [Date]" => "[Date]: [Place]"
1257
- *
1258
- * Todo: Check collection(s) for etree, need to get as array.
1259
- * Current search-service only returns first collection as string.
1260
- */
1261
- private etreeTitle(
1262
- title: string | undefined,
1263
- mediatype: string | undefined,
1264
- collections: string[] | undefined
1265
- ): string {
1266
- if (mediatype === 'etree' || collections?.includes('etree')) {
1267
- const regex = /^(.*) Live at (.*) on (\d\d\d\d-\d\d-\d\d)$/;
1268
- const newTitle = title?.replace(regex, '$3: $2');
1269
- if (newTitle) {
1270
- return `${newTitle}`;
1271
- }
1272
- }
1273
- return title ?? '';
1274
- }
1275
-
1276
- /**
1277
- * Callback when a result is selected
1278
- */
1279
- resultSelected(event: CustomEvent<TileModel>): void {
1280
- this.analyticsHandler?.sendEventNoSampling({
1281
- category: this.searchContext,
1282
- action: analyticsActions.resultSelected,
1283
- label: event.detail.mediatype,
1284
- });
1285
-
1286
- this.analyticsHandler?.sendEventNoSampling({
1287
- category: this.searchContext,
1288
- action: analyticsActions.resultSelected,
1289
- label: `page-${this.currentPage}`,
1290
- });
1291
- }
1292
-
1293
- cellForIndex(index: number): TemplateResult | undefined {
1294
- const model = this.tileModelAtCellIndex(index);
1295
- if (!model) return undefined;
1296
-
1297
- return html`
1298
- <tile-dispatcher
1299
- .baseNavigationUrl=${this.baseNavigationUrl}
1300
- .baseImageUrl=${this.baseImageUrl}
1301
- .model=${model}
1302
- .tileDisplayMode=${this.displayMode}
1303
- .resizeObserver=${this.resizeObserver}
1304
- .collectionNameCache=${this.collectionNameCache}
1305
- .sortParam=${this.sortParam}
1306
- .mobileBreakpoint=${this.mobileBreakpoint}
1307
- .loggedIn=${this.loggedIn}
1308
- @resultSelected=${(e: CustomEvent) => this.resultSelected(e)}
1309
- >
1310
- </tile-dispatcher>
1311
- `;
1312
- }
1313
-
1314
- /**
1315
- * When the user scrolls near to the bottom of the page, fetch the next page
1316
- * increase the number of pages to render and start fetching data for the new page
1317
- */
1318
- private scrollThresholdReached() {
1319
- this.pagesToRender += 1;
1320
- this.fetchPage(this.pagesToRender);
1321
- }
1322
-
1323
- static styles = css`
1324
- :host {
1325
- display: block;
1326
- }
1327
-
1328
- /**
1329
- * When page width resizes from desktop to mobile, use this class to
1330
- * disable expand/collapse transition when loading.
1331
- */
1332
- .preload * {
1333
- transition: none !important;
1334
- -webkit-transition: none !important;
1335
- -moz-transition: none !important;
1336
- -ms-transition: none !important;
1337
- -o-transition: none !important;
1338
- }
1339
-
1340
- #content-container {
1341
- display: flex;
1342
- }
1343
-
1344
- .collapser {
1345
- display: inline-block;
1346
- }
1347
-
1348
- .collapser svg {
1349
- width: 10px;
1350
- height: 10px;
1351
- transition: transform 0.2s ease-out;
1352
- }
1353
-
1354
- .collapser.open svg {
1355
- transform: rotate(90deg);
1356
- }
1357
-
1358
- #mobile-filter-collapse h1 {
1359
- cursor: pointer;
1360
- }
1361
-
1362
- #content-container.mobile {
1363
- display: block;
1364
- }
1365
-
1366
- .column {
1367
- padding-top: 2rem;
1368
- }
1369
-
1370
- #right-column {
1371
- flex: 1;
1372
- position: relative;
1373
- border-left: 1px solid rgb(232, 232, 232);
1374
- padding-left: 1rem;
1375
- }
1376
-
1377
- .mobile #right-column {
1378
- border-left: none;
1379
- padding: 0;
1380
- }
1381
-
1382
- #left-column {
1383
- width: 18rem;
1384
- min-width: 18rem; /* Prevents Safari from shrinking col at first draw */
1385
- padding-right: 12px;
1386
- padding-right: 1rem;
1387
- }
1388
-
1389
- .desktop #left-column::-webkit-scrollbar {
1390
- display: none;
1391
- }
1392
-
1393
- .mobile #left-column {
1394
- width: 100%;
1395
- padding: 0;
1396
- }
1397
-
1398
- .desktop #left-column {
1399
- top: 0;
1400
- position: sticky;
1401
- max-height: 100vh;
1402
- overflow: scroll;
1403
- -ms-overflow-style: none; /* hide scrollbar IE and Edge */
1404
- scrollbar-width: none; /* hide scrollbar Firefox */
1405
- }
1406
-
1407
- #mobile-header-container {
1408
- display: flex;
1409
- justify-content: space-between;
1410
- }
1411
-
1412
- #facets-container {
1413
- position: relative;
1414
- max-height: 0;
1415
- transition: max-height 0.2s ease-in-out;
1416
- z-index: 1;
1417
- padding-bottom: 2rem;
1418
- }
1419
-
1420
- .mobile #facets-container {
1421
- overflow: hidden;
1422
- padding-bottom: 0;
1423
- }
1424
-
1425
- #facets-container.expanded {
1426
- max-height: 2000px;
1427
- }
1428
-
1429
- #results-total {
1430
- display: flex;
1431
- align-items: center;
1432
- margin-bottom: 5rem;
1433
- }
1434
-
1435
- .mobile #results-total {
1436
- margin-bottom: 0;
1437
- }
1438
-
1439
- #big-results-count {
1440
- font-size: 2.4rem;
1441
- font-weight: 500;
1442
- margin-right: 5px;
1443
- }
1444
-
1445
- #big-results-label {
1446
- font-size: 1rem;
1447
- font-weight: 200;
1448
- text-transform: uppercase;
1449
- }
1450
-
1451
- #list-header {
1452
- max-height: 4.2rem;
1453
- }
1454
-
1455
- .loading-cover {
1456
- position: absolute;
1457
- top: 0;
1458
- left: 0;
1459
- width: 100%;
1460
- height: 100%;
1461
- display: flex;
1462
- justify-content: center;
1463
- z-index: 1;
1464
- padding-top: 50px;
1465
- }
1466
-
1467
- circular-activity-indicator {
1468
- width: 30px;
1469
- height: 30px;
1470
- }
1471
-
1472
- sort-filter-bar {
1473
- display: block;
1474
- margin-bottom: 4rem;
1475
- }
1476
-
1477
- infinite-scroller {
1478
- display: block;
1479
- --infiniteScrollerRowGap: var(--collectionBrowserRowGap, 1.7rem);
1480
- --infiniteScrollerColGap: var(--collectionBrowserColGap, 1.7rem);
1481
- }
1482
-
1483
- infinite-scroller.list-compact {
1484
- --infiniteScrollerCellMinWidth: var(
1485
- --collectionBrowserCellMinWidth,
1486
- 100%
1487
- );
1488
- --infiniteScrollerCellMinHeight: 34px; /* override infinite scroller component */
1489
- --infiniteScrollerCellMaxHeight: 56px;
1490
- --infiniteScrollerRowGap: 0px;
1491
- }
1492
-
1493
- infinite-scroller.list-detail {
1494
- --infiniteScrollerCellMinWidth: var(
1495
- --collectionBrowserCellMinWidth,
1496
- 100%
1497
- );
1498
- --infiniteScrollerCellMinHeight: var(
1499
- --collectionBrowserCellMinHeight,
1500
- 5rem
1501
- );
1502
- /*
1503
- 30px in spec, compensating for a -4px margin
1504
- to align title with top of item image
1505
- src/tiles/list/tile-list.ts
1506
- */
1507
- --infiniteScrollerRowGap: 34px;
1508
- }
1509
-
1510
- .mobile infinite-scroller.list-detail {
1511
- --infiniteScrollerRowGap: 24px;
1512
- }
1513
-
1514
- infinite-scroller.grid {
1515
- --infiniteScrollerCellMinWidth: var(
1516
- --collectionBrowserCellMinWidth,
1517
- 18rem
1518
- );
1519
- --infiniteScrollerCellMaxWidth: var(--collectionBrowserCellMaxWidth, 1fr);
1520
- --infiniteScrollerCellMinHeight: var(
1521
- --collectionBrowserCellMinHeight,
1522
- 29rem
1523
- );
1524
- --infiniteScrollerCellMaxHeight: var(
1525
- --collectionBrowserCellMaxHeight,
1526
- 29rem
1527
- );
1528
- }
1529
- `;
1530
- }
1
+ /* eslint-disable import/no-duplicates */
2
+ import {
3
+ html,
4
+ css,
5
+ LitElement,
6
+ PropertyValues,
7
+ TemplateResult,
8
+ nothing,
9
+ } from 'lit';
10
+ import { customElement, property, query, state } from 'lit/decorators.js';
11
+ import { ifDefined } from 'lit/directives/if-defined.js';
12
+
13
+ import type { AnalyticsManagerInterface } from '@internetarchive/analytics-manager';
14
+ import type {
15
+ InfiniteScroller,
16
+ InfiniteScrollerCellProviderInterface,
17
+ } from '@internetarchive/infinite-scroller';
18
+ import {
19
+ Aggregation,
20
+ SearchParams,
21
+ SearchResult,
22
+ SearchServiceInterface,
23
+ SearchType,
24
+ SortDirection,
25
+ SortParam,
26
+ } from '@internetarchive/search-service';
27
+ import type {
28
+ SharedResizeObserverInterface,
29
+ SharedResizeObserverResizeHandlerInterface,
30
+ } from '@internetarchive/shared-resize-observer';
31
+ import '@internetarchive/infinite-scroller';
32
+ import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
33
+ import type { ModalManagerInterface } from '@internetarchive/modal-manager';
34
+ import './tiles/tile-dispatcher';
35
+ import './tiles/collection-browser-loading-tile';
36
+ import './sort-filter-bar/sort-filter-bar';
37
+ import './collection-facets';
38
+ import './circular-activity-indicator';
39
+ import './sort-filter-bar/sort-filter-bar';
40
+ import {
41
+ SelectedFacets,
42
+ SortField,
43
+ SortFieldToMetadataField,
44
+ CollectionBrowserContext,
45
+ defaultSelectedFacets,
46
+ TileModel,
47
+ CollectionDisplayMode,
48
+ FacetOption,
49
+ } from './models';
50
+ import {
51
+ RestorationStateHandlerInterface,
52
+ RestorationStateHandler,
53
+ RestorationState,
54
+ } from './restoration-state-handler';
55
+ import chevronIcon from './assets/img/icons/chevron';
56
+ import { LanguageCodeHandler } from './language-code-handler/language-code-handler';
57
+ import type { PlaceholderType } from './empty-placeholder';
58
+ import './empty-placeholder';
59
+
60
+ import {
61
+ analyticsActions,
62
+ analyticsCategories,
63
+ } from './utils/analytics-events';
64
+
65
+ @customElement('collection-browser')
66
+ export class CollectionBrowser
67
+ extends LitElement
68
+ implements
69
+ InfiniteScrollerCellProviderInterface,
70
+ SharedResizeObserverResizeHandlerInterface
71
+ {
72
+ @property({ type: String }) baseNavigationUrl?: string;
73
+
74
+ @property({ type: String }) baseImageUrl: string = 'https://archive.org';
75
+
76
+ @property({ type: Object }) searchService?: SearchServiceInterface;
77
+
78
+ @property({ type: String }) searchType: SearchType = SearchType.METADATA;
79
+
80
+ @property({ type: String }) baseQuery?: string;
81
+
82
+ @property({ type: String }) displayMode?: CollectionDisplayMode;
83
+
84
+ @property({ type: Object }) sortParam: SortParam | null = null;
85
+
86
+ @property({ type: String }) selectedSort: SortField = SortField.relevance;
87
+
88
+ @property({ type: String }) selectedTitleFilter: string | null = null;
89
+
90
+ @property({ type: String }) selectedCreatorFilter: string | null = null;
91
+
92
+ @property({ type: String }) sortDirection: SortDirection | null = null;
93
+
94
+ @property({ type: String }) dateRangeQueryClause?: string;
95
+
96
+ @property({ type: Number }) pageSize = 50;
97
+
98
+ @property({ type: Object }) resizeObserver?: SharedResizeObserverInterface;
99
+
100
+ @property({ type: String }) titleQuery?: string;
101
+
102
+ @property({ type: String }) creatorQuery?: string;
103
+
104
+ @property({ type: Number }) currentPage?: number;
105
+
106
+ @property({ type: String }) minSelectedDate?: string;
107
+
108
+ @property({ type: String }) maxSelectedDate?: string;
109
+
110
+ @property({ type: Object }) selectedFacets?: SelectedFacets;
111
+
112
+ @property({ type: Boolean }) showHistogramDatePicker = false;
113
+
114
+ /** describes where this component is being used */
115
+ @property({ type: String, reflect: true }) searchContext: string =
116
+ analyticsCategories.default;
117
+
118
+ @property({ type: Object })
119
+ collectionNameCache?: CollectionNameCacheInterface;
120
+
121
+ @property({ type: String }) pageContext: CollectionBrowserContext = 'search';
122
+
123
+ @property({ type: Object })
124
+ restorationStateHandler: RestorationStateHandlerInterface = new RestorationStateHandler(
125
+ {
126
+ context: this.pageContext,
127
+ }
128
+ );
129
+
130
+ @property({ type: Number }) mobileBreakpoint = 600;
131
+
132
+ @property({ type: Boolean }) loggedIn = false;
133
+
134
+ @property({ type: Object }) modalManager?: ModalManagerInterface = undefined;
135
+
136
+ /**
137
+ * If item management UI active
138
+ */
139
+ @property({ type: Boolean }) isManageView = false;
140
+
141
+ /**
142
+ * The page that the consumer wants to load.
143
+ */
144
+ private initialPageNumber = 1;
145
+
146
+ /**
147
+ * This the the number of pages that we want to show.
148
+ *
149
+ * The data isn't necessarily loaded for all of the pages, but this lets us
150
+ * know how many cells we should render.
151
+ */
152
+ @state() private pagesToRender = this.initialPageNumber;
153
+
154
+ @state() private searchResultsLoading = false;
155
+
156
+ @state() private facetsLoading = false;
157
+
158
+ @state() private lendingFacetLoading = false;
159
+
160
+ @state() private fullYearAggregationLoading = false;
161
+
162
+ @state() private aggregations?: Record<string, Aggregation>;
163
+
164
+ @state() private fullYearsHistogramAggregation: Aggregation | undefined;
165
+
166
+ /**
167
+ * The search type of the previous search (i.e., the currently displayed
168
+ * search results), which may differ from the one that is currently selected
169
+ * to be used for the next search.
170
+ */
171
+ @state() private previousSearchType?: SearchType;
172
+
173
+ @state() private totalResults?: number;
174
+
175
+ @state() private mobileView = false;
176
+
177
+ @state() private mobileFacetsVisible = false;
178
+
179
+ @state() private placeholderType: PlaceholderType = null;
180
+
181
+ @query('#content-container') private contentContainer!: HTMLDivElement;
182
+
183
+ private languageCodeHandler = new LanguageCodeHandler();
184
+
185
+ @property({ type: Object, attribute: false })
186
+ private analyticsHandler?: AnalyticsManagerInterface;
187
+
188
+ /**
189
+ * When we're animated scrolling to the page, we don't want to fetch
190
+ * all of the pages as it scrolls so this lets us know if we're scrolling
191
+ */
192
+ private isScrollingToCell = false;
193
+
194
+ /**
195
+ * When we've reached the end of the data, stop trying to fetch more
196
+ */
197
+ private endOfDataReached = false;
198
+
199
+ /**
200
+ * When page width resizes from desktop to mobile, set true to
201
+ * disable expand/collapse transition when loading.
202
+ */
203
+ private isResizeToMobile = false;
204
+
205
+ private placeholderCellTemplate = html`<collection-browser-loading-tile></collection-browser-loading-tile>`;
206
+
207
+ private tileModelAtCellIndex(index: number): TileModel | undefined {
208
+ const pageNumber = Math.floor(index / this.pageSize) + 1;
209
+ const itemIndex = index % this.pageSize;
210
+ const model = this.dataSource[pageNumber]?.[itemIndex];
211
+ /**
212
+ * If we encounter a model we don't have yet and we're not in the middle of an
213
+ * automated scroll, fetch the page and just return undefined.
214
+ * The datasource will be updated once the page is loaded and the cell will be rendered.
215
+ *
216
+ * We disable it during the automated scroll since we may fetch pages for cells the
217
+ * user may never see.
218
+ */
219
+ if (!model && !this.isScrollingToCell) {
220
+ this.fetchPage(pageNumber);
221
+ }
222
+ return model;
223
+ }
224
+
225
+ private get sortFilterQueries(): string {
226
+ const queries = [this.titleQuery, this.creatorQuery];
227
+ return queries.filter(q => q).join(' AND ');
228
+ }
229
+
230
+ // this is the total number of tiles we expect if
231
+ // the data returned is a full page worth
232
+ // this is useful for putting in placeholders for the expected number of tiles
233
+ private get estimatedTileCount(): number {
234
+ return this.pagesToRender * this.pageSize;
235
+ }
236
+
237
+ // this is the actual number of tiles in the datasource,
238
+ // which is useful for removing excess placeholder tiles
239
+ // once we reached the end of the data
240
+ private get actualTileCount(): number {
241
+ return Object.keys(this.dataSource).reduce(
242
+ (acc, page) => acc + this.dataSource[page].length,
243
+ 0
244
+ );
245
+ }
246
+
247
+ /**
248
+ * The results per page so we can paginate.
249
+ *
250
+ * This allows us to start in the middle of the search results and
251
+ * fetch data before or after the current page. If we don't have a key
252
+ * for the previous/next page, we'll fetch the next/previous page to populate it
253
+ */
254
+ private dataSource: Record<string, TileModel[]> = {};
255
+
256
+ @query('infinite-scroller')
257
+ private infiniteScroller!: InfiniteScroller;
258
+
259
+ /**
260
+ * Go to the given page of results
261
+ *
262
+ * @param pageNumber
263
+ */
264
+ goToPage(pageNumber: number) {
265
+ this.initialPageNumber = pageNumber;
266
+ this.pagesToRender = pageNumber;
267
+ this.scrollToPage(pageNumber);
268
+ }
269
+
270
+ clearFilters() {
271
+ this.selectedFacets = defaultSelectedFacets;
272
+ this.sortParam = null;
273
+ this.selectedTitleFilter = null;
274
+ this.selectedCreatorFilter = null;
275
+ this.titleQuery = undefined;
276
+ this.creatorQuery = undefined;
277
+ this.selectedSort = SortField.relevance;
278
+ this.sortDirection = null;
279
+ }
280
+
281
+ render() {
282
+ this.setPlaceholderType();
283
+ return html`
284
+ <div
285
+ id="content-container"
286
+ class=${this.mobileView ? 'mobile' : 'desktop'}
287
+ >
288
+ ${this.placeholderType
289
+ ? this.emptyPlaceholderTemplate
290
+ : this.collectionBrowserTemplate}
291
+ </div>
292
+ `;
293
+ }
294
+
295
+ private setPlaceholderType() {
296
+ this.placeholderType = null;
297
+ if (!this.baseQuery) {
298
+ this.placeholderType = 'empty-query';
299
+ }
300
+
301
+ if (!this.searchResultsLoading && this.totalResults === 0) {
302
+ this.placeholderType = 'null-result';
303
+ }
304
+ }
305
+
306
+ private get emptyPlaceholderTemplate() {
307
+ return html`
308
+ <empty-placeholder
309
+ .placeholderType=${this.placeholderType}
310
+ ?isMobileView=${this.mobileView}
311
+ ></empty-placeholder>
312
+ `;
313
+ }
314
+
315
+ private get collectionBrowserTemplate() {
316
+ return html`<div
317
+ id="left-column"
318
+ class="column${this.isResizeToMobile ? ' preload' : ''}"
319
+ >
320
+ <div id="mobile-header-container">
321
+ ${this.mobileView ? this.mobileFacetsTemplate : nothing}
322
+ <div id="results-total">
323
+ <span id="big-results-count">
324
+ ${this.totalResults !== undefined
325
+ ? this.totalResults.toLocaleString()
326
+ : '-'}
327
+ </span>
328
+ <span id="big-results-label">
329
+ ${this.totalResults === 1 ? 'Result' : 'Results'}
330
+ </span>
331
+ </div>
332
+ </div>
333
+ <div
334
+ id="facets-container"
335
+ class=${!this.mobileView || this.mobileFacetsVisible
336
+ ? 'expanded'
337
+ : ''}
338
+ >
339
+ ${this.facetsTemplate}
340
+ </div>
341
+ </div>
342
+ <div id="right-column" class="column">
343
+ ${this.searchResultsLoading ? this.loadingTemplate : nothing}
344
+ ${this.sortFilterBarTemplate}
345
+ ${this.displayMode === `list-compact`
346
+ ? this.listHeaderTemplate
347
+ : nothing}
348
+ ${this.infiniteScrollerTemplate}
349
+ </div>`;
350
+ }
351
+
352
+ private get infiniteScrollerTemplate() {
353
+ return html`<infinite-scroller
354
+ class="${ifDefined(this.displayMode)}"
355
+ .cellProvider=${this}
356
+ .placeholderCellTemplate=${this.placeholderCellTemplate}
357
+ @scrollThresholdReached=${this.scrollThresholdReached}
358
+ @visibleCellsChanged=${this.visibleCellsChanged}
359
+ ></infinite-scroller>`;
360
+ }
361
+
362
+ private get sortFilterBarTemplate() {
363
+ return html`
364
+ <sort-filter-bar
365
+ .selectedSort=${this.selectedSort}
366
+ .sortDirection=${this.sortDirection}
367
+ .displayMode=${this.displayMode}
368
+ .selectedTitleFilter=${this.selectedTitleFilter}
369
+ .selectedCreatorFilter=${this.selectedCreatorFilter}
370
+ .resizeObserver=${this.resizeObserver}
371
+ @sortChanged=${this.userChangedSort}
372
+ @displayModeChanged=${this.displayModeChanged}
373
+ @titleLetterChanged=${this.titleLetterSelected}
374
+ @creatorLetterChanged=${this.creatorLetterSelected}
375
+ >
376
+ </sort-filter-bar>
377
+ `;
378
+ }
379
+
380
+ private userChangedSort(
381
+ e: CustomEvent<{
382
+ selectedSort: SortField;
383
+ sortDirection: SortDirection | null;
384
+ }>
385
+ ) {
386
+ const { selectedSort, sortDirection } = e.detail;
387
+ this.selectedSort = selectedSort;
388
+ this.sortDirection = sortDirection;
389
+
390
+ if ((this.currentPage ?? 1) > 1) {
391
+ this.goToPage(1);
392
+ }
393
+ this.currentPage = 1;
394
+ }
395
+
396
+ private sendSortByAnalytics(prevSortDirection: SortDirection | null): void {
397
+ const directionCleared = prevSortDirection && !this.sortDirection;
398
+
399
+ this.analyticsHandler?.sendEventNoSampling({
400
+ category: this.searchContext,
401
+ action: analyticsActions.sortBy,
402
+ label: `${this.selectedSort}${
403
+ this.sortDirection || directionCleared ? `-${this.sortDirection}` : ''
404
+ }`,
405
+ });
406
+ }
407
+
408
+ private selectedSortChanged(): void {
409
+ if (this.selectedSort === 'relevance' || this.sortDirection === null) {
410
+ this.sortParam = null;
411
+ return;
412
+ }
413
+ const sortField = SortFieldToMetadataField[this.selectedSort];
414
+
415
+ if (!sortField) return;
416
+ this.sortParam = { field: sortField, direction: this.sortDirection };
417
+ }
418
+
419
+ private displayModeChanged(
420
+ e: CustomEvent<{ displayMode: CollectionDisplayMode }>
421
+ ) {
422
+ this.displayMode = e.detail.displayMode;
423
+
424
+ if (this.displayMode) {
425
+ this.analyticsHandler?.sendEventNoSampling({
426
+ category: this.searchContext,
427
+ action: analyticsActions.displayMode,
428
+ label: this.displayMode,
429
+ });
430
+ }
431
+ }
432
+
433
+ /** Send Analytics when sorting by title's first letter
434
+ * labels: 'start-<ToLetter>' | 'clear-<FromLetter>' | '<FromLetter>-<ToLetter>'
435
+ * */
436
+ private sendFilterByTitleAnalytics(prevSelectedLetter: string | null): void {
437
+ if (!prevSelectedLetter && !this.selectedTitleFilter) {
438
+ return;
439
+ }
440
+ const cleared = prevSelectedLetter && this.selectedTitleFilter === null;
441
+
442
+ this.analyticsHandler?.sendEventNoSampling({
443
+ category: this.searchContext,
444
+ action: analyticsActions.filterByTitle,
445
+ label: cleared
446
+ ? `clear-${prevSelectedLetter}`
447
+ : `${prevSelectedLetter || 'start'}-${this.selectedTitleFilter}`,
448
+ });
449
+ }
450
+
451
+ private selectedTitleLetterChanged(): void {
452
+ this.titleQuery = this.selectedTitleFilter
453
+ ? `firstTitle:${this.selectedTitleFilter}`
454
+ : undefined;
455
+ }
456
+
457
+ /** Send Analytics when filtering by creator's first letter
458
+ * labels: 'start-<ToLetter>' | 'clear-<FromLetter>' | '<FromLetter>-<ToLetter>'
459
+ * */
460
+ private sendFilterByCreatorAnalytics(
461
+ prevSelectedLetter: string | null
462
+ ): void {
463
+ if (!prevSelectedLetter && !this.selectedCreatorFilter) {
464
+ return;
465
+ }
466
+ const cleared = prevSelectedLetter && this.selectedCreatorFilter === null;
467
+
468
+ this.analyticsHandler?.sendEventNoSampling({
469
+ category: this.searchContext,
470
+ action: analyticsActions.filterByCreator,
471
+ label: cleared
472
+ ? `clear-${prevSelectedLetter}`
473
+ : `${prevSelectedLetter || 'start'}-${this.selectedCreatorFilter}`,
474
+ });
475
+ }
476
+
477
+ private selectedCreatorLetterChanged(): void {
478
+ this.creatorQuery = this.selectedCreatorFilter
479
+ ? `firstCreator:${this.selectedCreatorFilter}`
480
+ : undefined;
481
+ }
482
+
483
+ private titleLetterSelected(e: CustomEvent<{ selectedLetter: string }>) {
484
+ this.selectedCreatorFilter = null;
485
+ this.selectedTitleFilter = e.detail.selectedLetter;
486
+ }
487
+
488
+ private creatorLetterSelected(e: CustomEvent<{ selectedLetter: string }>) {
489
+ this.selectedTitleFilter = null;
490
+ this.selectedCreatorFilter = e.detail.selectedLetter;
491
+ }
492
+
493
+ private get facetDataLoading(): boolean {
494
+ return (
495
+ this.facetsLoading ||
496
+ this.lendingFacetLoading ||
497
+ this.fullYearAggregationLoading
498
+ );
499
+ }
500
+
501
+ private get mobileFacetsTemplate() {
502
+ return html`
503
+ <div id="mobile-filter-collapse">
504
+ <h1
505
+ @click=${() => {
506
+ this.isResizeToMobile = false;
507
+ this.mobileFacetsVisible = !this.mobileFacetsVisible;
508
+ }}
509
+ @keyup=${() => {
510
+ this.isResizeToMobile = false;
511
+ this.mobileFacetsVisible = !this.mobileFacetsVisible;
512
+ }}
513
+ >
514
+ <span class="collapser ${this.mobileFacetsVisible ? 'open' : ''}">
515
+ ${chevronIcon}
516
+ </span>
517
+ Filters
518
+ </h1>
519
+ </div>
520
+ `;
521
+ }
522
+
523
+ private get facetsTemplate() {
524
+ return html`
525
+ ${this.facetsLoading ? this.loadingTemplate : nothing}
526
+ <collection-facets
527
+ @facetsChanged=${this.facetsChanged}
528
+ @histogramDateRangeUpdated=${this.histogramDateRangeUpdated}
529
+ .searchService=${this.searchService}
530
+ .searchType=${this.searchType}
531
+ .aggregations=${this.aggregations}
532
+ .fullYearsHistogramAggregation=${this.fullYearsHistogramAggregation}
533
+ .previousSearchType=${this.previousSearchType}
534
+ .minSelectedDate=${this.minSelectedDate}
535
+ .maxSelectedDate=${this.maxSelectedDate}
536
+ .selectedFacets=${this.selectedFacets}
537
+ .collectionNameCache=${this.collectionNameCache}
538
+ .languageCodeHandler=${this.languageCodeHandler}
539
+ .showHistogramDatePicker=${this.showHistogramDatePicker}
540
+ .fullQuery=${this.fullQuery}
541
+ .modalManager=${this.modalManager}
542
+ ?collapsableFacets=${this.mobileView}
543
+ ?facetsLoading=${this.facetDataLoading}
544
+ ?fullYearAggregationLoading=${this.fullYearAggregationLoading}
545
+ .onFacetClick=${this.facetClickHandler}
546
+ .analyticsHandler=${this.analyticsHandler}
547
+ >
548
+ </collection-facets>
549
+ `;
550
+ }
551
+
552
+ private get loadingTemplate() {
553
+ return html`
554
+ <div class="loading-cover">
555
+ <circular-activity-indicator></circular-activity-indicator>
556
+ </div>
557
+ `;
558
+ }
559
+
560
+ private get listHeaderTemplate() {
561
+ return html`
562
+ <div id="list-header">
563
+ <tile-dispatcher
564
+ .tileDisplayMode=${'list-header'}
565
+ .resizeObserver=${this.resizeObserver}
566
+ .sortParam=${this.sortParam}
567
+ .mobileBreakpoint=${this.mobileBreakpoint}
568
+ .loggedIn=${this.loggedIn}
569
+ >
570
+ </tile-dispatcher>
571
+ </div>
572
+ `;
573
+ }
574
+
575
+ private get queryDebuggingTemplate() {
576
+ return html`
577
+ <div>
578
+ <ul>
579
+ <li>Base Query: ${this.baseQuery}</li>
580
+ <li>Facet Query: ${this.facetQuery}</li>
581
+ <li>Sort Filter Query: ${this.sortFilterQueries}</li>
582
+ <li>Date Range Query: ${this.dateRangeQueryClause}</li>
583
+ <li>Sort: ${this.sortParam?.field} ${this.sortParam?.direction}</li>
584
+ <li>Full Query: ${this.fullQuery}</li>
585
+ </ul>
586
+ </div>
587
+ `;
588
+ }
589
+
590
+ private histogramDateRangeUpdated(
591
+ e: CustomEvent<{
592
+ minDate: string;
593
+ maxDate: string;
594
+ }>
595
+ ) {
596
+ const { minDate, maxDate } = e.detail;
597
+ this.dateRangeQueryClause = `year:[${minDate} TO ${maxDate}]`;
598
+
599
+ if (this.dateRangeQueryClause) {
600
+ this.analyticsHandler?.sendEventNoSampling({
601
+ category: this.searchContext,
602
+ action: analyticsActions.histogramChanged,
603
+ label: this.dateRangeQueryClause,
604
+ });
605
+ }
606
+ }
607
+
608
+ firstUpdated(): void {
609
+ this.setupStateRestorationObserver();
610
+ this.restoreState();
611
+ }
612
+
613
+ updated(changed: PropertyValues) {
614
+ if (
615
+ changed.has('displayMode') ||
616
+ changed.has('baseNavigationUrl') ||
617
+ changed.has('baseImageUrl') ||
618
+ changed.has('loggedIn')
619
+ ) {
620
+ this.infiniteScroller?.reload();
621
+ }
622
+ if (changed.has('baseQuery')) {
623
+ this.emitBaseQueryChanged();
624
+ }
625
+ if (changed.has('currentPage') || changed.has('displayMode')) {
626
+ this.persistState();
627
+ }
628
+ if (
629
+ changed.has('baseQuery') ||
630
+ changed.has('titleQuery') ||
631
+ changed.has('creatorQuery') ||
632
+ changed.has('dateRangeQueryClause') ||
633
+ changed.has('sortParam') ||
634
+ changed.has('selectedFacets') ||
635
+ changed.has('searchService')
636
+ ) {
637
+ this.handleQueryChange();
638
+ }
639
+ if (changed.has('selectedSort') || changed.has('sortDirection')) {
640
+ const prevSortDirection = changed.get('sortDirection') as SortDirection;
641
+ this.sendSortByAnalytics(prevSortDirection);
642
+ this.selectedSortChanged();
643
+ }
644
+ if (changed.has('selectedTitleFilter')) {
645
+ this.sendFilterByTitleAnalytics(
646
+ changed.get('selectedTitleFilter') as string
647
+ );
648
+ this.selectedTitleLetterChanged();
649
+ }
650
+ if (changed.has('selectedCreatorFilter')) {
651
+ this.sendFilterByCreatorAnalytics(
652
+ changed.get('selectedCreatorFilter') as string
653
+ );
654
+ this.selectedCreatorLetterChanged();
655
+ }
656
+ if (changed.has('pagesToRender')) {
657
+ if (!this.endOfDataReached && this.infiniteScroller) {
658
+ this.infiniteScroller.itemCount = this.estimatedTileCount;
659
+ }
660
+ }
661
+ if (changed.has('resizeObserver')) {
662
+ const oldObserver = changed.get(
663
+ 'resizeObserver'
664
+ ) as SharedResizeObserverInterface;
665
+ if (oldObserver) this.disconnectResizeObserver(oldObserver);
666
+ this.setupResizeObserver();
667
+ }
668
+ }
669
+
670
+ disconnectedCallback(): void {
671
+ if (this.resizeObserver) {
672
+ this.disconnectResizeObserver(this.resizeObserver);
673
+ }
674
+ if (this.boundNavigationHandler) {
675
+ window.removeEventListener('popstate', this.boundNavigationHandler);
676
+ }
677
+ }
678
+
679
+ handleResize(entry: ResizeObserverEntry): void {
680
+ const previousView = this.mobileView;
681
+ if (entry.target === this.contentContainer) {
682
+ this.mobileView = entry.contentRect.width < this.mobileBreakpoint;
683
+ // If changing from desktop to mobile disable transition
684
+ if (this.mobileView && !previousView) {
685
+ this.isResizeToMobile = true;
686
+ }
687
+ }
688
+ }
689
+
690
+ private emitBaseQueryChanged() {
691
+ this.dispatchEvent(
692
+ new CustomEvent<{ baseQuery?: string }>('baseQueryChanged', {
693
+ detail: {
694
+ baseQuery: this.baseQuery,
695
+ },
696
+ })
697
+ );
698
+ }
699
+
700
+ private disconnectResizeObserver(
701
+ resizeObserver: SharedResizeObserverInterface
702
+ ) {
703
+ resizeObserver.removeObserver({
704
+ target: this.contentContainer,
705
+ handler: this,
706
+ });
707
+ }
708
+
709
+ private setupResizeObserver() {
710
+ if (!this.resizeObserver) return;
711
+ this.resizeObserver.addObserver({
712
+ target: this.contentContainer,
713
+ handler: this,
714
+ });
715
+ }
716
+
717
+ /**
718
+ * When the visible cells change from the infinite scroller, we want to emit
719
+ * which page is currently visible so the consumer can update its UI or the URL
720
+ *
721
+ * @param e
722
+ * @returns
723
+ */
724
+ private visibleCellsChanged(
725
+ e: CustomEvent<{ visibleCellIndices: number[] }>
726
+ ) {
727
+ if (this.isScrollingToCell) return;
728
+ const { visibleCellIndices } = e.detail;
729
+ if (visibleCellIndices.length === 0) return;
730
+ const lastVisibleCellIndex =
731
+ visibleCellIndices[visibleCellIndices.length - 1];
732
+ const lastVisibleCellPage =
733
+ Math.floor(lastVisibleCellIndex / this.pageSize) + 1;
734
+ if (this.currentPage !== lastVisibleCellPage) {
735
+ this.currentPage = lastVisibleCellPage;
736
+ }
737
+ const event = new CustomEvent('visiblePageChanged', {
738
+ detail: {
739
+ pageNumber: lastVisibleCellPage,
740
+ },
741
+ });
742
+ this.dispatchEvent(event);
743
+ }
744
+
745
+ // we only want to scroll on the very first query change
746
+ // so this keeps track of whether we've already set the initial query
747
+ private initialQueryChangeHappened = false;
748
+
749
+ private historyPopOccurred = false;
750
+
751
+ // this lets us store the query key so we know if it's actually changed or not
752
+ private previousQueryKey?: string;
753
+
754
+ private async handleQueryChange() {
755
+ // only reset if the query has actually changed
756
+ if (!this.searchService || this.pageFetchQueryKey === this.previousQueryKey)
757
+ return;
758
+ this.previousQueryKey = this.pageFetchQueryKey;
759
+
760
+ this.dataSource = {};
761
+ this.pageFetchesInProgress = {};
762
+ this.endOfDataReached = false;
763
+ this.pagesToRender = this.initialPageNumber;
764
+ if (!this.initialQueryChangeHappened && this.initialPageNumber > 1) {
765
+ this.scrollToPage(this.initialPageNumber);
766
+ }
767
+ this.initialQueryChangeHappened = true;
768
+ // if the query changed as part of a window.history pop event, we don't want to
769
+ // persist the state because it overwrites the forward history
770
+ if (!this.historyPopOccurred) {
771
+ this.persistState();
772
+ this.historyPopOccurred = false;
773
+ }
774
+
775
+ // Ensure lending aggregations don't carry over to non-metadata searches
776
+ if (this.searchType !== SearchType.METADATA) {
777
+ delete this.aggregations?.lending;
778
+ }
779
+
780
+ await Promise.all([
781
+ this.doInitialPageFetch(),
782
+ this.fetchFacets(),
783
+ this.fetchLendingFacet(),
784
+ this.fetchFullYearHistogram(),
785
+ ]);
786
+ }
787
+
788
+ private setupStateRestorationObserver() {
789
+ if (this.boundNavigationHandler) return;
790
+ this.boundNavigationHandler = this.historyNavigationHandler.bind(this);
791
+ // when the user navigates back, we want to update the UI to match the URL
792
+ window.addEventListener('popstate', this.boundNavigationHandler);
793
+ }
794
+
795
+ private boundNavigationHandler?: () => void;
796
+
797
+ private historyNavigationHandler() {
798
+ this.historyPopOccurred = true;
799
+ this.restoreState();
800
+ }
801
+
802
+ private restoreState() {
803
+ const restorationState = this.restorationStateHandler.getRestorationState();
804
+ this.displayMode = restorationState.displayMode;
805
+ this.selectedSort = restorationState.selectedSort ?? SortField.relevance;
806
+ this.sortDirection = restorationState.sortDirection ?? null;
807
+ this.selectedTitleFilter = restorationState.selectedTitleFilter ?? null;
808
+ this.selectedCreatorFilter = restorationState.selectedCreatorFilter ?? null;
809
+ this.selectedFacets = restorationState.selectedFacets;
810
+ this.baseQuery = restorationState.baseQuery;
811
+ this.titleQuery = restorationState.titleQuery;
812
+ this.creatorQuery = restorationState.creatorQuery;
813
+ this.dateRangeQueryClause = restorationState.dateRangeQueryClause;
814
+ this.sortParam = restorationState.sortParam ?? null;
815
+ this.currentPage = restorationState.currentPage ?? 1;
816
+ this.minSelectedDate = restorationState.minSelectedDate;
817
+ this.maxSelectedDate = restorationState.maxSelectedDate;
818
+ if (this.currentPage > 1) {
819
+ this.goToPage(this.currentPage);
820
+ }
821
+ }
822
+
823
+ private persistState() {
824
+ const restorationState: RestorationState = {
825
+ displayMode: this.displayMode,
826
+ sortParam: this.sortParam ?? undefined,
827
+ selectedSort: this.selectedSort,
828
+ sortDirection: this.sortDirection ?? undefined,
829
+ selectedFacets: this.selectedFacets ?? defaultSelectedFacets,
830
+ baseQuery: this.baseQuery,
831
+ currentPage: this.currentPage,
832
+ dateRangeQueryClause: this.dateRangeQueryClause,
833
+ titleQuery: this.titleQuery,
834
+ creatorQuery: this.creatorQuery,
835
+ minSelectedDate: this.minSelectedDate,
836
+ maxSelectedDate: this.maxSelectedDate,
837
+ selectedTitleFilter: this.selectedTitleFilter ?? undefined,
838
+ selectedCreatorFilter: this.selectedCreatorFilter ?? undefined,
839
+ };
840
+ this.restorationStateHandler.persistState(restorationState);
841
+ }
842
+
843
+ private async doInitialPageFetch() {
844
+ this.searchResultsLoading = true;
845
+ await this.fetchPage(this.initialPageNumber);
846
+ this.searchResultsLoading = false;
847
+ }
848
+
849
+ private get fullQuery(): string | undefined {
850
+ let { fullQueryWithoutDate } = this;
851
+ const { dateRangeQueryClause } = this;
852
+ if (dateRangeQueryClause) {
853
+ fullQueryWithoutDate += ` AND ${dateRangeQueryClause}`;
854
+ }
855
+ return fullQueryWithoutDate;
856
+ }
857
+
858
+ private get fullQueryWithoutDate(): string | undefined {
859
+ if (!this.baseQuery) return undefined;
860
+ let fullQuery = this.baseQuery;
861
+ const { facetQuery, sortFilterQueries } = this;
862
+ if (facetQuery) {
863
+ fullQuery += ` AND ${facetQuery}`;
864
+ }
865
+ if (sortFilterQueries) {
866
+ fullQuery += ` AND ${sortFilterQueries}`;
867
+ }
868
+ return fullQuery;
869
+ }
870
+
871
+ /**
872
+ * Generates a query string for the given facets
873
+ *
874
+ * Example: `mediatype:("collection" OR "audio" OR -"etree") AND year:("2000" OR "2001")`
875
+ */
876
+ private get facetQuery(): string | undefined {
877
+ if (!this.selectedFacets) return undefined;
878
+ const facetQuery = [];
879
+ for (const [facetName, facetValues] of Object.entries(
880
+ this.selectedFacets
881
+ )) {
882
+ const facetEntries = Object.entries(facetValues);
883
+ const facetQueryName =
884
+ facetName === 'lending' ? 'lending___status' : facetName;
885
+ // eslint-disable-next-line no-continue
886
+ if (facetEntries.length === 0) continue;
887
+ const facetValuesArray: string[] = [];
888
+ for (const [key, facetData] of facetEntries) {
889
+ const plusMinusPrefix = facetData.state === 'hidden' ? '-' : '';
890
+
891
+ if (facetName === 'language') {
892
+ const languages =
893
+ this.languageCodeHandler.getCodeArrayFromCodeString(key);
894
+ for (const language of languages) {
895
+ facetValuesArray.push(`${plusMinusPrefix}"${language}"`);
896
+ }
897
+ } else {
898
+ facetValuesArray.push(`${plusMinusPrefix}"${key}"`);
899
+ }
900
+ }
901
+ const valueQuery = facetValuesArray.join(` OR `);
902
+ facetQuery.push(`${facetQueryName}:(${valueQuery})`);
903
+ }
904
+ return facetQuery.length > 0 ? `(${facetQuery.join(' AND ')})` : undefined;
905
+ }
906
+
907
+ facetsChanged(e: CustomEvent<SelectedFacets>) {
908
+ this.selectedFacets = e.detail;
909
+ }
910
+
911
+ facetClickHandler(
912
+ name: FacetOption,
913
+ facetSelected: boolean,
914
+ negative: boolean
915
+ ): void {
916
+ if (negative) {
917
+ this.analyticsHandler?.sendEventNoSampling({
918
+ category: this.searchContext,
919
+ action: facetSelected
920
+ ? analyticsActions.facetNegativeSelected
921
+ : analyticsActions.facetNegativeDeselected,
922
+ label: name,
923
+ });
924
+ } else {
925
+ this.analyticsHandler?.sendEventNoSampling({
926
+ category: this.searchContext,
927
+ action: facetSelected
928
+ ? analyticsActions.facetSelected
929
+ : analyticsActions.facetDeselected,
930
+ label: name,
931
+ });
932
+ }
933
+ }
934
+
935
+ private async fetchFacets() {
936
+ if (!this.fullQuery) return;
937
+
938
+ const params: SearchParams = {
939
+ query: this.fullQuery,
940
+ rows: 0,
941
+ // Note: we don't need an aggregations param to fetch the default aggregations from the PPS.
942
+ // The default aggregations for the search_results page type should be what we need here.
943
+ };
944
+
945
+ this.facetsLoading = true;
946
+ this.previousSearchType = this.searchType;
947
+ const results = await this.searchService?.search(params, this.searchType);
948
+ this.facetsLoading = false;
949
+
950
+ this.aggregations = {
951
+ ...this.aggregations,
952
+ ...results?.success?.response.aggregations,
953
+ };
954
+ }
955
+
956
+ private async fetchLendingFacet() {
957
+ // Only retrieve lending facet for metadata searches
958
+ if (this.searchType !== SearchType.METADATA) return;
959
+ if (!this.fullQuery) return;
960
+
961
+ const params: SearchParams = {
962
+ query: this.fullQuery,
963
+ rows: 0,
964
+ aggregations: {
965
+ simpleParams: ['lending___status'],
966
+ },
967
+ aggregationsSize: 10, // Larger size to ensure we get all possible statuses
968
+ };
969
+
970
+ this.lendingFacetLoading = true;
971
+ const results = await this.searchService?.search(params, this.searchType);
972
+ this.lendingFacetLoading = false;
973
+
974
+ this.aggregations = {
975
+ ...this.aggregations,
976
+ ...results?.success?.response.aggregations,
977
+ };
978
+ }
979
+
980
+ /**
981
+ * If we haven't changed the query, we don't need to fetch the full year histogram
982
+ *
983
+ * @private
984
+ * @type {string}
985
+ * @memberof CollectionBrowser
986
+ */
987
+ private previousFullQueryNoDate?: string;
988
+
989
+ /**
990
+ * The query key is a string that uniquely identifies the current query
991
+ * without the date range.
992
+ *
993
+ * If this doesn't change, we don't need to re-fetch the histogram date range
994
+ */
995
+ private get fullQueryNoDateKey() {
996
+ return `${this.fullQueryWithoutDate}-${this.sortParam?.field}-${this.sortParam?.direction}`;
997
+ }
998
+
999
+ /**
1000
+ * This method is similar to fetching the facets above,
1001
+ * but only fetching the year histogram. There is a subtle difference
1002
+ * in how you have to fetch the year histogram where you can't use the
1003
+ * advanced JSON syntax like the other aggregations. It's a special
1004
+ * case that @ximm put it place.
1005
+ */
1006
+ private async fetchFullYearHistogram(): Promise<void> {
1007
+ const { fullQueryNoDateKey } = this;
1008
+ if (
1009
+ !this.fullQueryWithoutDate ||
1010
+ fullQueryNoDateKey === this.previousFullQueryNoDate
1011
+ )
1012
+ return;
1013
+ this.previousFullQueryNoDate = fullQueryNoDateKey;
1014
+
1015
+ const aggregations = {
1016
+ simpleParams: ['year'],
1017
+ };
1018
+
1019
+ const params = {
1020
+ query: this.fullQueryWithoutDate,
1021
+ aggregations,
1022
+ rows: 0,
1023
+ };
1024
+
1025
+ this.fullYearAggregationLoading = true;
1026
+ const results = await this.searchService?.search(params, this.searchType);
1027
+ this.fullYearAggregationLoading = false;
1028
+
1029
+ this.fullYearsHistogramAggregation =
1030
+ results?.success?.response?.aggregations?.year_histogram;
1031
+ }
1032
+
1033
+ private scrollToPage(pageNumber: number) {
1034
+ const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
1035
+ // without this setTimeout, Safari just pauses until the `fetchPage` is complete
1036
+ // then scrolls to the cell
1037
+ setTimeout(() => {
1038
+ this.isScrollingToCell = true;
1039
+ this.infiniteScroller.scrollToCell(cellIndexToScrollTo, true);
1040
+ // This timeout is to give the scroll animation time to finish
1041
+ // then updating the infinite scroller once we're done scrolling
1042
+ // There's no scroll animation completion callback so we're
1043
+ // giving it 0.5s to finish.
1044
+ setTimeout(() => {
1045
+ this.isScrollingToCell = false;
1046
+ this.infiniteScroller.reload();
1047
+ }, 500);
1048
+ }, 0);
1049
+ }
1050
+
1051
+ /**
1052
+ * The query key is a string that uniquely identifies the current query
1053
+ *
1054
+ * This lets us keep track of queries so we don't persist data that's
1055
+ * no longer relevant.
1056
+ */
1057
+ private get pageFetchQueryKey() {
1058
+ return `${this.fullQuery}-${this.sortParam?.field}-${this.sortParam?.direction}`;
1059
+ }
1060
+
1061
+ // this maps the query to the pages being fetched for that query
1062
+ private pageFetchesInProgress: Record<string, Set<number>> = {};
1063
+
1064
+ async fetchPage(pageNumber: number) {
1065
+ if (!this.fullQuery) return;
1066
+
1067
+ // if we already have data, don't fetch again
1068
+ if (this.dataSource[pageNumber]) return;
1069
+
1070
+ if (this.endOfDataReached) return;
1071
+
1072
+ // if a fetch is already in progress for this query and page, don't fetch again
1073
+ const { pageFetchQueryKey } = this;
1074
+ const pageFetches =
1075
+ this.pageFetchesInProgress[pageFetchQueryKey] ?? new Set();
1076
+ if (pageFetches.has(pageNumber)) return;
1077
+ pageFetches.add(pageNumber);
1078
+ this.pageFetchesInProgress[pageFetchQueryKey] = pageFetches;
1079
+
1080
+ const sortParams = this.sortParam ? [this.sortParam] : [];
1081
+ const params: SearchParams = {
1082
+ query: this.fullQuery,
1083
+ fields: [
1084
+ 'addeddate',
1085
+ 'avg_rating',
1086
+ 'collections_raw',
1087
+ 'creator',
1088
+ 'date',
1089
+ 'description',
1090
+ 'downloads',
1091
+ 'identifier',
1092
+ 'issue',
1093
+ 'item_count',
1094
+ 'mediatype',
1095
+ 'num_favorites',
1096
+ 'num_reviews',
1097
+ 'publicdate',
1098
+ 'reviewdate',
1099
+ 'source',
1100
+ 'subject', // topic
1101
+ 'title',
1102
+ 'volume',
1103
+ ],
1104
+ page: pageNumber,
1105
+ rows: this.pageSize,
1106
+ sort: sortParams,
1107
+ aggregations: { omit: true },
1108
+ };
1109
+ const searchResponse = await this.searchService?.search(
1110
+ params,
1111
+ this.searchType
1112
+ );
1113
+ const success = searchResponse?.success;
1114
+
1115
+ if (!success) return;
1116
+
1117
+ this.totalResults = success.response.totalResults;
1118
+
1119
+ // this is checking to see if the query has changed since the data was fetched
1120
+ // if so, we just want to discard the data since there should be a new query
1121
+ // right behind it
1122
+ const searchQuery = success.request.clientParameters.user_query;
1123
+ const searchSort = success.request.clientParameters.sort;
1124
+ let sortChanged = false;
1125
+ if (!searchSort || searchSort.length === 0) {
1126
+ // if we went from no sort to sort, the sort has changed
1127
+ if (this.sortParam) {
1128
+ sortChanged = true;
1129
+ }
1130
+ } else {
1131
+ // check if the sort has changed
1132
+ for (const sortType of searchSort) {
1133
+ const [field, direction] = sortType.split(':');
1134
+ if (
1135
+ field !== this.sortParam?.field ||
1136
+ direction !== this.sortParam?.direction
1137
+ ) {
1138
+ sortChanged = true;
1139
+ break;
1140
+ }
1141
+ }
1142
+ }
1143
+ const queryChangedSinceFetch =
1144
+ searchQuery !== this.fullQuery || sortChanged;
1145
+ if (queryChangedSinceFetch) return;
1146
+
1147
+ const { results } = success.response;
1148
+ if (results && results.length > 0) {
1149
+ this.preloadCollectionNames(results);
1150
+ this.updateDataSource(pageNumber, results);
1151
+ }
1152
+ if (results.length < this.pageSize) {
1153
+ this.endOfDataReached = true;
1154
+ // this updates the infinite scroller to show the actual size
1155
+ if (this.infiniteScroller) {
1156
+ this.infiniteScroller.itemCount = this.actualTileCount;
1157
+ }
1158
+ }
1159
+ this.pageFetchesInProgress[pageFetchQueryKey]?.delete(pageNumber);
1160
+ this.searchResultsLoading = false;
1161
+ }
1162
+
1163
+ private preloadCollectionNames(results: SearchResult[]) {
1164
+ const collectionIds = results
1165
+ .map(result => result.collection?.values)
1166
+ .flat();
1167
+ const collectionIdsArray = Array.from(new Set(collectionIds)) as string[];
1168
+ this.collectionNameCache?.preloadIdentifiers(collectionIdsArray);
1169
+ }
1170
+
1171
+ /**
1172
+ * This is useful for determining whether we need to reload the scroller.
1173
+ *
1174
+ * When the fetch completes, we need to reload the scroller if the cells for that
1175
+ * page are visible, but if the page is not currenlty visible, we don't need to reload
1176
+ */
1177
+ private get currentVisiblePageNumbers(): number[] {
1178
+ const visibleCells = this.infiniteScroller.getVisibleCellIndices();
1179
+ const visiblePages = new Set<number>();
1180
+ visibleCells.forEach(cellIndex => {
1181
+ const visiblePage = Math.floor(cellIndex / this.pageSize) + 1;
1182
+ visiblePages.add(visiblePage);
1183
+ });
1184
+ return Array.from(visiblePages);
1185
+ }
1186
+
1187
+ /**
1188
+ * Update the datasource from the fetch response
1189
+ *
1190
+ * @param pageNumber
1191
+ * @param results
1192
+ */
1193
+ private updateDataSource(pageNumber: number, results: SearchResult[]) {
1194
+ // copy our existing datasource so when we set it below, it gets set
1195
+ // instead of modifying the existing dataSource since object changes
1196
+ // don't trigger a re-render
1197
+ const datasource = { ...this.dataSource };
1198
+ const tiles: TileModel[] = [];
1199
+ results?.forEach(result => {
1200
+ if (!result.identifier) return;
1201
+
1202
+ let loginRequired = false;
1203
+ let contentWarning = false;
1204
+ // Check if item and item in "modifying" collection, setting above flags
1205
+ if (
1206
+ result.collection?.values.length &&
1207
+ result.mediatype?.value !== 'collection'
1208
+ ) {
1209
+ for (const collection of result.collection?.values ?? []) {
1210
+ if (collection === 'loggedin') {
1211
+ loginRequired = true;
1212
+ if (contentWarning) break;
1213
+ }
1214
+ if (collection === 'no-preview') {
1215
+ contentWarning = true;
1216
+ if (loginRequired) break;
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ tiles.push({
1222
+ // TODO the commented items are not currently being returned by the PPS and
1223
+ // we will need to have them added to the PPS hit schemas where appropriate
1224
+
1225
+ // averageRating: result.avg_rating?.value,
1226
+ collections: result.collection?.values ?? [],
1227
+ commentCount: result.num_reviews?.value ?? 0,
1228
+ creator: result.creator?.value,
1229
+ creators: result.creator?.values ?? [],
1230
+ // dateAdded: result.addeddate?.value,
1231
+ dateArchived: result.publicdate?.value,
1232
+ datePublished: result.date?.value,
1233
+ dateReviewed: result.reviewdate?.value,
1234
+ description: result.description?.value,
1235
+ favCount: result.num_favorites?.value ?? 0,
1236
+ identifier: result.identifier,
1237
+ // issue: result.issue?.value,
1238
+ itemCount: 0, // result.item_count?.value ?? 0,
1239
+ mediatype: result.mediatype?.value ?? 'data',
1240
+ snippets: result.highlight?.values ?? [],
1241
+ // source: result.source?.value,
1242
+ subjects: result.subject?.values ?? [],
1243
+ title: this.etreeTitle(
1244
+ result.title?.value,
1245
+ result.mediatype?.value,
1246
+ result.collection?.values
1247
+ ),
1248
+ volume: result.volume?.value,
1249
+ viewCount: result.downloads?.value ?? 0,
1250
+ loginRequired,
1251
+ contentWarning,
1252
+ });
1253
+ });
1254
+ datasource[pageNumber] = tiles;
1255
+ this.dataSource = datasource;
1256
+ const visiblePages = this.currentVisiblePageNumbers;
1257
+ const needsReload = visiblePages.includes(pageNumber);
1258
+ if (needsReload) {
1259
+ this.infiniteScroller.reload();
1260
+ }
1261
+ }
1262
+
1263
+ /*
1264
+ * Convert etree titles
1265
+ * "[Creator] Live at [Place] on [Date]" => "[Date]: [Place]"
1266
+ *
1267
+ * Todo: Check collection(s) for etree, need to get as array.
1268
+ * Current search-service only returns first collection as string.
1269
+ */
1270
+ private etreeTitle(
1271
+ title: string | undefined,
1272
+ mediatype: string | undefined,
1273
+ collections: string[] | undefined
1274
+ ): string {
1275
+ if (mediatype === 'etree' || collections?.includes('etree')) {
1276
+ const regex = /^(.*) Live at (.*) on (\d\d\d\d-\d\d-\d\d)$/;
1277
+ const newTitle = title?.replace(regex, '$3: $2');
1278
+ if (newTitle) {
1279
+ return `${newTitle}`;
1280
+ }
1281
+ }
1282
+ return title ?? '';
1283
+ }
1284
+
1285
+ /**
1286
+ * Callback when a result is selected
1287
+ */
1288
+ resultSelected(event: CustomEvent<TileModel>): void {
1289
+ this.analyticsHandler?.sendEventNoSampling({
1290
+ category: this.searchContext,
1291
+ action: analyticsActions.resultSelected,
1292
+ label: event.detail.mediatype,
1293
+ });
1294
+
1295
+ this.analyticsHandler?.sendEventNoSampling({
1296
+ category: this.searchContext,
1297
+ action: analyticsActions.resultSelected,
1298
+ label: `page-${this.currentPage}`,
1299
+ });
1300
+ }
1301
+
1302
+ cellForIndex(index: number): TemplateResult | undefined {
1303
+ const model = this.tileModelAtCellIndex(index);
1304
+ if (!model) return undefined;
1305
+
1306
+ return html`
1307
+ <tile-dispatcher
1308
+ .baseNavigationUrl=${this.baseNavigationUrl}
1309
+ .baseImageUrl=${this.baseImageUrl}
1310
+ .model=${model}
1311
+ .tileDisplayMode=${this.displayMode}
1312
+ .resizeObserver=${this.resizeObserver}
1313
+ .collectionNameCache=${this.collectionNameCache}
1314
+ .sortParam=${this.sortParam}
1315
+ .mobileBreakpoint=${this.mobileBreakpoint}
1316
+ .loggedIn=${this.loggedIn}
1317
+ @resultSelected=${(e: CustomEvent) => this.resultSelected(e)}
1318
+ >
1319
+ </tile-dispatcher>
1320
+ `;
1321
+ }
1322
+
1323
+ /**
1324
+ * When the user scrolls near to the bottom of the page, fetch the next page
1325
+ * increase the number of pages to render and start fetching data for the new page
1326
+ */
1327
+ private scrollThresholdReached() {
1328
+ this.pagesToRender += 1;
1329
+ this.fetchPage(this.pagesToRender);
1330
+ }
1331
+
1332
+ static styles = css`
1333
+ :host {
1334
+ display: block;
1335
+ }
1336
+
1337
+ /**
1338
+ * When page width resizes from desktop to mobile, use this class to
1339
+ * disable expand/collapse transition when loading.
1340
+ */
1341
+ .preload * {
1342
+ transition: none !important;
1343
+ -webkit-transition: none !important;
1344
+ -moz-transition: none !important;
1345
+ -ms-transition: none !important;
1346
+ -o-transition: none !important;
1347
+ }
1348
+
1349
+ #content-container {
1350
+ display: flex;
1351
+ }
1352
+
1353
+ .collapser {
1354
+ display: inline-block;
1355
+ }
1356
+
1357
+ .collapser svg {
1358
+ width: 10px;
1359
+ height: 10px;
1360
+ transition: transform 0.2s ease-out;
1361
+ }
1362
+
1363
+ .collapser.open svg {
1364
+ transform: rotate(90deg);
1365
+ }
1366
+
1367
+ #mobile-filter-collapse h1 {
1368
+ cursor: pointer;
1369
+ }
1370
+
1371
+ #content-container.mobile {
1372
+ display: block;
1373
+ }
1374
+
1375
+ .column {
1376
+ padding-top: 2rem;
1377
+ }
1378
+
1379
+ #right-column {
1380
+ flex: 1;
1381
+ position: relative;
1382
+ border-left: 1px solid rgb(232, 232, 232);
1383
+ padding-left: 1rem;
1384
+ }
1385
+
1386
+ .mobile #right-column {
1387
+ border-left: none;
1388
+ padding: 0;
1389
+ }
1390
+
1391
+ #left-column {
1392
+ width: 18rem;
1393
+ min-width: 18rem; /* Prevents Safari from shrinking col at first draw */
1394
+ padding-right: 12px;
1395
+ padding-right: 1rem;
1396
+ }
1397
+
1398
+ .desktop #left-column::-webkit-scrollbar {
1399
+ display: none;
1400
+ }
1401
+
1402
+ .mobile #left-column {
1403
+ width: 100%;
1404
+ padding: 0;
1405
+ }
1406
+
1407
+ .desktop #left-column {
1408
+ top: 0;
1409
+ position: sticky;
1410
+ max-height: 100vh;
1411
+ overflow: scroll;
1412
+ -ms-overflow-style: none; /* hide scrollbar IE and Edge */
1413
+ scrollbar-width: none; /* hide scrollbar Firefox */
1414
+ }
1415
+
1416
+ #mobile-header-container {
1417
+ display: flex;
1418
+ justify-content: space-between;
1419
+ }
1420
+
1421
+ #facets-container {
1422
+ position: relative;
1423
+ max-height: 0;
1424
+ transition: max-height 0.2s ease-in-out;
1425
+ z-index: 1;
1426
+ padding-bottom: 2rem;
1427
+ }
1428
+
1429
+ .mobile #facets-container {
1430
+ overflow: hidden;
1431
+ padding-bottom: 0;
1432
+ }
1433
+
1434
+ #facets-container.expanded {
1435
+ max-height: 2000px;
1436
+ }
1437
+
1438
+ #results-total {
1439
+ display: flex;
1440
+ align-items: center;
1441
+ margin-bottom: 5rem;
1442
+ }
1443
+
1444
+ .mobile #results-total {
1445
+ margin-bottom: 0;
1446
+ }
1447
+
1448
+ #big-results-count {
1449
+ font-size: 2.4rem;
1450
+ font-weight: 500;
1451
+ margin-right: 5px;
1452
+ }
1453
+
1454
+ #big-results-label {
1455
+ font-size: 1rem;
1456
+ font-weight: 200;
1457
+ text-transform: uppercase;
1458
+ }
1459
+
1460
+ #list-header {
1461
+ max-height: 4.2rem;
1462
+ }
1463
+
1464
+ .loading-cover {
1465
+ position: absolute;
1466
+ top: 0;
1467
+ left: 0;
1468
+ width: 100%;
1469
+ height: 100%;
1470
+ display: flex;
1471
+ justify-content: center;
1472
+ z-index: 1;
1473
+ padding-top: 50px;
1474
+ }
1475
+
1476
+ circular-activity-indicator {
1477
+ width: 30px;
1478
+ height: 30px;
1479
+ }
1480
+
1481
+ sort-filter-bar {
1482
+ display: block;
1483
+ margin-bottom: 4rem;
1484
+ }
1485
+
1486
+ infinite-scroller {
1487
+ display: block;
1488
+ --infiniteScrollerRowGap: var(--collectionBrowserRowGap, 1.7rem);
1489
+ --infiniteScrollerColGap: var(--collectionBrowserColGap, 1.7rem);
1490
+ }
1491
+
1492
+ infinite-scroller.list-compact {
1493
+ --infiniteScrollerCellMinWidth: var(
1494
+ --collectionBrowserCellMinWidth,
1495
+ 100%
1496
+ );
1497
+ --infiniteScrollerCellMinHeight: 34px; /* override infinite scroller component */
1498
+ --infiniteScrollerCellMaxHeight: 56px;
1499
+ --infiniteScrollerRowGap: 0px;
1500
+ }
1501
+
1502
+ infinite-scroller.list-detail {
1503
+ --infiniteScrollerCellMinWidth: var(
1504
+ --collectionBrowserCellMinWidth,
1505
+ 100%
1506
+ );
1507
+ --infiniteScrollerCellMinHeight: var(
1508
+ --collectionBrowserCellMinHeight,
1509
+ 5rem
1510
+ );
1511
+ /*
1512
+ 30px in spec, compensating for a -4px margin
1513
+ to align title with top of item image
1514
+ src/tiles/list/tile-list.ts
1515
+ */
1516
+ --infiniteScrollerRowGap: 34px;
1517
+ }
1518
+
1519
+ .mobile infinite-scroller.list-detail {
1520
+ --infiniteScrollerRowGap: 24px;
1521
+ }
1522
+
1523
+ infinite-scroller.grid {
1524
+ --infiniteScrollerCellMinWidth: var(
1525
+ --collectionBrowserCellMinWidth,
1526
+ 18rem
1527
+ );
1528
+ --infiniteScrollerCellMaxWidth: var(--collectionBrowserCellMaxWidth, 1fr);
1529
+ --infiniteScrollerCellMinHeight: var(
1530
+ --collectionBrowserCellMinHeight,
1531
+ 29rem
1532
+ );
1533
+ --infiniteScrollerCellMaxHeight: var(
1534
+ --collectionBrowserCellMaxHeight,
1535
+ 29rem
1536
+ );
1537
+ }
1538
+ `;
1539
+ }