From 61e9203282fee6c2076d79c9582063fe96ee44a9 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Wed, 16 Aug 2023 11:25:46 -0400 Subject: [PATCH] Revert registry, preprint and institution discover pages. (#1935) * Revert "[ENG-4573] Registry discover (#1900)" This reverts commit 2ab39e0abb4fdf6413e666829f1fec1d3f98b4ca. * comment out institution discover and preprint routes * skip tests --- app/router.ts | 8 +- .../addon/helpers/get-localized-property.ts | 40 -- .../app/helpers/get-localized-property.js | 1 - .../addon/application/controller.ts | 14 +- .../addon/branded/discover/controller.ts | 42 +- .../addon/branded/discover/route.ts | 13 +- .../addon/branded/discover/styles.scss | 11 + .../styles.scss | 55 +++ .../template.hbs | 33 ++ .../registries-discover-search/component.ts | 13 + .../registries-discover-search/styles.scss | 12 + .../registries-discover-search/template.hbs | 15 + .../x-result/component.ts | 11 + .../x-result/styles.scss | 10 + .../x-result/template.hbs | 1 + .../x-results/component.ts | 24 ++ .../x-results/styles.scss | 0 .../x-results/template.hbs | 5 + .../x-sidebar/component.ts | 62 +++ .../x-sidebar/styles.scss | 39 ++ .../x-sidebar/template.hbs | 30 ++ .../components/registries-navbar/template.hbs | 2 +- .../registries-provider-facet/component.ts | 60 +++ .../registries-provider-facet/styles.scss | 35 ++ .../registries-provider-facet/template.hbs | 31 ++ .../registries-recent-list/template.hbs | 7 +- .../component.ts | 117 ++++++ .../styles.scss | 54 +++ .../template.hbs | 25 ++ .../component.ts | 11 + .../styles.scss | 14 + .../template.hbs | 4 + .../registries-search-result/component.ts | 46 ++ .../registries-search-result/styles.scss | 107 +++++ .../registries-search-result/template.hbs | 83 ++++ .../registries-subjects-facet/component.ts | 184 ++++++++ .../registries-subjects-facet/template.hbs | 5 + lib/registries/addon/discover/controller.ts | 397 ++++++++++++++++++ lib/registries/addon/discover/route.ts | 12 + lib/registries/addon/discover/styles.scss | 97 +++++ lib/registries/addon/discover/template.hbs | 108 +++++ lib/registries/addon/routes.ts | 1 + mirage/scenarios/registrations.ts | 1 - .../acceptance/institutions/discover-test.ts | 6 +- tests/acceptance/preprints/discover-test.ts | 6 +- .../acceptance/branded/discover-test.ts | 31 +- .../acceptance/discover-page-test.ts | 86 ++++ .../acceptance/landing-page-test.ts | 6 + .../integration/discover/discover-test.ts | 380 +++++++++++++++++ 49 files changed, 2251 insertions(+), 104 deletions(-) delete mode 100644 lib/osf-components/addon/helpers/get-localized-property.ts delete mode 100644 lib/osf-components/app/helpers/get-localized-property.js create mode 100644 lib/registries/addon/branded/discover/styles.scss create mode 100644 lib/registries/addon/components/registries-discover-results-header/styles.scss create mode 100644 lib/registries/addon/components/registries-discover-results-header/template.hbs create mode 100644 lib/registries/addon/components/registries-discover-search/component.ts create mode 100644 lib/registries/addon/components/registries-discover-search/styles.scss create mode 100644 lib/registries/addon/components/registries-discover-search/template.hbs create mode 100644 lib/registries/addon/components/registries-discover-search/x-result/component.ts create mode 100644 lib/registries/addon/components/registries-discover-search/x-result/styles.scss create mode 100644 lib/registries/addon/components/registries-discover-search/x-result/template.hbs create mode 100644 lib/registries/addon/components/registries-discover-search/x-results/component.ts create mode 100644 lib/registries/addon/components/registries-discover-search/x-results/styles.scss create mode 100644 lib/registries/addon/components/registries-discover-search/x-results/template.hbs create mode 100644 lib/registries/addon/components/registries-discover-search/x-sidebar/component.ts create mode 100644 lib/registries/addon/components/registries-discover-search/x-sidebar/styles.scss create mode 100644 lib/registries/addon/components/registries-discover-search/x-sidebar/template.hbs create mode 100644 lib/registries/addon/components/registries-provider-facet/component.ts create mode 100644 lib/registries/addon/components/registries-provider-facet/styles.scss create mode 100644 lib/registries/addon/components/registries-provider-facet/template.hbs create mode 100644 lib/registries/addon/components/registries-registration-type-facet/component.ts create mode 100644 lib/registries/addon/components/registries-registration-type-facet/styles.scss create mode 100644 lib/registries/addon/components/registries-registration-type-facet/template.hbs create mode 100644 lib/registries/addon/components/registries-search-facet-container/component.ts create mode 100644 lib/registries/addon/components/registries-search-facet-container/styles.scss create mode 100644 lib/registries/addon/components/registries-search-facet-container/template.hbs create mode 100644 lib/registries/addon/components/registries-search-result/component.ts create mode 100644 lib/registries/addon/components/registries-search-result/styles.scss create mode 100644 lib/registries/addon/components/registries-search-result/template.hbs create mode 100644 lib/registries/addon/components/registries-subjects-facet/component.ts create mode 100644 lib/registries/addon/components/registries-subjects-facet/template.hbs create mode 100644 lib/registries/addon/discover/controller.ts create mode 100644 lib/registries/addon/discover/route.ts create mode 100644 lib/registries/addon/discover/styles.scss create mode 100644 lib/registries/addon/discover/template.hbs create mode 100644 tests/engines/registries/acceptance/discover-page-test.ts create mode 100644 tests/engines/registries/integration/discover/discover-test.ts diff --git a/app/router.ts b/app/router.ts index 70f4176fe2..01aab62a1f 100644 --- a/app/router.ts +++ b/app/router.ts @@ -22,12 +22,12 @@ Router.map(function() { this.route('goodbye'); this.route('search'); this.route('institutions', function() { - this.route('discover', { path: '/:institution_id' }); + // this.route('discover', { path: '/:institution_id' }); this.route('dashboard', { path: '/:institution_id/dashboard' }); }); - this.route('preprints', function() { - this.route('discover', { path: '/:provider_id/discover' }); - }); + // this.route('preprints', function() { + // this.route('discover', { path: '/:provider_id/discover' }); + // }); this.route('register'); this.route('settings', function() { this.route('profile', function() { diff --git a/lib/osf-components/addon/helpers/get-localized-property.ts b/lib/osf-components/addon/helpers/get-localized-property.ts deleted file mode 100644 index 848a9f4607..0000000000 --- a/lib/osf-components/addon/helpers/get-localized-property.ts +++ /dev/null @@ -1,40 +0,0 @@ -import Helper from '@ember/component/helper'; -import { inject as service } from '@ember/service'; -import IntlService from 'ember-intl/services/intl'; - -import { LanguageText } from 'ember-osf-web/models/index-card'; - -/** - * This helper is used to get a locale-appropriate string for a property from a metadata hash. - * It is used to fetch metadata fields from a index-card's resourceMetadata attribute, but can be used for any - * hash that contains an array of LangaugeText objects. - * If the property is not found, the first value in the array is returned, or if the property is found, - * but there is no locale-appropriate value, the first value in the array is returned. - * - * @example - * ```handlebars - * {{get-localized-property indexCard.resourceMetadata 'title'}} - * ``` - * where `indexCard` is an index-card model instance. - * @class get-localized-property - * @param {Object} metadataHash The metadata hash to search for the property - * @param {String} propertyName The name of the property to search for - * @return {String} The locale-appropriate string or the first value in the array or 'Not provided' message - */ -export default class GetLocalizedPropertyHelper extends Helper { - @service intl!: IntlService; - - compute([metadataHash, propertyName]: [Record, string]): string { - const locale = this.intl.locale; - const valueOptions = metadataHash?.[propertyName]; - if (!metadataHash || !valueOptions || valueOptions.length === 0) { - return this.intl.t('helpers.get-localized-property.not-provided'); - } - - const index = valueOptions.findIndex((valueOption: LanguageText) => valueOption['@language'] === locale); - if (index === -1) { - return valueOptions[0]['@value']; - } - return valueOptions[index]['@value']; - } -} diff --git a/lib/osf-components/app/helpers/get-localized-property.js b/lib/osf-components/app/helpers/get-localized-property.js deleted file mode 100644 index 42cf034a93..0000000000 --- a/lib/osf-components/app/helpers/get-localized-property.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'osf-components/helpers/get-localized-property'; diff --git a/lib/registries/addon/application/controller.ts b/lib/registries/addon/application/controller.ts index 16213c92c4..7570785dfc 100644 --- a/lib/registries/addon/application/controller.ts +++ b/lib/registries/addon/application/controller.ts @@ -1,9 +1,19 @@ import Controller from '@ember/controller'; -import RouterService from '@ember/routing/router-service'; +import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import Features from 'ember-feature-flags/services/features'; +import { OSFService } from 'osf-components/components/osf-navbar/component'; + export default class Application extends Controller { @service features!: Features; - @service router!: RouterService; + + activeService = OSFService.REGISTRIES; + + @action + search(query: string) { + this.transitionToRoute('discover', { + queryParams: { query }, + }); + } } diff --git a/lib/registries/addon/branded/discover/controller.ts b/lib/registries/addon/branded/discover/controller.ts index c67c24a681..863ea7c7e6 100644 --- a/lib/registries/addon/branded/discover/controller.ts +++ b/lib/registries/addon/branded/discover/controller.ts @@ -1,36 +1,18 @@ -import Store from '@ember-data/store'; -// import EmberArray, { A } from '@ember/array'; -import Controller from '@ember/controller'; -import { action } from '@ember/object'; -import { inject as service } from '@ember/service'; -import Intl from 'ember-intl/services/intl'; -import Media from 'ember-responsive'; -import { tracked } from '@glimmer/tracking'; -import { OnSearchParams } from 'osf-components/components/search-page/component'; -import pathJoin from 'ember-osf-web/utils/path-join'; -import config from 'ember-get-config'; +import DiscoverController from 'registries/discover/controller'; -export default class BrandedDiscover extends Controller.extend() { - @service media!: Media; - @service intl!: Intl; - @service store!: Store; +import { ShareTermsFilter } from 'registries/services/share-search'; - @tracked cardSearchText? = ''; - @tracked sort? = '-relevance'; - @tracked page? = ''; - - queryParams = ['cardSearchText', 'page', 'sort']; - - get defaultQueryOptions() { - return { - publisher: pathJoin(config.OSF.url, 'registrations', this.model.id), - }; +export default class Discover extends DiscoverController { + // this route uses the registries.discover page template where the custom branding is handled + get providerModel() { + return this.model; } - @action - onSearch(onSearchParams: OnSearchParams) { - this.cardSearchText = onSearchParams.cardSearchText; - this.page = onSearchParams.page; - this.sort = onSearchParams.sort; + get additionalFilters() { + const { shareSource, name } = this.model; + + return [ + new ShareTermsFilter('sources', shareSource, name), + ]; } } diff --git a/lib/registries/addon/branded/discover/route.ts b/lib/registries/addon/branded/discover/route.ts index 4e2c45c7d7..9a881cc407 100644 --- a/lib/registries/addon/branded/discover/route.ts +++ b/lib/registries/addon/branded/discover/route.ts @@ -1,11 +1,11 @@ import Route from '@ember/routing/route'; -import RouterService from '@ember/routing/router-service'; -import { inject as service } from '@ember/service'; +import RegistrationProviderModel from 'ember-osf-web/models/registration-provider'; import { notFoundURL } from 'ember-osf-web/utils/clean-url'; export default class BrandedRegistriesDiscoverRoute extends Route { - @service router!: RouterService; + // this route uses the registries.discover page template where the custom branding is handled + templateName = 'discover'; model() { return this.modelFor('branded'); @@ -14,11 +14,7 @@ export default class BrandedRegistriesDiscoverRoute extends Route { afterModel(provider: RegistrationProviderModel) { if (!provider.brandedDiscoveryPage) { if (provider.id === 'osf') { - this.router.transitionTo('search', { - queryParams: { - resourceType: 'osf:Registration', - }, - }); + this.transitionTo('discover'); } else { this.transitionTo('page-not-found', notFoundURL(window.location.pathname)); } @@ -29,7 +25,6 @@ export default class BrandedRegistriesDiscoverRoute extends Route { return { osfMetrics: { isSearch: true, - providerId: this.controller.model.id, }, }; } diff --git a/lib/registries/addon/branded/discover/styles.scss b/lib/registries/addon/branded/discover/styles.scss new file mode 100644 index 0000000000..4c457cdfd7 --- /dev/null +++ b/lib/registries/addon/branded/discover/styles.scss @@ -0,0 +1,11 @@ +.BrandedRegistriesSearchResult { + composes: RegistriesSearchResult from '../../discover/styles'; + + svg { + color: var(--primary-color); + } +} + +.Pagination { + composes: Pagination from '../../discover/styles'; +} diff --git a/lib/registries/addon/components/registries-discover-results-header/styles.scss b/lib/registries/addon/components/registries-discover-results-header/styles.scss new file mode 100644 index 0000000000..9482e9caf5 --- /dev/null +++ b/lib/registries/addon/components/registries-discover-results-header/styles.scss @@ -0,0 +1,55 @@ + + +.results-container { + composes: ResultsHeader from '../../discover/styles'; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + width: 100%; + + .header { + width: 50%; + } + + .dropdown-container { + width: 50%; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: flex-start; + + } +} + +/** +I have no idea why but these classes are needed just like they are spelled +even though they do not exist in the hbs file. +*/ +.SortDropDown__List { + composes: SortDropDown__List from '../../discover/styles'; +} + +.SortDropDown__Option { + composes: SortDropDown__Option from '../../discover/styles'; +} + +.DropdownTrigger { + composes: Button from 'osf-components/components/button/styles'; + composes: MediumButton from 'osf-components/components/button/styles'; + composes: SecondaryButton from 'osf-components/components/button/styles'; + + color: $color-text-black; + + // Recreate the browser focus state for buttons + &:focus { + outline: auto 2px Highlight; + outline: auto 5px -webkit-focus-ring-color; + } +} + +.DropdownContent { + border: 1px solid $color-border-gray; + box-shadow: 0 1px 2px $primary-box-shadow; +} diff --git a/lib/registries/addon/components/registries-discover-results-header/template.hbs b/lib/registries/addon/components/registries-discover-results-header/template.hbs new file mode 100644 index 0000000000..4f8b419345 --- /dev/null +++ b/lib/registries/addon/components/registries-discover-results-header/template.hbs @@ -0,0 +1,33 @@ +
+
+

+ {{t 'registries.discover.registration_count' count=@totalResults}} +

+
+
+ + + {{t 'registries.discover.sort_by'}}: {{t @searchOptions.order.display}} + + + + {{#each @sortOptions as |option index|}} +
+ +
+ {{/each}} +
+
+
+
diff --git a/lib/registries/addon/components/registries-discover-search/component.ts b/lib/registries/addon/components/registries-discover-search/component.ts new file mode 100644 index 0000000000..c5723273a1 --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/component.ts @@ -0,0 +1,13 @@ +import { A } from '@ember/array'; +import Component from '@ember/component'; + +import { layout, requiredAction } from 'ember-osf-web/decorators/component'; +import { SearchOptions } from 'registries/services/search'; +import template from './template'; + +@layout(template) +export default class Discover extends Component { + results = A([]); + searchOptions!: SearchOptions; + @requiredAction onSearchOptionsUpdated!: (options: SearchOptions) => void; +} diff --git a/lib/registries/addon/components/registries-discover-search/styles.scss b/lib/registries/addon/components/registries-discover-search/styles.scss new file mode 100644 index 0000000000..ae8b6c6b48 --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/styles.scss @@ -0,0 +1,12 @@ +.Discover__Body { + background-color: #f5f5f5; + border-top: 1px solid $color-border-gray; + border-bottom: 1px solid #dedede; + padding-bottom: 50px; + padding-top: 50px; +} + +.row { + margin-right: -15px; + margin-left: -15px; +} diff --git a/lib/registries/addon/components/registries-discover-search/template.hbs b/lib/registries/addon/components/registries-discover-search/template.hbs new file mode 100644 index 0000000000..5532ff7981 --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/template.hbs @@ -0,0 +1,15 @@ +
+
+
+ {{yield (hash + results=(component 'registries-discover-search/x-results' @results) + sidebar=(component 'registries-discover-search/x-sidebar' + searchOptions=@searchOptions + additionalFilters=@additionalFilters + provider=@provider + onSearchOptionsUpdated=(action @onSearchOptionsUpdated) + ) + )}} +
+
+
diff --git a/lib/registries/addon/components/registries-discover-search/x-result/component.ts b/lib/registries/addon/components/registries-discover-search/x-result/component.ts new file mode 100644 index 0000000000..4fa8a4fcac --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/x-result/component.ts @@ -0,0 +1,11 @@ +import Component from '@ember/component'; +import { localClassNames } from 'ember-css-modules'; + +import { layout } from 'ember-osf-web/decorators/component'; +import template from './template'; + +@layout(template) +@localClassNames('SearchResult') +export default class SearchResult extends Component { + result!: T; +} diff --git a/lib/registries/addon/components/registries-discover-search/x-result/styles.scss b/lib/registries/addon/components/registries-discover-search/x-result/styles.scss new file mode 100644 index 0000000000..f8de59acae --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/x-result/styles.scss @@ -0,0 +1,10 @@ +.SearchResult { + background: $color-bg-white; + margin-bottom: 30px; + word-wrap: break-word; + word-break: break-word; + overflow-wrap: break-word; + box-shadow: 0 1px 2px #ddd; + border-width: 0; + padding: 10px; +} diff --git a/lib/registries/addon/components/registries-discover-search/x-result/template.hbs b/lib/registries/addon/components/registries-discover-search/x-result/template.hbs new file mode 100644 index 0000000000..b7555a22e0 --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/x-result/template.hbs @@ -0,0 +1 @@ +{{yield @result}} diff --git a/lib/registries/addon/components/registries-discover-search/x-results/component.ts b/lib/registries/addon/components/registries-discover-search/x-results/component.ts new file mode 100644 index 0000000000..0968fb1eac --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/x-results/component.ts @@ -0,0 +1,24 @@ +import Component from '@ember/component'; +import { action } from '@ember/object'; +import { localClassNames } from 'ember-css-modules'; + +import { layout, requiredAction } from 'ember-osf-web/decorators/component'; +import { SearchOptions } from 'registries/services/search'; + +import template from './template'; + +@layout(template) +@localClassNames('SearchResults') +export default class SearchResults extends Component { + static positionalParams = ['results']; + + searchOptions!: SearchOptions; + @requiredAction onSearchOptionsUpdated!: (options: SearchOptions) => void; + + results!: T[]; + + @action + _onSearchOptionsUpdated(options: SearchOptions) { + this.onSearchOptionsUpdated(options); + } +} diff --git a/lib/registries/addon/components/registries-discover-search/x-results/styles.scss b/lib/registries/addon/components/registries-discover-search/x-results/styles.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/registries/addon/components/registries-discover-search/x-results/template.hbs b/lib/registries/addon/components/registries-discover-search/x-results/template.hbs new file mode 100644 index 0000000000..7e598ef765 --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/x-results/template.hbs @@ -0,0 +1,5 @@ +{{#each @results as |result|}} + {{#registries-discover-search/x-result result=result as |ctxResult|}} + {{yield ctxResult}} + {{/registries-discover-search/x-result}} +{{/each}} \ No newline at end of file diff --git a/lib/registries/addon/components/registries-discover-search/x-sidebar/component.ts b/lib/registries/addon/components/registries-discover-search/x-sidebar/component.ts new file mode 100644 index 0000000000..5c692756a8 --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/x-sidebar/component.ts @@ -0,0 +1,62 @@ +import { A } from '@ember/array'; +import Component from '@ember/component'; +import { action, computed } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { localClassNames } from 'ember-css-modules'; +import { is, OrderedSet } from 'immutable'; + +import { layout, requiredAction } from 'ember-osf-web/decorators/component'; +import ProviderModel from 'ember-osf-web/models/provider'; +import Analytics from 'ember-osf-web/services/analytics'; +import { SearchFilter, SearchOptions } from 'registries/services/search'; +import template from './template'; + +function includesImmutable(someArray: unknown[], someValue: unknown) { + return someArray.any(val => is(val, someValue)); +} + +@layout(template) +@localClassNames('Sidebar') +export default class SideBar extends Component { + @service analytics!: Analytics; + + searchOptions!: SearchOptions; + additionalFilters!: SearchFilter[]; + provider?: ProviderModel; + @requiredAction onSearchOptionsUpdated!: (options: SearchOptions) => void; + + @computed('additionalFilters', 'searchOptions.filters') + get filters() { + const filters = A([]); + for (const filter of this.searchOptions.filters) { + if (!includesImmutable(this.additionalFilters, filter)) { + filters.addObject({ + filter, + display: filter.display, + }); + } + } + return filters; + } + + @action + _onSearchOptionsUpdated(options: SearchOptions) { + this.onSearchOptionsUpdated(options); + } + + @action + removeFilter(filter: SearchFilter) { + if (this.provider) { + this.analytics.click('link', `Discover - Remove Filter ${this.provider.name}`, filter); + } else { + this.analytics.click('link', 'Discover - Remove Filter', filter); + } + this.onSearchOptionsUpdated(this.searchOptions.removeFilters(filter)); + } + + @action + clearFilters() { + this.analytics.track('button', 'click', 'Discover - Clear Filters'); + this.onSearchOptionsUpdated(this.searchOptions.set('filters', OrderedSet())); + } +} diff --git a/lib/registries/addon/components/registries-discover-search/x-sidebar/styles.scss b/lib/registries/addon/components/registries-discover-search/x-sidebar/styles.scss new file mode 100644 index 0000000000..427112fd2d --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/x-sidebar/styles.scss @@ -0,0 +1,39 @@ +// stylelint-disable selector-no-qualifying-type +h2.Sidebar__FilterHeading { + font-size: 18px; + font-weight: 700; +} +// stylelint-enable selector-no-qualifying-type +.Sidebar__ActiveFilters { + display: flex; + flex-wrap: wrap; +} + +.Sidebar__ActiveFilter { + line-height: 30px; + display: flex; + margin-top: 5px; + margin-right: 5px; +} + +.Sidebar__ActiveFilterLabel { + background-color: $color-filter-bg; + color: $color-text-white; + padding: 1.5px 8px 4px; + border-radius: 2px 0 0 2px; +} + +.Sidebar__RemoveFilter { + line-height: inherit; + border-radius: 0 2px 2px 0; +} + +.Sidebar__Facets { + padding-top: 15px; +} + +.sidebar-container { + width: 100%; + padding-left: 20px; + padding-right: 20px; +} diff --git a/lib/registries/addon/components/registries-discover-search/x-sidebar/template.hbs b/lib/registries/addon/components/registries-discover-search/x-sidebar/template.hbs new file mode 100644 index 0000000000..58b8ac9d30 --- /dev/null +++ b/lib/registries/addon/components/registries-discover-search/x-sidebar/template.hbs @@ -0,0 +1,30 @@ +
+

+ {{t 'registries.discover.sidebar.refine_search'}} +

+
+ {{#each this.filters as |filter|}} +
+ + {{filter.display}} + + +
+ {{/each}} +
+
+ {{yield (hash + filters=this.filters + searchOptions=@searchOptions + onSearchOptionsUpdated=(action this._onSearchOptionsUpdated) + )}} +
+
\ No newline at end of file diff --git a/lib/registries/addon/components/registries-navbar/template.hbs b/lib/registries/addon/components/registries-navbar/template.hbs index f20ea0e457..b2e075c5f5 100644 --- a/lib/registries/addon/components/registries-navbar/template.hbs +++ b/lib/registries/addon/components/registries-navbar/template.hbs @@ -9,7 +9,7 @@ void; + + title = 'Provider'; + options: EmberArray<{ + count: number, + filter: SearchFilter, + }> = A([]); + + @computed('options', 'searchOptions.filters') + get providers() { + return this.options.map(option => ({ + ...option, + checked: this.searchOptions.filters.has(option.filter), + })); + } + + @computed('options.length') + get shouldLinkToAggregateDiscover() { + return this.options.length === 1; + } + + @action + providerChecked(filter: SearchFilter, remove: boolean) { + if (this.provider) { + this.analytics.track( + 'filter', + remove + ? 'remove' + : 'add', + `Discover - providers ${filter.display} ${this.provider.name}`, + ); + } else { + this.analytics.track('filter', remove ? 'remove' : 'add', `Discover - providers ${filter.display}`); + } + if (remove) { + this.onSearchOptionsUpdated(this.searchOptions.removeFilters(filter)); + } else { + this.onSearchOptionsUpdated(this.searchOptions.addFilters(filter)); + } + } +} diff --git a/lib/registries/addon/components/registries-provider-facet/styles.scss b/lib/registries/addon/components/registries-provider-facet/styles.scss new file mode 100644 index 0000000000..3ed1819148 --- /dev/null +++ b/lib/registries/addon/components/registries-provider-facet/styles.scss @@ -0,0 +1,35 @@ +.ProviderFacet__List { + list-style: none; + padding: 0; +} + +.ProviderFacet__ListItem { + display: flex; + justify-content: space-between; +} + +.ProviderFacet__Label { + font-weight: 400; + min-width: 0; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + flex-grow: 1; + + &:hover { + color: var(--primary-color); + } +} + +.ProviderFacet__Checkbox.ProviderFacet__Checkbox { + margin-right: 5px; + float: left; +} + +.ProviderFacet__Count { + font-size: 85%; + font-weight: bold; + color: $color-text-gray; + align-self: center; + white-space: nowrap; +} diff --git a/lib/registries/addon/components/registries-provider-facet/template.hbs b/lib/registries/addon/components/registries-provider-facet/template.hbs new file mode 100644 index 0000000000..8353d553aa --- /dev/null +++ b/lib/registries/addon/components/registries-provider-facet/template.hbs @@ -0,0 +1,31 @@ +{{#registries-search-facet-container title='Provider'}} +
    + {{#each this.providers as |provider|}} +
  • + + + {{t 'registries.discover.sidebar.provider_count' count=provider.count}} + +
  • + {{/each}} +
+ {{#if this.shouldLinkToAggregateDiscover}} + + {{t 'registries.facets.provider.other_registries'}} + + {{/if}} +{{/registries-search-facet-container}} diff --git a/lib/registries/addon/components/registries-recent-list/template.hbs b/lib/registries/addon/components/registries-recent-list/template.hbs index 9918d51a1a..d07ce2928f 100644 --- a/lib/registries/addon/components/registries-recent-list/template.hbs +++ b/lib/registries/addon/components/registries-recent-list/template.hbs @@ -4,12 +4,9 @@ {{t 'registries.index.recent.title'}}
- + {{t 'registries.index.recent.more'}} - +
diff --git a/lib/registries/addon/components/registries-registration-type-facet/component.ts b/lib/registries/addon/components/registries-registration-type-facet/component.ts new file mode 100644 index 0000000000..a4c55fd366 --- /dev/null +++ b/lib/registries/addon/components/registries-registration-type-facet/component.ts @@ -0,0 +1,117 @@ +import Store from '@ember-data/store'; +import EmberArray, { A } from '@ember/array'; +import Component from '@ember/component'; +import { action, computed } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import { task } from 'ember-concurrency'; +import Features from 'ember-feature-flags/services/features'; +import Intl from 'ember-intl/services/intl'; +import Toast from 'ember-toastr/services/toast'; + +import { layout, requiredAction } from 'ember-osf-web/decorators/component'; +import RegistrationProviderModel from 'ember-osf-web/models/registration-provider'; +import Analytics from 'ember-osf-web/services/analytics'; +import captureException, { getApiErrorMessage } from 'ember-osf-web/utils/capture-exception'; + +import registriesConfig from 'registries/config/environment'; +import { SearchOptions } from 'registries/services/search'; +import { ShareTermsFilter } from 'registries/services/share-search'; +import template from './template'; + +@layout(template) +export default class RegistriesRegistrationTypeFacet extends Component { + @service intl!: Intl; + @service toast!: Toast; + @service store!: Store; + @service analytics!: Analytics; + @service features!: Features; + + searchOptions!: SearchOptions; + provider?: RegistrationProviderModel; + @requiredAction onSearchOptionsUpdated!: (options: SearchOptions) => void; + + registrationTypes: EmberArray = A([]); + + @task({ on: 'didReceiveAttrs' }) + @waitFor + async fetchRegistrationTypes() { + const { defaultProviderId } = registriesConfig; + + try { + if (!this.provider){ + this.provider = await this.store.findRecord('registration-provider', defaultProviderId); + } + const metaschemas = await this.provider.queryHasMany('schemas', { + 'page[size]': 100, + }); + const metaschemaNames = metaschemas.mapBy('name'); + if (this.provider.id === defaultProviderId) { + metaschemaNames.push( + // Manually add 'Election Research Preacceptance Competition' to the list of possible + // facets. Metaschema was removed from the API as a possible registration type + // but should still be searchable + 'Election Research Preacceptance Competition', + ); + } + this.set('registrationTypes', A(metaschemaNames.sort())); + } catch (e) { + const errorMessage = this.intl.t('registries.facets.registration_type.registration_schema_error'); + captureException(e, { errorMessage }); + this.toast.error(getApiErrorMessage(e), errorMessage); + throw e; + } + } + + get title() { + return this.intl.t('registries.facets.registration_type.title'); + } + + @computed('searchOptions.filters') + get onlyOSF() { + const osfSelected = this.searchOptions.filters.find( + item => item instanceof ShareTermsFilter + && item.key === 'sources' + && item.value === 'OSF Registries', + ); + return this.searchOptions.filters.filter(filter => filter.key === 'sources').size === 1 && osfSelected; + } + + @computed('registrationTypes', 'searchOptions.filters') + get types() { + return this.registrationTypes.map(name => { + const filter = new ShareTermsFilter('registration_type', name, name); + + return { + name, + filter, + checked: this.searchOptions.filters.contains(filter), + }; + }); + } + + @action + typeChecked(filter: ShareTermsFilter, checked: boolean) { + if (!this.onlyOSF) { + return undefined; + } + + if (this.provider) { + this.analytics.track( + 'filter', + checked + ? 'remove' + : 'add', + `Discover - type ${filter.display} ${this.provider.name}`, + ); + } else { + this.analytics.track('filter', checked ? 'remove' : 'add', `Discover - type ${filter.display}`); + } + + if (checked) { + return this.onSearchOptionsUpdated(this.searchOptions.removeFilters(filter)); + } + + return this.onSearchOptionsUpdated(this.searchOptions.addFilters(filter)); + } +} diff --git a/lib/registries/addon/components/registries-registration-type-facet/styles.scss b/lib/registries/addon/components/registries-registration-type-facet/styles.scss new file mode 100644 index 0000000000..863a0af9bd --- /dev/null +++ b/lib/registries/addon/components/registries-registration-type-facet/styles.scss @@ -0,0 +1,54 @@ +.RegistrationType { + opacity: 1; + animation: enable 0.5s ease-in-out; +} + +@keyframes disable { + 0% { + opacity: 1; + } + + 100% { + opacity: 0.5; + } +} + +@keyframes enable { + 0% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} + +.RegistrationType__Warning { + font-weight: 700; + color: #000; + padding-left: 0; + display: block; +} + +.RegistrationType__List { + list-style: none; + padding: 0; +} + +.RegistrationType__Item { + label { + font-weight: 400; + + &:hover { + color: var(--primary-color); + } + } +} + +.RegistrationType__Filter { + background-color: #d7e6e9; +} + +.m-t-sm { + margin-top: 10px; +} diff --git a/lib/registries/addon/components/registries-registration-type-facet/template.hbs b/lib/registries/addon/components/registries-registration-type-facet/template.hbs new file mode 100644 index 0000000000..ac7e5de9f6 --- /dev/null +++ b/lib/registries/addon/components/registries-registration-type-facet/template.hbs @@ -0,0 +1,25 @@ +{{#if this.onlyOSF}} + {{#registries-search-facet-container title=this.title}} +
+ + {{t 'registries.facets.registration_type.only_available_with_osf'}} + + +
    + {{#each this.types as |type index|}} +
  • + +
  • + {{/each}} +
+
+ {{/registries-search-facet-container}} +{{/if}} diff --git a/lib/registries/addon/components/registries-search-facet-container/component.ts b/lib/registries/addon/components/registries-search-facet-container/component.ts new file mode 100644 index 0000000000..38b3ccea31 --- /dev/null +++ b/lib/registries/addon/components/registries-search-facet-container/component.ts @@ -0,0 +1,11 @@ +import Component from '@ember/component'; +import { localClassNames } from 'ember-css-modules'; + +import { layout } from 'ember-osf-web/decorators/component'; +import template from './template'; + +@layout(template) +@localClassNames('SearchFacet') +export default class SearchFacetContainer extends Component { + title!: string; +} diff --git a/lib/registries/addon/components/registries-search-facet-container/styles.scss b/lib/registries/addon/components/registries-search-facet-container/styles.scss new file mode 100644 index 0000000000..479fa378dc --- /dev/null +++ b/lib/registries/addon/components/registries-search-facet-container/styles.scss @@ -0,0 +1,14 @@ +.SearchFacet { + border-width: 0; + box-shadow: none; +} +// stylelint-disable selector-no-qualifying-type +h3.SearchFacet__Heading { + font-size: 15px; + font-weight: 700; + margin-top: 10px; +} + +.SearchFacet__Body { + padding: 10px 0; +} diff --git a/lib/registries/addon/components/registries-search-facet-container/template.hbs b/lib/registries/addon/components/registries-search-facet-container/template.hbs new file mode 100644 index 0000000000..fcb847f2ba --- /dev/null +++ b/lib/registries/addon/components/registries-search-facet-container/template.hbs @@ -0,0 +1,4 @@ +

{{@title}}

+
+ {{yield}} +
diff --git a/lib/registries/addon/components/registries-search-result/component.ts b/lib/registries/addon/components/registries-search-result/component.ts new file mode 100644 index 0000000000..eabf489d52 --- /dev/null +++ b/lib/registries/addon/components/registries-search-result/component.ts @@ -0,0 +1,46 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { localClassNames } from 'ember-css-modules'; +import { inject as service } from '@ember/service'; +import Media from 'ember-responsive'; + +import { layout } from 'ember-osf-web/decorators/component'; +import { ShareRegistration } from 'registries/services/share-search'; + +import template from './template'; + +const OSF_GUID_REGEX = /^https?:\/\/.*osf\.io\/([^/]+)/; + +@layout(template) +@localClassNames('RegistriesSearchResult') +export default class RegistriesSearchResult extends Component { + @service media!: Media; + // Required + result!: ShareRegistration; + + // For use later, when the registration overview page is implemented + // @computed('result') + // get osfID() { + // const res = OSF_GUID_REGEX.exec(this.result.mainLink || ''); + + // if (res) { + // return res[1]; + // } + + // return false; + // } + + @computed('result.contributors') + get contributors() { + return this.result.contributors.filter( + contrib => contrib.bibliographic, + ).map(contrib => ({ + name: contrib.name, + link: contrib.identifiers.filter(ident => OSF_GUID_REGEX.test(ident))[0], + })); + } + + get isMobile() { + return this.media.isMobile; + } +} diff --git a/lib/registries/addon/components/registries-search-result/styles.scss b/lib/registries/addon/components/registries-search-result/styles.scss new file mode 100644 index 0000000000..4382a0b490 --- /dev/null +++ b/lib/registries/addon/components/registries-search-result/styles.scss @@ -0,0 +1,107 @@ +// stylelint-disable max-nesting-depth, selector-max-compound-selectors + +.search-container { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + width: 100%; + + &.mobile { + .search-result-container { + width: calc(100% - 50px); + } + + .badges-container { + width: 50px; + min-width: 50px; + padding-left: 0; + } + } + + .search-result-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + width: 75%; + + .title { + width: 100%; + line-height: inherit; + margin-top: 10px; + + :global(.label-default) { + background-color: $color-bg-gray-darker; + } + } + + .updated { + width: 100%; + font-style: italic; + } + + .sources { + width: 100%; + font-weight: 700; + margin-top: 10px; + } + + .contributors { + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + list-style: none; + padding-left: 0; + + & > li { + display: inline-flex; + padding-right: 5px; + + &::after { + content: ','; + } + } + + & > li:last-child::after { + content: ''; + } + } + + .description { + max-height: 120px; + height: fit-content; + overflow: hidden; + transition: max-height 0.5s cubic-bezier(0, 1, 0, 1); + margin-top: 10px; + width: 100%; + padding-right: 10px; + } + } + + .badges-container { + min-width: 150px; + width: 25%; + border-left-width: thin; + border-left-color: $color-bg-gray-light; + border-left-style: solid; + padding-left: 10px; + margin-top: 12px; + } + +} + +.label { + display: inline; + padding: 0.2em 0.6em 0.3em; + font-size: 75%; + font-weight: 700; + line-height: 1; + color: $color-text-white; + background-color: $color-bg-gray-darker; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0.25em; +} diff --git a/lib/registries/addon/components/registries-search-result/template.hbs b/lib/registries/addon/components/registries-search-result/template.hbs new file mode 100644 index 0000000000..63ecf039f8 --- /dev/null +++ b/lib/registries/addon/components/registries-search-result/template.hbs @@ -0,0 +1,83 @@ +
+
+

+ {{#if @result.relatedResourceTypes}} + {{!-- This means it's an OSF resource, which means it'll have a guid --}} + + {{math @result.title}} + + {{else}} + + {{math @result.title}} + + {{/if}} + {{#if @result.withdrawn}} + {{t 'registries.discover.search_result.withdrawn'}} + {{/if}} +

+ + {{#if this.contributors}} +
    + {{#each this.contributors as |contrib|}} +
  • + {{#if contrib.link}} + + {{contrib.name}} + + {{else}} + {{contrib.name}} + {{/if}} +
  • + {{/each}} +
+ {{/if}} + + {{#if @result.dateUpdated}} +
+ {{t 'registries.discover.search_result.last_edited' date=(moment-format (utc @result.dateUpdated) 'MMMM D, YYYY UTC')}} +
+ {{/if}} + +
+ {{#each @result.sources as |source index|}} + {{if index '| '}}{{source}} + {{/each}} + + {{#if @result.registrationType}} + | {{@result.registrationType}} + {{/if}} +
+ +

+ {{math @result.description}} +

+
+ {{#if @result.relatedResourceTypes}} +
+ +
+ {{/if}} +
diff --git a/lib/registries/addon/components/registries-subjects-facet/component.ts b/lib/registries/addon/components/registries-subjects-facet/component.ts new file mode 100644 index 0000000000..a9b1551620 --- /dev/null +++ b/lib/registries/addon/components/registries-subjects-facet/component.ts @@ -0,0 +1,184 @@ +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import Component from '@glimmer/component'; + +import ProviderModel from 'ember-osf-web/models/provider'; +import SubjectModel from 'ember-osf-web/models/subject'; +import Analytics from 'ember-osf-web/services/analytics'; +import { SubjectManager } from 'osf-components/components/subjects/manager/component'; +import { SearchOptions } from 'registries/services/search'; +import { ShareTermsFilter } from 'registries/services/share-search'; + +interface Args { + provider: ProviderModel; + searchOptions: SearchOptions; + onSearchOptionsUpdated(options: SearchOptions): void; +} + +// TODO: memoize some of these functions? could get expensive with lots of subjects expanded + +// WARNING: assumes subject.parent (and subject.parent.parent...) is already loaded +function getSubjectTerm(subject: SubjectModel): string { + // subjects are indexed with their full hierarchy, including taxonomy name + // 'taxonomy|parent subject text|this subject text' + // e.g. 'bepress|Law|Bird Law' + const parentSubject = subject.belongsTo('parent').value() as SubjectModel | null; + + return parentSubject + ? `${getSubjectTerm(parentSubject)}|${subject.text}` + : `${subject.taxonomyName}|${subject.text}`; +} + +function newSubjectFilter(subject: SubjectModel): ShareTermsFilter { + return new ShareTermsFilter('subjects', getSubjectTerm(subject), subject.text); +} + +/* want to get filters for all ancestors, e.g. + * given `bepress|foo|bar|baz` + * get [`bepress|foo`, `bepress|foo|bar`] + */ +function getAncestry(subjectTerm: string): string[] { + const parentTerms: string[] = []; + const [taxonomyName, ...subjectAncestry] = subjectTerm.split('|'); + for (let i = 1; i < subjectAncestry.length; i++) { + const ancestorLineage = subjectAncestry.slice(0, i).join('|'); + parentTerms.push(`${taxonomyName}|${ancestorLineage}`); + } + return parentTerms; +} + +function getAncestryFilters(subjectTerm: string): ShareTermsFilter[] { + const parentFilters: ShareTermsFilter[] = []; + const [taxonomyName, ...subjectAncestry] = subjectTerm.split('|'); + for (let i = 1; i < subjectAncestry.length; i++) { + const ancestorText = subjectAncestry[i]; + const ancestorLineage = subjectAncestry.slice(0, i).join('|'); + parentFilters.push(new ShareTermsFilter( + 'subjects', + `${taxonomyName}|${ancestorLineage}`, + ancestorText, + )); + } + return parentFilters; +} + +export default class RegistriesSubjectsFacet extends Component { + @service analytics!: Analytics; + + provider?: ProviderModel; + + get selectedSubjectFilters() { + const { searchOptions: { filters } } = this.args; + return filters.filter(f => f.key === 'subjects').toArray(); + } + + get selectedSubjectTerms(): Set { + return new Set( + this.selectedSubjectFilters.map(f => f.value as string), + ); + } + + get parentTermsWithSelectedChild(): Set { + const { selectedSubjectTerms } = this; + const parentTerms = new Set(); + + selectedSubjectTerms.forEach( + subjectTerm => getAncestry(subjectTerm).forEach( + ancestorTerm => parentTerms.add(ancestorTerm), + ), + ); + + return parentTerms; + } + + get subjectsManager(): SubjectManager { + const { + args: { provider }, + selectSubject, + unselectSubject, + selectedSubjectTerms, + parentTermsWithSelectedChild, + } = this; + + return { + provider, + selectSubject, + unselectSubject, + + subjectIsSelected(subject: SubjectModel): boolean { + // display a subject as selected if any of its children are selected + return selectedSubjectTerms.has(getSubjectTerm(subject)) + || parentTermsWithSelectedChild.has(getSubjectTerm(subject)); + }, + + subjectHasSelectedChildren(subject: SubjectModel) { + return parentTermsWithSelectedChild.has(getSubjectTerm(subject)); + }, + + subjectIsSaved: () => false, // TODO: should this return true? + + // NOTE: everything below is not needed by Subjects::Browse, so they're + // just here to fit the interface that assumes we're saving subjects + // on a model instance + savedSubjects: [], + selectedSubjects: [], + isSaving: false, + hasChanged: false, + discardChanges: () => undefined, + saveChanges: () => Promise.resolve(), + }; + } + + @action + selectSubject(subject: SubjectModel): void { + const { + searchOptions, + onSearchOptionsUpdated, + } = this.args; + + if (this.provider) { + this.analytics.track( + 'filter', + 'add', + `Discover - subject ${subject.text} ${this.provider.name}`, + ); + } else { + this.analytics.track('filter', 'add', `Discover - subject ${subject.taxonomyName}`); + } + + const filterToAdd = newSubjectFilter(subject); + const subjectTerm = getSubjectTerm(subject); + const parentFilters = getAncestryFilters(subjectTerm); + + onSearchOptionsUpdated(searchOptions.addFilters(filterToAdd, ...parentFilters)); + } + + @action + unselectSubject(subject: SubjectModel): void { + const { + args: { + searchOptions, + onSearchOptionsUpdated, + }, + selectedSubjectFilters, + } = this; + + if (this.provider) { + this.analytics.track( + 'filter', + 'remove', + `Discover - subject ${subject.text} ${this.provider.name}`, + ); + } else { + this.analytics.track('filter', 'remove', `Discover - subject ${subject.taxonomyName}`); + } + + const subjectTerm = getSubjectTerm(subject); + + const filtersToRemove = selectedSubjectFilters.filter( + f => (f.value as string).startsWith(subjectTerm), + ); + + onSearchOptionsUpdated(searchOptions.removeFilters(...filtersToRemove)); + } +} diff --git a/lib/registries/addon/components/registries-subjects-facet/template.hbs b/lib/registries/addon/components/registries-subjects-facet/template.hbs new file mode 100644 index 0000000000..9ae740ef8c --- /dev/null +++ b/lib/registries/addon/components/registries-subjects-facet/template.hbs @@ -0,0 +1,5 @@ + + + diff --git a/lib/registries/addon/discover/controller.ts b/lib/registries/addon/discover/controller.ts new file mode 100644 index 0000000000..91c601de77 --- /dev/null +++ b/lib/registries/addon/discover/controller.ts @@ -0,0 +1,397 @@ +import Store from '@ember-data/store'; +import EmberArray, { A } from '@ember/array'; +import Controller from '@ember/controller'; +import { action, computed } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import { restartableTask, task, timeout } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; +import Intl from 'ember-intl/services/intl'; +import QueryParams from 'ember-parachute'; +import { is, OrderedSet } from 'immutable'; +import Media from 'ember-responsive'; + +import config from 'ember-get-config'; +import ProviderModel from 'ember-osf-web/models/provider'; +import Analytics from 'ember-osf-web/services/analytics'; +import discoverStyles from 'registries/components/registries-discover-search/styles'; +import { SearchFilter, SearchOptions, SearchOrder, SearchResults } from 'registries/services/search'; +import ShareSearch, { + ShareRegistration, + ShareTermsAggregation, + ShareTermsFilter, +} from 'registries/services/share-search'; + +// Helper for Immutable.is as it doesn't like Native Arrays +function isEqual(obj1: any, obj2: any) { + if (Array.isArray(obj1) && Array.isArray(obj2)) { + if (obj1.length !== obj2.length) { + return false; + } + + for (let i = 0; i < obj1.length; i++) { + if (!(isEqual(obj1[i], obj2[i]))) { + return false; + } + } + + return true; + } + + return is(obj1, obj2); +} + +interface DiscoverQueryParams { + page: number; + query: string; + size: number; + sort: SearchOrder; + registrationTypes: ShareTermsFilter[]; + sourceNames: string[]; + subjects: ShareTermsFilter[]; +} + +const sortOptions = [ + new SearchOrder({ + ascending: true, + display: 'registries.discover.order.relevance', + key: undefined, + }), + new SearchOrder({ + ascending: true, + display: 'registries.discover.order.modified_ascending', + key: 'date', + }), + new SearchOrder({ + ascending: false, + display: 'registries.discover.order.modified_descending', + key: 'date', + }), +]; + +const queryParams = { + sourceNames: { + as: 'provider', + defaultValue: [] as string[], + serialize(value: string[]) { + return value.join('|'); + }, + deserialize(value: string) { + return value.split('|'); + }, + }, + registrationTypes: { + as: 'type', + refresh: true, + defaultValue: [] as ShareTermsFilter[], + serialize(value: ShareTermsFilter[]) { + return value.map(filter => filter.value).join('|'); + }, + deserialize(value: string) { + // Handle empty strings + if (value.trim().length < 1) { + return []; + } + return value.split('|').map( + registrationType => new ShareTermsFilter('registration_type', registrationType, registrationType), + ); + }, + }, + query: { + as: 'q', + defaultValue: '', + replace: true, + }, + size: { + defaultValue: 10, + serialize(value: number) { + return value.toString(); + }, + deserialize(value: string) { + return parseInt(value, 10) || this.defaultValue; + }, + }, + sort: { + defaultValue: sortOptions[0], + serialize(value: SearchOrder) { + if (value.key === 'date_modified') { + return ''; + } + + return `${value.ascending ? '' : '-'}${value.key || ''}`; + }, + deserialize(value: string) { + return sortOptions.find( + option => !!option.key + && value.endsWith(option.key) + && option.ascending === !value.startsWith('-'), + ) || sortOptions[0]; + }, + }, + page: { + defaultValue: 1, + serialize(value: number) { + return value.toString(); + }, + deserialize(value: string) { + return parseInt(value, 10) || this.defaultValue; + }, + }, + subjects: { + defaultValue: [] as ShareTermsFilter[], + serialize(value: ShareTermsFilter[]) { + return value.map(filter => filter.value).join(',,'); + }, + deserialize(value: string) { + return value.split(',,').map( + subjectTerm => { + const subjectPieces = subjectTerm.split('|'); + const display = subjectPieces[subjectPieces.length - 1]; + return new ShareTermsFilter('subjects', subjectTerm, display); + }, + ); + }, + }, +}; + +export const discoverQueryParams = new QueryParams(queryParams); + +export default class Discover extends Controller.extend(discoverQueryParams.Mixin) { + @service media!: Media; + @service intl!: Intl; + @service analytics!: Analytics; + @service store!: Store; + @service shareSearch!: ShareSearch; + + sortOptions = sortOptions; + + results: EmberArray = A([]); + searchable!: number; + totalResults = 0; + searchOptions!: SearchOptions; + + filterableSources: Array<{ + count: number, + filter: SearchFilter, + }> = []; + + get providerModel(): ProviderModel | undefined { + return undefined; + } + + // used to filter the counts/aggregations and all search results + get additionalFilters(): ShareTermsFilter[] { + return []; + } + + @computed('sourceNames.[]', 'shareSearch.allRegistries.[]') + get sourceFilters() { + return this.sourceNames.map( + name => this.shareSearch.allRegistries.find(r => r.name === name), + ).filter(Boolean).map( + source => new ShareTermsFilter('sources', source!.name, source!.display || source!.name), + ); + } + + @computed('searchOptions.size', 'totalResults') + get maxPage() { + const max = Math.ceil(this.totalResults / this.searchOptions.size); + if (max > (10000 / this.searchOptions.size)) { + return Math.ceil(10000 / this.searchOptions.size); + } + return max; + } + + @task + @waitFor + async getCountsAndAggs() { + const results = await this.shareSearch.registrations(new SearchOptions({ + size: 0, + modifiers: OrderedSet([ + new ShareTermsAggregation('sources', 'sources'), + ]), + filters: OrderedSet([ + ...this.additionalFilters, + ]), + })); + + const osfProviders = await this.store.query('registration-provider', { + 'page[size]': 100, + }); + + // Setting osfProviders on the share-search service + const urlRegex = config.OSF.url.replace(/^https?/, '^https?'); + const filteredProviders = osfProviders.filter(provider => provider.shareSource).map(provider => ({ + name: provider.shareSource!, // `name` should match what SHARE calls it + display: provider.name, + https: true, + urlRegex, + })); + this.shareSearch.set('osfProviders', filteredProviders); + + const filterableSources: Array<{count: number, filter: SearchFilter}> = []; + /* eslint-disable camelcase */ + const buckets = results.aggregations.sources.buckets as Array<{key: string, doc_count: number}>; + + // NOTE: config.externalRegistries is iterated over here to match its order. + for (const source of this.shareSearch.allRegistries) { + const bucket = buckets.find(x => x.key === source.name); + if (!bucket) { + continue; + } + + filterableSources.push({ + count: bucket.doc_count, + filter: new ShareTermsFilter( + 'sources', + bucket.key, + source.display || source.name, + ), + }); + } + /* eslint-enable camelcase */ + + this.set('searchable', results.total); + this.set('filterableSources', filterableSources); + taskFor(this.doSearch).perform(); + } + + @restartableTask + @waitFor + async doSearch() { + // TODO-mob don't hard-code 'OSF' + + // Unless OSF is the only source, registration_type filters must be cleared + if (!(this.sourceNames.length === 1 && this.sourceNames[0]! === 'OSF Registries')) { + this.set('registrationTypes', A([])); + } + + // If query has changed but page has not changed reset page to 1. + // The page check stops other tests from breaking + if (this.searchOptions && this.searchOptions.query !== this.query && this.searchOptions.page === this.page) { + this.set('page', 1); + } + + let options = new SearchOptions({ + query: this.query, + size: this.size, + page: this.page, + order: this.sort, + filters: OrderedSet([ + ...this.sourceFilters, + ...this.registrationTypes, + ...this.additionalFilters, + ]), + }); + + // If there is no query, no filters, and no sort, default to -date_modified rather + // than relevance. + if (!options.order.key && (!options.query || options.query === '') && options.filters.size === 0) { + options = options.set('order', new SearchOrder({ + display: 'registries.discover.order.relevance', + ascending: false, + key: 'date_modified', + })); + } + + this.set('searchOptions', options); + + await timeout(250); + + const results: SearchResults = await this.shareSearch.registrations(options); + + this.set('results', A(results.results)); + this.set('totalResults', results.total); + } + + setup() { + taskFor(this.getCountsAndAggs).perform(); + } + + queryParamsDidChange() { + taskFor(this.doSearch).perform(); + } + + @action + onSearchOptionsUpdated(options: SearchOptions) { + const sources: ShareTermsFilter[] = []; + const registrationTypes: ShareTermsFilter[] = []; + const subjects: ShareTermsFilter[] = []; + + for (const filter of options.filters.values()) { + if (filter.key === 'sources') { + sources.push(filter as ShareTermsFilter); + } + + if (filter.key === 'registration_type') { + registrationTypes.push(filter as ShareTermsFilter); + } + + if (filter.key === 'subjects') { + subjects.push(filter as ShareTermsFilter); + } + } + + const changes = {} as Discover; + + if (!isEqual(this.sourceFilters, sources)) { + changes.page = 1; + changes.sourceNames = sources.map(filter => filter.value.toString()); + } + + if (!isEqual(this.registrationTypes, registrationTypes)) { + changes.page = 1; + changes.registrationTypes = registrationTypes; + } + + if (!isEqual(this.subjects, subjects)) { + changes.page = 1; + changes.subjects = subjects; + } + + // If any filters are changed page is reset to 1 + this.setProperties(changes); + } + + @action + changePage(page: number) { + this.set('page', page); + + // Get the application owner by using + // passed down services as rootElement + // isn't defined on engines' owners + const element = document.querySelector(`.${discoverStyles.Discover__Body}`) as HTMLElement; + if (!element) { + return; + } + element.scrollIntoView(); + } + + @action + onSearch(value: string) { + // Set page to 1 here to ensure page is always reset when updating a query + this.setProperties({ page: 1, query: value }); + // If query or page don't actually change ember won't fire related events + // So always kick off a doSearch task to allow forcing a "re-search" + taskFor(this.doSearch).perform(); + } + + @action + setOrder(value: SearchOrder) { + if (this.providerModel) { + this.analytics.track( + 'dropdown', + 'select', + `Discover - Sort By: ${this.intl.t(value.display)} ${this.providerModel.name}`, + ); + } else { + this.analytics.track('dropdown', 'select', `Discover - Sort By: ${this.intl.t(value.display)}`); + } + // Set page to 1 here to ensure page is always reset when changing the order/sorting of a search + this.setProperties({ page: 1, sort: value }); + } + + get isMobile() { + return this.media.isMobile; + } +} diff --git a/lib/registries/addon/discover/route.ts b/lib/registries/addon/discover/route.ts new file mode 100644 index 0000000000..f0755af90c --- /dev/null +++ b/lib/registries/addon/discover/route.ts @@ -0,0 +1,12 @@ +import Route from '@ember/routing/route'; + +export default class RegistriesDiscoverRoute extends Route { + buildRouteInfoMetadata() { + return { + osfMetrics: { + isSearch: true, + providerId: 'osf', + }, + }; + } +} diff --git a/lib/registries/addon/discover/styles.scss b/lib/registries/addon/discover/styles.scss new file mode 100644 index 0000000000..04033d43ce --- /dev/null +++ b/lib/registries/addon/discover/styles.scss @@ -0,0 +1,97 @@ +// stylelint-disable max-nesting-depth, selector-max-compound-selectors + +.ResultsHeader { + padding: 0 0 15px; + + h2 { + margin: 0; + } +} + +.SortDropDown__List { + background-color: $color-bg-gray-light; + left: auto; +} + +.SortDropDown__Option { + text-align: left; + background-color: transparent; + cursor: pointer; + color: #000; + text-decoration: none; + width: 100%; +} + +.RegistriesSearchResult { + h4 { + font-size: 24px; + font-weight: 400; + } + + svg { + color: var(--primary-color); + } +} + +.search-container { + width: 100%; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + + &.mobile { + flex-direction: column; + + .search-options-container { + width: 100%; + } + + .search-header-container { + width: 100%; + } + } + + .search-options-container { + width: 30%; + } + + .search-header-container { + width: 70%; + padding-left: 15px; + + .no-results { + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .Pagination { + width: 100%; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + padding-right: 12px; + } + + .loading-container, + .error-container { + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding-top: 15px; + padding-bottom: 15px; + } + } +} + +.lead { + margin-bottom: 20px; + font-weight: 300; + line-height: 1.4; +} diff --git a/lib/registries/addon/discover/template.hbs b/lib/registries/addon/discover/template.hbs new file mode 100644 index 0000000000..08744e3e9a --- /dev/null +++ b/lib/registries/addon/discover/template.hbs @@ -0,0 +1,108 @@ +{{page-title (t 'registries.discover.page_title') prepend=false}} + + + + {{registries-header + providerModel=this.providerModel + showHelp=true + value=(mut this.query) + onSearch=(action 'onSearch') + searchable=this.searchable + }} + + {{#registries-discover-search + results=this.results + isLoading=this.doSearch.isIdle + searchOptions=this.searchOptions + additionalFilters=this.additionalFilters + provider=this.providerModel + onSearchOptionsUpdated=(action this.onSearchOptionsUpdated) + as |discover| + }} +
+
+ {{#if this.searchOptions}} + {{#discover.sidebar as |sidebar|}} + {{registries-provider-facet + options=this.filterableSources + searchOptions=this.searchOptions + onSearchOptionsUpdated=sidebar.onSearchOptionsUpdated + provider=this.providerModel + }} + + {{registries-registration-type-facet + searchOptions=this.searchOptions + onSearchOptionsUpdated=sidebar.onSearchOptionsUpdated + provider=this.providerModel + }} + + {{!-- TODO: this feature is not ready yet. Will be implemented in Phase2 Branded Reg --}} + {{!-- {{#if this.providerModel}} + {{registries-subjects-facet + provider=this.providerModel + searchOptions=this.searchOptions + onSearchOptionsUpdated=sidebar.onSearchOptionsUpdated + provider=this.providerModel + }} + {{/if}} --}} + {{/discover.sidebar}} + {{/if}} +
+
+ {{#if (and this.doSearch.lastSuccessful this.searchOptions)}} + {{registries-discover-results-header + totalResults=this.totalResults + searchOptions=this.searchOptions + sortOptions=this.sortOptions + provider=this.providerModel + setOrder=(action this.setOrder) + }} + {{#discover.results + (html-attributes data-test-results=true) + as |result| + }} + + {{/discover.results}} + + {{#unless this.totalResults}} +
+

+ {{t 'registries.discover.no_results'}} +

+ {{t 'registries.discover.try_broadening'}} +
+ {{/unless}} + + {{#if (gt this.maxPage 1) }} +
+ {{search-paginator + current=this.searchOptions.page + maximum=this.maxPage + pageChanged=(action 'changePage') + }} +
+ {{/if}} + {{else if this.doSearch.isError}} +
+ {{t 'registries.discover.error_loading'}} +
+ {{else}} +
+ {{loading-indicator dark=true}} +
+ {{/if}} +
+
+ + {{/registries-discover-search}} +
diff --git a/lib/registries/addon/routes.ts b/lib/registries/addon/routes.ts index 544dd5f814..e26f07fd5b 100644 --- a/lib/registries/addon/routes.ts +++ b/lib/registries/addon/routes.ts @@ -2,6 +2,7 @@ import buildRoutes from 'ember-engines/routes'; export default buildRoutes(function() { this.route('index', { path: '/registries' }); + this.route('discover', { path: '/registries/discover' }); this.route('branded', { path: '/registries/:providerId' }, function() { this.route('discover'); this.route('new'); diff --git a/mirage/scenarios/registrations.ts b/mirage/scenarios/registrations.ts index 9fdd40e580..8517335a35 100644 --- a/mirage/scenarios/registrations.ts +++ b/mirage/scenarios/registrations.ts @@ -49,7 +49,6 @@ export function registrationScenario( server.create('registration-provider', { id: defaultProvider, - brandedDiscoveryPage: false, shareSource: 'OSF Registries', name: 'OSF Registries', }, 'withAllSchemas'); diff --git a/tests/acceptance/institutions/discover-test.ts b/tests/acceptance/institutions/discover-test.ts index f450415bdc..e7925b7322 100644 --- a/tests/acceptance/institutions/discover-test.ts +++ b/tests/acceptance/institutions/discover-test.ts @@ -2,14 +2,14 @@ import { currentURL, visit } from '@ember/test-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { percySnapshot } from 'ember-percy'; import { setBreakpoint } from 'ember-responsive/test-support'; -import { module, test } from 'qunit'; +import { module, skip } from 'qunit'; import { click, setupOSFApplicationTest} from 'ember-osf-web/tests/helpers'; module('Acceptance | institutions | discover', hooks => { setupOSFApplicationTest(hooks); setupMirage(hooks); - test('Desktop: Default colors', async assert => { + skip('Desktop: Default colors', async assert => { server.create('institution', { id: 'has-users', }, 'withMetrics'); @@ -26,7 +26,7 @@ module('Acceptance | institutions | discover', hooks => { await percySnapshot(assert); }); - test('Mobile: Default colors', async assert => { + skip('Mobile: Default colors', async assert => { setBreakpoint('mobile'); server.create('institution', { id: 'has-users', diff --git a/tests/acceptance/preprints/discover-test.ts b/tests/acceptance/preprints/discover-test.ts index 539143a02e..0804d81b7e 100644 --- a/tests/acceptance/preprints/discover-test.ts +++ b/tests/acceptance/preprints/discover-test.ts @@ -4,7 +4,7 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import { TestContext } from 'ember-test-helpers'; import { percySnapshot } from 'ember-percy'; import { setBreakpoint } from 'ember-responsive/test-support'; -import { module, test } from 'qunit'; +import { module, skip } from 'qunit'; import { setupOSFApplicationTest, visit } from 'ember-osf-web/tests/helpers'; import PreprintProviderModel from 'ember-osf-web/models/preprint-provider'; @@ -28,7 +28,7 @@ module('Acceptance | preprints | discover', hooks => { this.provider = provider; }); - test('Desktop', async function(this: PreprintDiscoverTestContext, assert) { + skip('Desktop', async function(this: PreprintDiscoverTestContext, assert) { await visit(`/preprints/${this.provider.id}/discover`); assert.equal(currentRouteName(), 'preprints.discover', 'Current route is preprints discover'); const pageTitle = document.getElementsByTagName('title')[0].innerText; @@ -41,7 +41,7 @@ module('Acceptance | preprints | discover', hooks => { await percySnapshot(assert); }); - test('mobile', async function(this: PreprintDiscoverTestContext, assert) { + skip('mobile', async function(this: PreprintDiscoverTestContext, assert) { setBreakpoint('mobile'); await visit(`/preprints/${this.provider.id}/discover`); assert.equal(currentRouteName(), 'preprints.discover', 'Current route is preprints discover'); diff --git a/tests/engines/registries/acceptance/branded/discover-test.ts b/tests/engines/registries/acceptance/branded/discover-test.ts index 018f11304c..eb96a69feb 100644 --- a/tests/engines/registries/acceptance/branded/discover-test.ts +++ b/tests/engines/registries/acceptance/branded/discover-test.ts @@ -27,10 +27,30 @@ module('Registries | Acceptance | branded.discover', hooks => { server.createList('registration', 3, { provider: this.brandedProvider }); }); - test('branded discover page renders', async function(this: ThisTestContext, assert) { + test('branded discover with no external providers', async function(this: ThisTestContext, assert) { await visit(`/registries/${this.brandedProvider.id}/discover`); await percySnapshot('branded discover page'); assert.equal(currentRouteName(), 'registries.branded.discover', 'On the branded discover page'); + + assert.dom('[data-test-active-filter]').doesNotExist('The given provider is not shown as an active filter'); + assert.dom('[data-test-source-filter-id]').exists({ count: 1 }, 'Only one provider is available'); + assert.dom('[data-test-source-filter-id]').isChecked('Provider facet checkbox is checked'); + assert.dom('[data-test-source-filter-id]').isDisabled('Provider facet checkbox is disabled'); + assert.dom('[data-test-link-other-registries]').exists('Link to other registries is shown'); + assert.dom('[data-test-provider-description]').containsText('Find out more', 'Provider description exists'); + assert.dom('[data-test-provider-description] a').exists('There is a link in the provider description'); + assert.ok(document.querySelector('link[rel="icon"][href="fakelink"]')); + }); + + test('branded discover with external providers', async function(this: ThisTestContext, assert) { + const externalProvider = server.create('external-provider', { shareSource: 'ClinicalTrials.gov' }); + server.createList('external-registration', 3, { provider: externalProvider }); + + await visit(`/registries/${this.brandedProvider.id}/discover`); + assert.dom('[data-test-source-filter-id]').exists({ count: 1 }, 'Only brand provider is shown'); + assert.dom(`[data-test-source-filter-id="${externalProvider.shareSource}"]`) + .doesNotExist('External provider is not shown'); + assert.ok(document.querySelector('link[rel="icon"][href="fakelink"]')); }); test('redirects branded.index to branded.discover', async function(this: ThisTestContext, assert) { @@ -38,6 +58,7 @@ module('Registries | Acceptance | branded.discover', hooks => { assert.equal(currentRouteName(), 'registries.branded.discover', 'successfully redirects index to discover'); + assert.dom(`[data-test-source-filter-id="${this.brandedProvider.shareSource}"]`).exists({ count: 1 }); }); test('redirects', async assert => { @@ -57,12 +78,12 @@ module('Registries | Acceptance | branded.discover', hooks => { await visit(`/registries/${osfProvider.id}/discover`); assert.equal(currentRouteName(), - 'search', - '/registries/osf/discover redirects to /search'); + 'registries.discover', + '/registries/osf/discover redirects to registries/discover'); await visit(`/registries/${osfProvider.id}`); assert.equal(currentRouteName(), - 'search', - '/registries/osf redirects to /search'); + 'registries.discover', + '/registries/osf redirects to registries/discover'); }); }); diff --git a/tests/engines/registries/acceptance/discover-page-test.ts b/tests/engines/registries/acceptance/discover-page-test.ts new file mode 100644 index 0000000000..0f51b65892 --- /dev/null +++ b/tests/engines/registries/acceptance/discover-page-test.ts @@ -0,0 +1,86 @@ +import { click, fillIn } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { t } from 'ember-intl/test-support'; +import { percySnapshot } from 'ember-percy'; +import { module, test } from 'qunit'; + +import { visit } from 'ember-osf-web/tests/helpers'; +import { setupEngineApplicationTest } from 'ember-osf-web/tests/helpers/engines'; + +module('Registries | Acceptance | aggregate discover', hooks => { + setupEngineApplicationTest(hooks, 'registries'); + setupMirage(hooks); + + hooks.beforeEach(() => { + const osfProvider = server.create('registration-provider', { id: 'osf' }); + const anotherProvider = server.create('registration-provider', { id: 'another' }); + const externalProvider = server.create('external-provider', { shareSource: 'ClinicalTrials.gov' }); + server.createList('external-registration', 3, { provider: externalProvider }); + server.createList('registration', 3, { provider: osfProvider }); + server.createList('registration', 3, { provider: anotherProvider }); + }); + + test('page renders with all functionality', async assert => { + await visit('/registries/discover'); + await click('[data-test-sort-dropdown]'); + await percySnapshot('happy path'); + + const osfProvider = server.schema.registrationProviders.find('osf'); + const registrationIds = server.schema.registrations.all().models.map(item => item.id); + for (const id of registrationIds) { + assert.dom(`[data-test-result-title-id="${id}"]`).exists(); + } + assert.dom('[data-test-sort-dropdown]').exists('Sort dropdown exists'); + assert.dom('[data-test-active-filter]').doesNotExist('No filters are applied by default'); + assert.dom('[data-test-source-filter-id]').exists({ count: 3 }, 'Three sources exist'); + assert.dom('[data-test-page-number]').doesNotExist('No pagination for less than 10 registrations'); + + const searchableReg = server.schema.registrations.first(); + await fillIn('[data-test-search-box]', searchableReg.title); + assert.dom(`[data-test-result-title-id='${searchableReg.id}']`).exists('Search shows appropriate result'); + + await fillIn('[data-test-search-box]', ''); + + await click(`[data-test-source-filter-id="${osfProvider.shareSource}"]`); + assert.dom('[data-test-result-title-id]').exists({ count: 3 }, 'Provider filter works'); + + await fillIn('[data-test-search-box]', 'kjafnsdflkjhsdfnasdkndfa random string'); + assert.dom('[data-test-no-results-placeholder]').hasText(t('registries.discover.no_results')); + assert.dom('[data-test-result-title-id]').doesNotExist('No results rendered'); + }); + + test('paginator works', async assert => { + server.createList('registration', 2, { provider: server.schema.registrationProviders.first() }); + + await visit('/registries/discover/'); + + // Count is 4 including previous and next buttons + assert.dom('[data-test-page-number]').exists({ count: 4 }, 'Exactly two pages of results'); + assert.dom('[data-test-page-number="1"]').exists(); + assert.dom('[data-test-page-number="2"]').exists(); + assert.dom('[data-test-results-count]').hasText(t('registries.discover.registration_count', { count: 11 })); + + assert.dom('[data-test-result-title-id]').exists({ count: 10 }, 'First page has correct number of results'); + + await click('[data-test-page-number="2"]'); + assert.dom('[data-test-result-title-id]').exists({ count: 1 }, 'Second page has correct number of results'); + }); + + test('initial state from query params', async assert => { + const anotherProvider = server.schema.registrationProviders.find('another'); + const searchableReg = anotherProvider.registrations.models[0]; + + await visit(`/registries/discover?provider=${anotherProvider.shareSource}&q=${searchableReg.title}`); + + await percySnapshot('with initial query params'); + + assert.dom('[data-test-search-box]').hasValue(searchableReg.title, 'Search box has initial value'); + + assert.dom(`[data-test-source-filter-id='${anotherProvider.shareSource}']`).isChecked(); + assert.dom( + `[data-test-source-filter-id]:not([data-test-source-filter-id='${anotherProvider.shareSource}'])`, + ).isNotChecked(); + + assert.dom('[data-test-result-title-id]').exists({ count: 1 }, 'Initial search uses initial params'); + }); +}); diff --git a/tests/engines/registries/acceptance/landing-page-test.ts b/tests/engines/registries/acceptance/landing-page-test.ts index f7f755250b..1231065a1c 100644 --- a/tests/engines/registries/acceptance/landing-page-test.ts +++ b/tests/engines/registries/acceptance/landing-page-test.ts @@ -28,4 +28,10 @@ module('Registries | Acceptance | landing page', hooks => { assert.dom('[data-test-search-box]').exists(); await percySnapshot(assert); }); + + test('visiting /registries/discover', async assert => { + await visit('/registries/discover/'); + assert.dom('[data-test-results]').exists(); + await percySnapshot(assert); + }); }); diff --git a/tests/engines/registries/integration/discover/discover-test.ts b/tests/engines/registries/integration/discover/discover-test.ts new file mode 100644 index 0000000000..84279fc153 --- /dev/null +++ b/tests/engines/registries/integration/discover/discover-test.ts @@ -0,0 +1,380 @@ +import { click, fillIn, getRootElement, triggerEvent } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { TestContext } from 'ember-test-helpers'; +import { OrderedSet, ValueObject } from 'immutable'; +import { module, test } from 'qunit'; +import sinon from 'sinon'; + +import { setupOSFApplicationTest, visit } from 'ember-osf-web/tests/helpers'; +import { loadEngine } from 'ember-osf-web/tests/helpers/engines'; +import param from 'ember-osf-web/utils/param'; + +import { SearchOptions, SearchOrder, SearchResults } from 'registries/services/search'; +import ShareSearch, { + ShareRegistration, + ShareTermsAggregation, + ShareTermsFilter, +} from 'registries/services/share-search'; + +const equals = (expected: ValueObject) => sinon.match((x: any) => expected.equals(x)); + +const emptyResults: SearchResults = { + total: 0, + results: [], + aggregations: { + sources: { + buckets: [], + }, + }, +}; + +// Default page ordering +const order = new SearchOrder({ + ascending: true, + display: 'registries.discover.order.relevance', + key: undefined, +}); + +const QueryParamTestCases: Array<{ + name: string, + params: { [key: string]: any }, + expected: { [key: string]: any }, + }> = [{ + name: 'Order by date_modified if no additional options are specified', + params: {}, + expected: { + query: '', + order: new SearchOrder({ + display: 'registries.discover.order.relevance', + ascending: false, + key: 'date_modified', + }), + }, + }, { + name: 'Basic query parameters', + params: { q: 'What', page: 10 }, + expected: { order, query: 'What', page: 10 }, + }, { + name: 'Providers Filters', + params: { q: 'Foo', provider: 'OSF Registries' }, + expected: { + order, + query: 'Foo', + filters: OrderedSet([ + new ShareTermsFilter('sources', 'OSF Registries', 'OSF Registries'), + ]), + }, + }, { + name: 'Multiple Providers Filters With Validation', + params: { q: 'Foo', provider: 'OSF Registries|ClinicalTrials.gov|Bar' }, + expected: { + order, + query: 'Foo', + filters: OrderedSet([ + new ShareTermsFilter('sources', 'OSF Registries', 'OSF Registries'), + new ShareTermsFilter('sources', 'ClinicalTrials.gov', 'ClinicalTrials.gov'), + ]), + }, + }, { + name: 'Sort', + params: { sort: 'date' }, + expected: { + query: '', + order: new SearchOrder({ + ascending: true, + display: 'registries.discover.order.modified_ascending', + key: 'date', + }), + }, + }, { + name: 'Sort decending', + params: { sort: '-date' }, + expected: { + query: '', + order: new SearchOrder({ + ascending: false, + display: 'registries.discover.order.modified_descending', + key: 'date', + }), + }, + }, { + name: 'Sort validation', + params: { q: 'Not Empty', sort: '-date_moodified' }, + expected: { order, query: 'Not Empty' }, + }, { + name: 'Registration Types without OSF', + params: { q: 'What', page: 10, type: 'Foo|BAR' }, + expected: { order, query: 'What', page: 10 }, + }, { + // NOTE: Not currently validated :( + name: 'Registration Types', + params: { q: 'What', page: 10, provider: 'OSF Registries', type: 'Foo|BAR' }, + expected: { + order, + query: 'What', + page: 10, + filters: OrderedSet([ + new ShareTermsFilter('sources', 'OSF Registries', 'OSF Registries'), + new ShareTermsFilter('registration_type', 'Foo', 'Foo'), + new ShareTermsFilter('registration_type', 'BAR', 'BAR'), + ]), + }, + }]; + +module('Registries | Integration | discover', hooks => { + setupOSFApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function(this: TestContext) { + server.create('registration-schema', { name: 'Open Ended' }); + server.create('registration-schema', { name: 'Close Fronted' }); + server.create('registration-provider', { + id: 'osf', + shareSource: 'OSF Registries', + name: 'OSF Registries', + }); + server.create('registration-provider', { + id: 'someother', + shareSource: 'someother', + name: 'Some Other', + }); + server.create('registration-provider', { + id: 'clinicaltrials', + shareSource: 'ClinicalTrials.gov', + name: 'ClinicalTrials.gov', + }); + + const engine = await loadEngine('registries', 'registries'); + + const shareSearch = ShareSearch.create(); + + engine.register('service:share-search', shareSearch, { instantiate: false }); + this.owner.register('service:share-search', shareSearch, { instantiate: false }); + }); + + test('query parameters', async function(this: TestContext, assert: Assert) { + const stub = sinon.stub(this.owner.lookup('service:share-search'), 'registrations').returns(emptyResults); + + // Initial load so we don't have to deal with the aggregations loading + await visit('/registries/discover'); + assert.ok(stub.calledTwice, 'stub called twice upon discover page load'); + // Aggregations on load + assert.ok(stub.calledWith(new SearchOptions({ + size: 0, + modifiers: OrderedSet([ + new ShareTermsAggregation('sources', 'sources'), + ]), + })), 'stub called with the expected arguments'); + + for (const url of ['/--registries/registries/discover', '/registries/discover']) { + for (const testCase of QueryParamTestCases) { + stub.reset(); + stub.returns(emptyResults); + const params = param(testCase.params); + + await visit(`${url}?${params}`); + + assert.ok(true, testCase.name); + assert.ok(stub.calledOnce, 'stub called once'); + assert.ok( + stub.calledWith(new SearchOptions(testCase.expected)), + `stub called with the expected arguments for: url: ${url}, params: ${params}`, + ); + } + } + }); + + test('page resets on filtering', async function(this: TestContext, assert) { + assert.expect(3); + const stub = sinon.stub(this.owner.lookup('service:share-search'), 'registrations').returns({ + total: 0, + results: [], + aggregations: { + sources: { + buckets: [ + { key: 'OSF Registries', doc_count: 10 }, + { key: 'someother', doc_count: 10 }, + { key: 'clinicaltrials', doc_count: 10 }, + ], + }, + }, + }); + + await visit('/registries/discover?page=10'); + + assert.ok(stub.calledWith(new SearchOptions({ + query: '', + page: 10, + order: new SearchOrder({ + display: 'registries.discover.order.relevance', + ascending: false, + key: 'date_modified', + }), + })), '/registries/discover?page=10: stub called with expected arguments'); + + await click('[data-test-source-filter-id="OSF Registries"]'); + + assert.ok(stub.calledWith(new SearchOptions({ + query: '', + page: 1, + order, + filters: OrderedSet([ + new ShareTermsFilter('sources', 'OSF Registries', 'OSF Registries'), + ]), + })), 'stub called with expected arguments'); + }); + + test('page resets on sorting', async function(this: TestContext, assert) { + assert.expect(3); + const stub = sinon.stub(this.owner.lookup('service:share-search'), 'registrations').returns({ + total: 0, + results: [], + aggregations: { + sources: { + buckets: [{ key: 'OSF Registries', doc_count: 10 }], + }, + }, + }); + + await visit('/registries/discover?page=10'); + + assert.ok(stub.calledWith(new SearchOptions({ + query: '', + page: 10, + order: new SearchOrder({ + display: 'registries.discover.order.relevance', + ascending: false, + key: 'date_modified', + }), + })), 'stub for service:share-search.registrations called with expected arguments'); + + await click('[data-test-sort-dropdown]'); + await click('[data-test-sort-option-id="1"]'); + + assert.ok(stub.calledWith(new SearchOptions({ + query: '', + page: 1, + order: new SearchOrder({ + ascending: true, + display: 'registries.discover.order.modified_ascending', + key: 'date', + }), + })), 'stub for service:share-search.registrations called with expected arguments'); + }); + + test('page resets on typing query', async function(this: TestContext, assert) { + assert.expect(3); + const stub = sinon.stub(this.owner.lookup('service:share-search'), 'registrations').returns({ + total: 0, + results: [], + aggregations: { + sources: { + buckets: [{ key: 'OSF Registries', doc_count: 10 }], + }, + }, + }); + + await visit('/registries/discover?page=10'); + + assert.ok(stub.calledWith(equals(new SearchOptions({ + query: '', + page: 10, + order: new SearchOrder({ + display: 'registries.discover.order.relevance', + ascending: false, + key: 'date_modified', + }), + })))); + + await fillIn('[data-test-search-box]', 'Test Query'); + + assert.ok(stub.calledWith(equals(new SearchOptions({ + query: 'Test Query', + page: 1, + order: new SearchOrder({ + display: 'registries.discover.order.relevance', + ascending: true, + key: undefined, + }), + })))); + }); + + test('page resets on clicking search', async function(this: TestContext, assert) { + assert.expect(3); + sinon.stub(this.owner.lookup('service:analytics'), 'click'); + const stub = sinon.stub(this.owner.lookup('service:share-search'), 'registrations').returns({ + total: 0, + results: [], + aggregations: { + sources: { + buckets: [{ key: 'OSF Registries', doc_count: 10 }], + }, + }, + }); + + await visit('/registries/discover?page=10&q=Testing'); + + assert.ok(stub.calledWith(equals(new SearchOptions({ + query: 'Testing', + page: 10, + order: new SearchOrder({ + display: 'registries.discover.order.relevance', + ascending: true, + key: undefined, + }), + })))); + + await triggerEvent('[data-test-search-form]', 'submit'); + + assert.ok(stub.calledWith(equals(new SearchOptions({ + query: 'Testing', + page: 1, + order: new SearchOrder({ + display: 'registries.discover.order.relevance', + ascending: true, + key: undefined, + }), + })))); + }); + + test('scroll top on pagination', async function(this: TestContext, assert: Assert) { + assert.expect(3); + const results = { + total: 21, + results: Array(21).fill({ + title: 'place holder', + description: 'place holder', + contributors: [], + mainLink: 'fakeLink', + }), + aggregations: { + sources: { + buckets: [], + }, + }, + }; + + const stub = sinon.stub(this.owner.lookup('service:share-search'), 'registrations').returns(results); + + await visit('/registries/discover'); + + stub.reset(); + stub.returns(results); + + const resultsEl = getRootElement().querySelector('[data-test-results]')! as HTMLElement; + + const initialTopPosition = resultsEl.getBoundingClientRect().top; + await click('[data-test-page-number="2"]'); + const currentTopPosition = resultsEl.getBoundingClientRect().top; + assert.ok(currentTopPosition < initialTopPosition, 'we have scrolled'); + assert.ok(stub.calledWith(new SearchOptions({ + query: '', + page: 2, + order: new SearchOrder({ + display: 'registries.discover.order.relevance', + ascending: false, + key: 'date_modified', + }), + })), 'stub called with expected arguments'); + }); +});