From 4ecf7ae5cdabbdf274660d32d34002f4c1c648b7 Mon Sep 17 00:00:00 2001 From: Lukas Trombach Date: Thu, 1 Dec 2022 17:09:51 +1300 Subject: [PATCH 1/2] Add Capabilities page type (#365) * add initial modules and components * update search lambda * update and add new graphQL queries * add capability type to standard card * add components to routes * add capability list * reuse article default banner for capability card for now * add capability display name to pipe * first version of capability page * make tests runnable * add capability page type to search types * add navbar link to subhub * add capability list unit test * make e2e tests runnable * fix unit test * fix unit tests * add capability unit test * decapitalised navbar link * added new card background for capability * add capability e2e tests and fixture * fix capability not showing in search results * fix e2e test * add capability type to content graph * remove navbar link to be added later * fix standard card default image loading * lowercase sign in/out * move support materials to the top * fix unit test * fix navbar e2e test * move contacts to the top * minor fix for standard images * simplify standard card component * add comment explaining image height --- hub-search-proxy/handler.js | 858 +++++++++--------- .../cypress/fixtures/capability.json | 288 ++++++ .../cypress/integration/capability.e2e.ts | 57 ++ .../cypress/integration/navbar.e2e.ts | 113 +-- .../cypress/integration/routing.e2e.ts | 95 +- .../capability-list.component.html | 1 + .../capability-list.component.scss | 0 .../capability-list.component.spec.ts | 76 ++ .../capability-list.component.ts | 40 + .../capabilitys/capability-routing.module.ts | 16 + .../capability/capability.component.html | 205 +++++ .../capability/capability.component.scss | 39 + .../capability/capability.component.spec.ts | 96 ++ .../capability/capability.component.ts | 136 +++ .../capabilitys/capabilitys.module.ts | 25 + .../standard-card.component.html | 24 +- .../standard-card.component.scss | 3 +- .../standard-card.component.spec.ts | 2 + .../standard-card/standard-card.component.ts | 53 +- .../color-legend/color-legend.component.ts | 2 +- .../graph-container.component.ts | 2 +- .../graph-layout/graph-layout.component.ts | 3 +- .../layout/navbar/navbar.component.html | 4 +- .../layout/navbar/navbar.component.scss | 5 + .../search-bar/search-bar.component.spec.ts | 11 +- .../layout/search-bar/search-bar.component.ts | 7 +- .../search-page/search-page.component.ts | 4 +- .../collection-list.component.ts | 4 +- .../src/app/global/searchTypes.ts | 8 +- .../fragments/public-fields.fragment.graphql | 19 +- .../queries/all-capabilities.query.graphql | 7 + .../queries/all-page-titles.query.graphql | 7 +- ...all-subhub-child-pages-slugs.query.graphql | 3 + .../queries/get-asset-by-id.query.graphql | 5 + .../get-capability-by-slug.query.graphql | 74 ++ .../pipes/content-type-display-name.pipe.ts | 1 + research-hub-web/src/app/routing/routing.ts | 4 + .../src/app/services/body-media.service.ts | 3 +- .../src/app/services/content-graph.service.ts | 1 - .../search-autocomplete.service.spec.ts | 3 +- .../services/search-autocomplete.service.ts | 27 +- .../src/partial-styles/_variables.scss | 2 +- 42 files changed, 1727 insertions(+), 606 deletions(-) create mode 100644 research-hub-web/cypress/fixtures/capability.json create mode 100644 research-hub-web/cypress/integration/capability.e2e.ts create mode 100644 research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.html create mode 100644 research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.scss create mode 100644 research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.spec.ts create mode 100644 research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.ts create mode 100644 research-hub-web/src/app/components/capabilitys/capability-routing.module.ts create mode 100644 research-hub-web/src/app/components/capabilitys/capability/capability.component.html create mode 100644 research-hub-web/src/app/components/capabilitys/capability/capability.component.scss create mode 100644 research-hub-web/src/app/components/capabilitys/capability/capability.component.spec.ts create mode 100644 research-hub-web/src/app/components/capabilitys/capability/capability.component.ts create mode 100644 research-hub-web/src/app/components/capabilitys/capabilitys.module.ts create mode 100644 research-hub-web/src/app/graphql/queries/all-capabilities.query.graphql create mode 100644 research-hub-web/src/app/graphql/queries/get-asset-by-id.query.graphql create mode 100644 research-hub-web/src/app/graphql/queries/get-capability-by-slug.query.graphql diff --git a/hub-search-proxy/handler.js b/hub-search-proxy/handler.js index a5dc38889..31c3f43dd 100644 --- a/hub-search-proxy/handler.js +++ b/hub-search-proxy/handler.js @@ -14,31 +14,31 @@ const contentfulEnv = process.env.CONTENTFUL_ENVIRONMENT; const region = 'ap-southeast-2'; const deliveryApiClient = contentful.createClient({ - space: spaceId, - accessToken: token + space: spaceId, + accessToken: token }) -const VALID_CONTENT_TYPES = ['article','casestudy','equipment','event', 'funding', 'service','software','subhub']; +const VALID_CONTENT_TYPES = ['article', 'casestudy', 'capability', 'equipment', 'event', 'funding', 'service', 'software', 'subhub']; let credentials; try { - // try getting credentials for the lambda from the AWS environment - credentials = new AWS.EnvironmentCredentials('AWS'); - if (!credentials.sessionToken) { - // we may be running this locally or from Jenkins, so try to get the credentials - // from the local environment instead - credentials = new AWS.SharedIniFileCredentials({profile: process.env.PROFILE}); - } - if (!credentials.sessionToken) { - throw new Error("Couldn't get credentials"); - } -} catch(error) { - console.log(`Error getting AWS credentials: ${error}`); + // try getting credentials for the lambda from the AWS environment + credentials = new AWS.EnvironmentCredentials('AWS'); + if (!credentials.sessionToken) { + // we may be running this locally or from Jenkins, so try to get the credentials + // from the local environment instead + credentials = new AWS.SharedIniFileCredentials({ profile: process.env.PROFILE }); + } + if (!credentials.sessionToken) { + throw new Error("Couldn't get credentials"); + } +} catch (error) { + console.log(`Error getting AWS credentials: ${error}`); } AWS.config.update({ - credentials: credentials, - region: region + credentials: credentials, + region: region }); const esClient = new Client({ @@ -49,451 +49,453 @@ const esClient = new Client({ const ELASTICSEARCH_INDEX_NAME = contentfulEnv; module.exports.search = async (event, context) => { - try { - console.log(`Received query: ${event.body}`); - const requestBody = JSON.parse(event.body); - let queryString = ''; - let size = 10; - let from = 0; - let queryFilters = {}; - let queryFiltersCount = 0; - let contentTypes = VALID_CONTENT_TYPES.slice(); - let sort = []; - - if (requestBody.hasOwnProperty('query')) { - queryString = requestBody.query; - } - if (requestBody.hasOwnProperty('size')) { - size = requestBody.size; - } - if (requestBody.hasOwnProperty('from')) { - from = requestBody.from; - } - if (requestBody.hasOwnProperty('filters')) { - queryFilters = requestBody.filters; - for (let key of Object.keys(queryFilters)) { - queryFiltersCount += queryFilters[key].length - } - } - if (requestBody.hasOwnProperty('includeContentTypes') && requestBody.includeContentTypes.length > 0) { - contentTypes = requestBody.includeContentTypes.map(contentType => contentType.toLowerCase()); - for(const type of contentTypes) { - if (!VALID_CONTENT_TYPES.includes(type)) { - throw new Error(`Received invalid content type: ${type}. Valid types are: ${VALID_CONTENT_TYPES.join()}`); + try { + console.log(`Received query: ${event.body}`); + const requestBody = JSON.parse(event.body); + let queryString = ''; + let size = 10; + let from = 0; + let queryFilters = {}; + let queryFiltersCount = 0; + let contentTypes = VALID_CONTENT_TYPES.slice(); + let sort = []; + + if (requestBody.hasOwnProperty('query')) { + queryString = requestBody.query; } - } - } - if (requestBody.hasOwnProperty('sort')) { - if(!(requestBody.sort === "A-Z" || requestBody.sort === "Z-A" || requestBody.sort === "" || requestBody.sort === "relevance")) { - throw new Error('Sort options are A-Z, Z-A, or relevance.') - } - if (requestBody.sort === "A-Z") { - sort.push({ "fields.title.en-US.raw": "asc" }); - } - if (requestBody.sort === "Z-A") { - sort.push({ "fields.title.en-US.raw": "desc" }); - } - } - - let query; - const includeInResult = [ - "fields.slug", - "fields.title", - "fields.summary", - "fields.ssoProtected", - "fields.searchable", - "fields.keywords", - "sys.contentType", - "fields.category.en-US", - ]; - - if(queryString.length === 0 && queryFiltersCount === 0) { - // query with no filters and no query string (fetch all searchable results) - - query = { - _source: { - includes: includeInResult - }, - query: { - bool: { - must: { - match_all: {} - }, - should: { - multi_match: { - query : "subhub", - fields : [ "sys.contentType.sys.id^2"] // boost match score for entries that are subhubs - } - }, - filter: [ - { - term: { - "fields.searchable.en-US": true - } - }, - { - terms: { - "sys.contentType.sys.id": contentTypes + if (requestBody.hasOwnProperty('size')) { + size = requestBody.size; + } + if (requestBody.hasOwnProperty('from')) { + from = requestBody.from; + } + if (requestBody.hasOwnProperty('filters')) { + queryFilters = requestBody.filters; + for (let key of Object.keys(queryFilters)) { + queryFiltersCount += queryFilters[key].length + } + } + if (requestBody.hasOwnProperty('includeContentTypes') && requestBody.includeContentTypes.length > 0) { + contentTypes = requestBody.includeContentTypes.map(contentType => contentType.toLowerCase()); + for (const type of contentTypes) { + if (!VALID_CONTENT_TYPES.includes(type)) { + throw new Error(`Received invalid content type: ${type}. Valid types are: ${VALID_CONTENT_TYPES.join()}`); } - } - ] - } - }, - sort: sort - }; - - } else if(queryString.length === 0 && queryFiltersCount > 0) { - // query with filters but no query string - - let queryParts = []; - - // add our search filters - for (const filter in queryFilters) { - if(!Array.isArray(queryFilters[filter])) { - throw new Error('Query filters must be in array format.') + } } - for (const id of queryFilters[filter]) { - queryParts.push( - JSON.parse( - `{"match":{"fields.${filter}.en-US.sys.id":"${id}"}}` - ) - ) + if (requestBody.hasOwnProperty('sort')) { + if (!(requestBody.sort === "A-Z" || requestBody.sort === "Z-A" || requestBody.sort === "" || requestBody.sort === "relevance")) { + throw new Error('Sort options are A-Z, Z-A, or relevance.') + } + if (requestBody.sort === "A-Z") { + sort.push({ "fields.title.en-US.raw": "asc" }); + } + if (requestBody.sort === "Z-A") { + sort.push({ "fields.title.en-US.raw": "desc" }); + } } - } - - query = { - _source: { - includes: includeInResult - }, - query: { - function_score: { - query: { - bool: { - minimum_should_match: 1, - should: queryParts, - filter: [ - { - term: { - "fields.searchable.en-US": true + + let query; + const includeInResult = [ + "fields.slug", + "fields.title", + "fields.summary", + "fields.ssoProtected", + "fields.searchable", + "fields.keywords", + "sys.contentType", + "fields.category.en-US", + ]; + + if (queryString.length === 0 && queryFiltersCount === 0) { + // query with no filters and no query string (fetch all searchable results) + + query = { + _source: { + includes: includeInResult + }, + query: { + bool: { + must: { + match_all: {} + }, + should: { + multi_match: { + query: "subhub", + fields: ["sys.contentType.sys.id^2"] // boost match score for entries that are subhubs + } + }, + filter: [ + { + term: { + "fields.searchable.en-US": true + } + }, + { + terms: { + "sys.contentType.sys.id": contentTypes + } + } + ] } - }, - { - terms: { - "sys.contentType.sys.id": contentTypes + }, + sort: sort + }; + + } else if (queryString.length === 0 && queryFiltersCount > 0) { + // query with filters but no query string + + let queryParts = []; + + // add our search filters + for (const filter in queryFilters) { + if (!Array.isArray(queryFilters[filter])) { + throw new Error('Query filters must be in array format.') + } + for (const id of queryFilters[filter]) { + queryParts.push( + JSON.parse( + `{"match":{"fields.${filter}.en-US.sys.id":"${id}"}}` + ) + ) + } + } + + query = { + _source: { + includes: includeInResult + }, + query: { + function_score: { + query: { + bool: { + minimum_should_match: 1, + should: queryParts, + filter: [ + { + term: { + "fields.searchable.en-US": true + } + }, + { + terms: { + "sys.contentType.sys.id": contentTypes + } + } + ] + } + }, + functions: [ + { + filter: { "match": { "sys.contentType.sys.id": "subhub" } }, + weight: 2 // boost match score for entries that are subhubs + } + ] } - } - ] - } - }, - functions: [ - { - filter: {"match":{"sys.contentType.sys.id": "subhub"}}, - weight: 2 // boost match score for entries that are subhubs - } - ] - } - }, - sort: sort - }; - } else { - // there is a query string and may or may not be any filters - - let formattedQueryString = queryString; - - // format the query string for fuzzy search if the query contains less than 5 words. - // Also if the query string contains query operators dont make it fuzzy - if (queryString.split(' ').length < 5 && !containsOperators(queryString)) { - // modify degree of fuzziness here. - // Note: '~AUTO' is not supported currently despite what the docs say. - const fuzziness = '~2'; - - formattedQueryString = queryString.split(' ').map(s => { - // if word is less than 3 letters dont make it fuzzy - return s.length < 3 ? s : s + fuzziness; - }).join(' '); - } - - const simpleQuery = { - simple_query_string: { - query: formattedQueryString, - default_operator: "and", - analyzer: "hub_analyzer" - } - } + }, + sort: sort + }; + } else { + // there is a query string and may or may not be any filters + + let formattedQueryString = queryString; + + // format the query string for fuzzy search if the query contains less than 5 words. + // Also if the query string contains query operators dont make it fuzzy + if (queryString.split(' ').length < 5 && !containsOperators(queryString)) { + // modify degree of fuzziness here. + // Note: '~AUTO' is not supported currently despite what the docs say. + const fuzziness = '~2'; + + formattedQueryString = queryString.split(' ').map(s => { + // if word is less than 3 letters dont make it fuzzy + return s.length < 3 ? s : s + fuzziness; + }).join(' '); + } + + const simpleQuery = { + simple_query_string: { + query: formattedQueryString, + default_operator: "and", + analyzer: "hub_analyzer" + } + } - let queryParts = [] + let queryParts = [] - // add our search filters - for (const filter in queryFilters) { - if(!Array.isArray(queryFilters[filter])) { - throw new Error('Query filters must be in array format.') - } - for (const id of queryFilters[filter]) { - queryParts.push( - JSON.parse( - `{"match":{"fields.${filter}.en-US.sys.id":"${id}"}}` - ) - ) - } - } - - let minimum_should_match = 0; - if (queryParts.length > 0) { minimum_should_match = 1 }; - - query = { - _source: { - includes: includeInResult - }, - query: { - function_score: { - query: { - bool: { - must: simpleQuery, - minimum_should_match: minimum_should_match, - should: queryParts, - filter: [ - { - term: { - "fields.searchable.en-US": true + // add our search filters + for (const filter in queryFilters) { + if (!Array.isArray(queryFilters[filter])) { + throw new Error('Query filters must be in array format.') + } + for (const id of queryFilters[filter]) { + queryParts.push( + JSON.parse( + `{"match":{"fields.${filter}.en-US.sys.id":"${id}"}}` + ) + ) + } + } + + let minimum_should_match = 0; + if (queryParts.length > 0) { minimum_should_match = 1 }; + + query = { + _source: { + includes: includeInResult + }, + query: { + function_score: { + query: { + bool: { + must: simpleQuery, + minimum_should_match: minimum_should_match, + should: queryParts, + filter: [ + { + term: { + "fields.searchable.en-US": true + } + }, + { + terms: { + "sys.contentType.sys.id": contentTypes + } + } + ] + } + }, + functions: [ + { + filter: { "match": { "fields.title.en-US": queryString } }, + weight: 2 // boost match score for titles that contain the individual query terms + }, + { + filter: { "match": { "fields.title.en-US.raw": queryString } }, + weight: 2 // boost match score for titles that contain the exact query terms (the 'raw' field is a keyword field meaning it is not tokenised) + } + ] } - }, - { - terms: { - "sys.contentType.sys.id": contentTypes + }, + sort: sort, + highlight: { + pre_tags: [""], + post_tags: [""], + fragment_size: 300, + highlight_query: { + simple_query_string: { + query: queryString, + default_operator: "and", + analyzer: "hub_analyzer" + } + }, + fields: { + "fields.summary.en-US": {} } - } - ] - } - }, - functions: [ - { - filter: {"match":{"fields.title.en-US": queryString}}, - weight: 2 // boost match score for titles that contain the individual query terms - }, - { - filter: {"match":{"fields.title.en-US.raw": queryString}}, - weight: 2 // boost match score for titles that contain the exact query terms (the 'raw' field is a keyword field meaning it is not tokenised) - } - ] - } - }, - sort: sort, - highlight: { - pre_tags : [""], - post_tags : [""], - fragment_size: 300, - highlight_query: {simple_query_string: { - query: queryString, - default_operator: "and", - analyzer: "hub_analyzer" - }}, - fields: { - "fields.summary.en-US": {} - } + } + }; } - }; - } - const params = { - index: ELASTICSEARCH_INDEX_NAME, - body: query, - size: size, - from: from - } - - const result = await esClient.search(params); - console.log(`Found ${result.body.hits.total.value} results.`); - return formatResponse( - 200, - { - query: queryString, - result: result.body - } - ) - } catch(error) { - let statusCode = 500; - if (error.hasOwnProperty("meta") && error.meta.statusCode) { - statusCode = error.meta.statusCode; + const params = { + index: ELASTICSEARCH_INDEX_NAME, + body: query, + size: size, + from: from + } + + const result = await esClient.search(params); + console.log(`Found ${result.body.hits.total.value} results.`); + return formatResponse( + 200, + { + query: queryString, + result: result.body + } + ) + } catch (error) { + let statusCode = 500; + if (error.hasOwnProperty("meta") && error.meta.statusCode) { + statusCode = error.meta.statusCode; + } + return formatResponse( + statusCode, + { result: `${error.name}: ${error.message}` } + ); } - return formatResponse( - statusCode, - { result: `${error.name}: ${error.message}` } - ); - } } module.exports.update = async (event, context) => { - let doc = JSON.parse(event.body); - const categories = await deliveryApiClient.getEntries({ - content_type: "category", - select: ['sys.id', 'fields.name'] - }); - - // add category names - if (doc.fields.hasOwnProperty('category')) { - for (let item of doc.fields.category['en-US']) { - const cat = categories.items.find((c) => { return c.sys.id === item.sys.id; }); - if (cat) {item['name'] = cat.fields.name;} + let doc = JSON.parse(event.body); + const categories = await deliveryApiClient.getEntries({ + content_type: "category", + select: ['sys.id', 'fields.name'] + }); + + // add category names + if (doc.fields.hasOwnProperty('category')) { + for (let item of doc.fields.category['en-US']) { + const cat = categories.items.find((c) => { return c.sys.id === item.sys.id; }); + if (cat) { item['name'] = cat.fields.name; } + } } - } - - const params = { - id: event.pathParameters.id, - index: ELASTICSEARCH_INDEX_NAME, - body: { - doc: doc, - doc_as_upsert: true // if doc doesn't exist, create it - }, - refresh: 'true' // index refresh - }; - - try { - const result = await esClient.update(params); - console.log(`Processed document id ${result.body._id}: ${result.body.result}.`); - return formatResponse( - 200, - { result: result.body } - ) - } catch(error) { - let statusCode = 500; - if (error.hasOwnProperty("meta") && error.meta.statusCode) { - statusCode = error.meta.statusCode; + + const params = { + id: event.pathParameters.id, + index: ELASTICSEARCH_INDEX_NAME, + body: { + doc: doc, + doc_as_upsert: true // if doc doesn't exist, create it + }, + refresh: 'true' // index refresh + }; + + try { + const result = await esClient.update(params); + console.log(`Processed document id ${result.body._id}: ${result.body.result}.`); + return formatResponse( + 200, + { result: result.body } + ) + } catch (error) { + let statusCode = 500; + if (error.hasOwnProperty("meta") && error.meta.statusCode) { + statusCode = error.meta.statusCode; + } + return formatResponse( + statusCode, + { result: `${error.name}: ${error.message}` } + ); } - return formatResponse( - statusCode, - { result: `${error.name}: ${error.message}` } - ); - } } module.exports.delete = async (event, context) => { - const params = { - id: event.pathParameters.id, - index: ELASTICSEARCH_INDEX_NAME, - refresh: 'true' // index refresh - }; - - try { - const result = await esClient.delete(params); - console.log(`Processed document id ${result.body._id}: ${result.body.result}.`); - return formatResponse( - 200, - { result: result.body } - ) - } catch(error) { - let statusCode = 500; - if (error.hasOwnProperty("meta") && error.meta.statusCode) { - statusCode = error.meta.statusCode; + const params = { + id: event.pathParameters.id, + index: ELASTICSEARCH_INDEX_NAME, + refresh: 'true' // index refresh + }; + + try { + const result = await esClient.delete(params); + console.log(`Processed document id ${result.body._id}: ${result.body.result}.`); + return formatResponse( + 200, + { result: result.body } + ) + } catch (error) { + let statusCode = 500; + if (error.hasOwnProperty("meta") && error.meta.statusCode) { + statusCode = error.meta.statusCode; + } + return formatResponse( + statusCode, + { result: `${error.name}: ${error.message}` } + ); } - return formatResponse( - statusCode, - { result: `${error.name}: ${error.message}` } - ); - } } /** * Bulk upload handler */ -module.exports.bulk = async () => { - let validEntries; - const categories = await deliveryApiClient.getEntries({ - content_type: "category", - select: ['sys.id', 'fields.name'] - }); - - try { - // contentful export and filter entries - console.log('Exporting data from Contentful space id: ' + spaceId + ', environment: ' + contentfulEnv); - const options = { - spaceId: spaceId, - managementToken: mgmtToken, - environmentId: contentfulEnv, - contentOnly: true, - downloadAssets: false, - saveFile: false, - maxAllowedLimit: 100 - }; - const contentfulData = await contentfulExport(options); - validEntries = contentfulData.entries.filter( - entry => VALID_CONTENT_TYPES.includes(entry.sys.contentType.sys.id.toLowerCase()) - ); - - console.log(`Found ${validEntries.length} entries to upload.`); - - console.log('Transforming entries...'); - for(let entry of validEntries) { - - // add category names - if (entry.fields.hasOwnProperty('category')) { - for (let item of entry.fields.category['en-US']) { - const cat = categories.items.find((c) => { return c.sys.id === item.sys.id; }); - if (cat) {item['name'] = cat.fields.name;} +module.exports.bulk = async () => { + let validEntries; + const categories = await deliveryApiClient.getEntries({ + content_type: "category", + select: ['sys.id', 'fields.name'] + }); + + try { + // contentful export and filter entries + console.log('Exporting data from Contentful space id: ' + spaceId + ', environment: ' + contentfulEnv); + const options = { + spaceId: spaceId, + managementToken: mgmtToken, + environmentId: contentfulEnv, + contentOnly: true, + downloadAssets: false, + saveFile: false, + maxAllowedLimit: 100 + }; + const contentfulData = await contentfulExport(options); + validEntries = contentfulData.entries.filter( + entry => VALID_CONTENT_TYPES.includes(entry.sys.contentType.sys.id.toLowerCase()) + ); + + console.log(`Found ${validEntries.length} entries to upload.`); + + console.log('Transforming entries...'); + for (let entry of validEntries) { + + // add category names + if (entry.fields.hasOwnProperty('category')) { + for (let item of entry.fields.category['en-US']) { + const cat = categories.items.find((c) => { return c.sys.id === item.sys.id; }); + if (cat) { item['name'] = cat.fields.name; } + } + } + }; + + // perform the upload + console.log(`Uploading documents to index: ${ELASTICSEARCH_INDEX_NAME}`); + const bulkBody = validEntries.flatMap((doc) => [ + { update: { _index: ELASTICSEARCH_INDEX_NAME, _id: doc.sys.id } }, + { doc: doc, doc_as_upsert: true }, + ]); + const { body: bulkResponse } = await esClient.bulk({ refresh: true, body: bulkBody }); + const erroredDocuments = [] + if (bulkResponse.errors) { + // The items array has the same order of the dataset we just indexed. + // The presence of the `error` key indicates that the operation + // that we did for the document has failed. + bulkResponse.items.forEach((action, i) => { + const operation = Object.keys(action)[0] + if (action[operation].error) { + erroredDocuments.push({ + // If the status is 429 it means that you can retry the document, + // otherwise it's very likely a mapping error, and you should + // fix the document before to try it again. + status: action[operation].status, + error: action[operation].error, + operation: body[i * 2], + document: body[i * 2 + 1] + }) + } + }) } - } - }; - - // perform the upload - console.log(`Uploading documents to index: ${ELASTICSEARCH_INDEX_NAME}`); - const bulkBody = validEntries.flatMap((doc) => [ - { update: { _index: ELASTICSEARCH_INDEX_NAME, _id: doc.sys.id } }, - { doc: doc, doc_as_upsert: true }, - ]); - const { body: bulkResponse } = await esClient.bulk({ refresh: true, body: bulkBody }); - const erroredDocuments = [] - if (bulkResponse.errors) { - // The items array has the same order of the dataset we just indexed. - // The presence of the `error` key indicates that the operation - // that we did for the document has failed. - bulkResponse.items.forEach((action, i) => { - const operation = Object.keys(action)[0] - if (action[operation].error) { - erroredDocuments.push({ - // If the status is 429 it means that you can retry the document, - // otherwise it's very likely a mapping error, and you should - // fix the document before to try it again. - status: action[operation].status, - error: action[operation].error, - operation: body[i * 2], - document: body[i * 2 + 1] - }) + + return formatResponse( + 200, + { + total: validEntries.length, + result: bulkResponse, + erroredDocuments: erroredDocuments + } + ) + } catch (error) { + let statusCode = 500; + if (error.hasOwnProperty("meta") && error.meta.statusCode) { + statusCode = error.meta.statusCode; } - }) + return formatResponse( + statusCode, + { result: `${error.name}: ${error.message}` } + ); } - - return formatResponse( - 200, - { - total: validEntries.length, - result: bulkResponse, - erroredDocuments: erroredDocuments - } - ) - } catch(error) { - let statusCode = 500; - if (error.hasOwnProperty("meta") && error.meta.statusCode) { - statusCode = error.meta.statusCode; - } - return formatResponse( - statusCode, - { result: `${error.name}: ${error.message}` } - ); - } } function formatResponse(status, body) { - return { - isBase64Encoded: false, - statusCode: status, - body: JSON.stringify(body), - headers: { - "Access-Control-Allow-Origin": process.env.CORS_ACCESS_CONTROL_ALLOW_ORIGINS, - "Content-Type": "application/json" - } - } + return { + isBase64Encoded: false, + statusCode: status, + body: JSON.stringify(body), + headers: { + "Access-Control-Allow-Origin": process.env.CORS_ACCESS_CONTROL_ALLOW_ORIGINS, + "Content-Type": "application/json" + } + } } function containsOperators(queryString) { - const queryOperators = ['+', '|', '-', '*', '"', '(', ')']; - return queryOperators.some(operator => queryString.includes(operator)); + const queryOperators = ['+', '|', '-', '*', '"', '(', ')']; + return queryOperators.some(operator => queryString.includes(operator)); } \ No newline at end of file diff --git a/research-hub-web/cypress/fixtures/capability.json b/research-hub-web/cypress/fixtures/capability.json new file mode 100644 index 000000000..3eb8364f1 --- /dev/null +++ b/research-hub-web/cypress/fixtures/capability.json @@ -0,0 +1,288 @@ +{ + "data": { + "capabilityCollection": { + "items": [ + { + "__typename": "Capability", + "sys": { + "id": "5fzsvyDauIzdkPINoGe9xM", + "__typename": "Sys" + }, + "title": "Research impact", + "maoriProverb": "external capability offering", + "slug": "research-impact", + "ssoProtected": false, + "searchable": true, + "callToAction": "https://research-hub-preview.auckland.ac.nz/research-project-management/identify-explore-and-create-opportunities", + "callToActionLabel": "Test label", + "banner": { + "url": "https://images.ctfassets.net/vbuxn5csp0ik/dLNmMgxMJVJjdDATTpWZn/433ae5de80f78868c4fb37a256ed2801/1500_UoA_13Oct09_001.jpg", + "__typename": "Asset" + }, + "summary": "Research Impact is \"The contribution that research and creative practice makes to society, the environment and the economy\".", + "bodyText": { + "json": { + "data": {}, + "content": [ + { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "Research Impact is \"The contribution that research and creative practice makes to society, the environment and the economy\".", + "nodeType": "text" + } + ], + "nodeType": "paragraph" + }, + { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "It embraces the diverse ways in which knowledge and outputs generated by research benefits individuals, whānau, communities, organisations, Aotearoa New Zealand and the world. You can think of research impact as your research making a difference to people's lives.", + "nodeType": "text" + } + ], + "nodeType": "paragraph" + }, + { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "Research impact research skill and development offerings will help you to plan, design and undertake your research so that it contributes to your discipline/research area, the University and also to the wider aims of all stakeholders, the public and the business sector.", + "nodeType": "text" + } + ], + "nodeType": "paragraph" + }, + { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "Below you will find useful resources, training, and events to build your research impact skills and achieve impact.", + "nodeType": "text" + } + ], + "nodeType": "paragraph" + }, + { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "You can find recordings of previous training seminars and details of upcoming events — sign up to the ", + "nodeType": "text" + }, + { + "data": { + "uri": "https://research-hub.auckland.ac.nz/article/research-impact-community-of-interest" + }, + "content": [ + { + "data": {}, + "marks": [], + "value": "Research impact community", + "nodeType": "text" + } + ], + "nodeType": "hyperlink" + }, + { + "data": {}, + "marks": [], + "value": " of interest to stay updated.", + "nodeType": "text" + } + ], + "nodeType": "paragraph" + }, + { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "This resource will continue to grow as we develop new training opportunities as part of the Development and expansion of research impact training initiative within the Research & innovation portfolio.", + "nodeType": "text" + } + ], + "nodeType": "paragraph" + }, + { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "Click on the cards below to learn more about the training available. If you want further support, you can contact the Research Impact team at ", + "nodeType": "text" + }, + { + "data": {}, + "marks": [ + { + "type": "bold" + } + ], + "value": "researchimpact@auckland.ac.nz.", + "nodeType": "text" + } + ], + "nodeType": "paragraph" + }, + { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "Capability offerings ", + "nodeType": "text" + } + ], + "nodeType": "heading-2" + }, + { + "data": { + "target": { + "sys": { + "id": "3SWAptFPhNDS3RYh3OHRC0", + "type": "Link", + "linkType": "Entry" + } + } + }, + "content": [], + "nodeType": "embedded-entry-block" + }, + { + "data": { + "target": { + "sys": { + "id": "6c7MwmZjAS8o0qcYdooDIE", + "type": "Link", + "linkType": "Entry" + } + } + }, + "content": [], + "nodeType": "embedded-entry-block" + }, + { + "data": {}, + "content": [ + { + "data": {}, + "marks": [], + "value": "", + "nodeType": "text" + } + ], + "nodeType": "paragraph" + } + ], + "nodeType": "document" + }, + "links": { + "entries": { + "block": [ + { + "__typename": "Expand", + "sys": { + "id": "3SWAptFPhNDS3RYh3OHRC0", + "__typename": "Sys" + } + }, + { + "__typename": "Expand", + "sys": { + "id": "6c7MwmZjAS8o0qcYdooDIE", + "__typename": "Sys" + } + } + ], + "inline": [], + "hyperlink": [], + "__typename": "CapabilityBodyTextEntries" + }, + "assets": { + "block": [], + "hyperlink": [], + "__typename": "CapabilityBodyTextAssets" + }, + "__typename": "CapabilityBodyTextLinks" + }, + "__typename": "CapabilityBodyText" + }, + "relatedItemsCollection": { + "items": [ + { + "__typename": "SubHub", + "slug": "research-management-systems", + "title": "Research Project Management Systems", + "summary": "Information on research processes, procedures and supporting resources.", + "ssoProtected": true, + "searchable": true, + "banner": null + } + ], + "__typename": "CapabilityRelatedItemsCollection" + }, + "relatedOrgsCollection": { + "items": [ + { + "__typename": "OrgUnit", + "name": "Office of Research Strategy and Integrity", + "maoriName": "Te Tari Rautaki Rangahau, Matatika ", + "description": "We support the Deputy Vice Chancellor Research and the University Research Committee by developing and implementing strategies and policies to enhance our research environment.", + "link": "https://www.auckland.ac.nz/en/research/about-our-research/research-support-contacts/office-research-strategy-integrity.html" + } + ], + "__typename": "CapabilityRelatedOrgsCollection" + }, + "relatedDocsCollection": { + "items": [ + { + "__typename": "OfficialDocuments", + "title": "Research Development Accounts Policy", + "summary": "Provides a policy framework for the use and administration of Research Development Accounts ", + "document": null, + "url": "https://www.auckland.ac.nz/en/about/the-university/how-university-works/policy-and-administration/research/finances/research-development-accounts-policy.html" + }, + { + "__typename": "OfficialDocuments", + "title": "Research and Consulting Incentives Policy FAQ's", + "summary": "Answering frequently asked questions about the Research and Consulting Incentives Policy.", + "document": null, + "url": "https://www.staff.auckland.ac.nz/en/research-gateway/training--development-and-resources/frequently-asked-questions--faq-/research-and-consulting-incentives-policy--.html" + } + ], + "__typename": "CapabilityRelatedDocsCollection" + }, + "relatedContactsCollection": { + "items": [ + { + "__typename": "Person", + "name": "Research Impact", + "role": "If you have any questions about research impact, please contact us", + "email": "researchimpact@auckland.ac.nz", + "phone": null, + "link": null + } + ], + "__typename": "CapabilityRelatedContactsCollection" + } + } + ], + "__typename": "CapabilityCollection" + } + } +} diff --git a/research-hub-web/cypress/integration/capability.e2e.ts b/research-hub-web/cypress/integration/capability.e2e.ts new file mode 100644 index 000000000..004d4239d --- /dev/null +++ b/research-hub-web/cypress/integration/capability.e2e.ts @@ -0,0 +1,57 @@ +import { hasOperationName } from '../utils/graphql-utils'; + +describe('ResearchHubs Capability Pages', () => { + beforeEach(() => { + cy.intercept('POST', Cypress.env('graphql_server'), (req) => { + console.log(req) + if (hasOperationName(req, 'GetCapabilityBySlug')) { + req.alias = 'gqlGetCapabilityBySlug'; + req.reply({ fixture: 'capability' }); + } + }); + + cy.visit('/capability/research-impact'); + + cy.wait('@gqlGetCapabilityBySlug'); + }); + + it('can visit a capability page and display the banner', () => { + cy.get('.banner-container').should('be.visible'); + }); + + it('can visit a capability and display its title', () => { + cy.get('h1.content-title').should('exist').and('have.text', ' Research impact '); + }); + + it('can visit a capability and display its subtitle', () => { + cy.get('#capability-container .content-summary').should('exist'); + }); + + it('can visit a capability page and display its Maori proverb', () => { + cy.get('.maori-proverb').should('exist').and('have.text', ' external capability offering '); + }); + + it('displays a call to action button', () => { + cy.get('.standard-button-banner > span').should('have.text', 'Test label'); + }); + + it('capability displays body text', () => { + cy.get('#capability-body > ng-component > ngx-contentful-rich-text > ng-component > p > ngx-contentful-rich-text > ng-component').should('exist'); + }); + + it('displays a list of related items', () => { + cy.get('#you-might-be-interested-in > app-standard-card').should('have.length', 1); + }); + + it('displays a list of contacts', () => { + cy.get('app-contact-card').should('have.length', 1); + }); + + it('displays a list of organisations', () => { + cy.get('app-org-unit-card').should('have.length', 1); + }); + + it('displays a list of documents', () => { + cy.get('app-document-card').should('have.length', 2); + }); +}); diff --git a/research-hub-web/cypress/integration/navbar.e2e.ts b/research-hub-web/cypress/integration/navbar.e2e.ts index a7eaffd28..796c90296 100644 --- a/research-hub-web/cypress/integration/navbar.e2e.ts +++ b/research-hub-web/cypress/integration/navbar.e2e.ts @@ -1,59 +1,60 @@ describe('ResearchHubs NavBar', () => { - beforeEach(() => { - cy.visit('/'); - }); - - it('displays the navbar first row', () => { - cy.get('.main-navbar-row').should('be.visible'); - }); - - it('The ResearchHub logo is displayed', () => { - cy.get('a.hub-logo > span > img') - .should('be.visible') - .invoke('width') - .should('be.greaterThan', 0); - }); - - it('displays search bar', () => { - cy.get('app-search-bar').should('exist'); - }); - - it('can click Search icon in search box and navigate to search page', () => { - cy.get('.search-bar-inner button mat-icon').contains('search').click(); - - cy.location('pathname').should('include', 'search'); - }); - - it('can enter a search into the search box and are taken to search page', () => { - cy.get('#search').type('unicorns{enter}'); - cy.get('#search').should('have.value', 'unicorns'); - cy.location('pathname').should('include', 'search'); - }); - - it('can click search filters icon in search bar and displays filters', () => { - cy.get('.search-filters-container').should('not.exist'); - - cy.get('.search-bar-inner button mat-icon').contains('tune').click(); - - cy.get('.search-filters-container').should('exist'); - }); - - it('can click Categories in navbar and navigate to Categories page', () => { - cy.get('.main-navbar-row a').contains('Categories').click(); - - cy.location('pathname').should('include', 'categories'); - }); - - it('can click Activities in navbar and navigate to Activities page', () => { - cy.get('.main-navbar-row a').contains('Activities').click(); - - cy.location('pathname').should('include', 'activities'); - }); - - it('can click Sign In in navbar and are taken to SSO login page', () => { - cy.get('.main-navbar-row a').contains('Sign In').click(); - - cy.location('pathname').should('include', '/profile/SAML2/Redirect/SSO'); - }); + beforeEach(() => { + cy.viewport(1400, 700); + cy.visit('/'); + }); + + it('displays the navbar first row', () => { + cy.get('.main-navbar-row').should('be.visible'); + }); + + it('The ResearchHub logo is displayed', () => { + cy.get('a.hub-logo > span > img') + .should('be.visible') + .invoke('width') + .should('be.greaterThan', 0); + }); + + it('displays search bar', () => { + cy.get('app-search-bar').should('exist'); + }); + + it('can click Search icon in search box and navigate to search page', () => { + cy.get('.search-bar-inner button mat-icon').contains('search').click(); + + cy.location('pathname').should('include', 'search'); + }); + + it('can enter a search into the search box and are taken to search page', () => { + cy.get('#search').type('unicorns{enter}'); + cy.get('#search').should('have.value', 'unicorns'); + cy.location('pathname').should('include', 'search'); + }); + + it('can click search filters icon in search bar and displays filters', () => { + cy.get('.search-filters-container').should('not.exist'); + + cy.get('.search-bar-inner button mat-icon').contains('tune').click(); + + cy.get('.search-filters-container').should('exist'); + }); + + it('can click Categories in navbar and navigate to Categories page', () => { + cy.get('.main-navbar-row a').contains('Categories').click(); + + cy.location('pathname').should('include', 'categories'); + }); + + it('can click Activities in navbar and navigate to Activities page', () => { + cy.get('.main-navbar-row a').contains('Activities').click(); + + cy.location('pathname').should('include', 'activities'); + }); + + it('can click Sign In in navbar and are taken to SSO login page', () => { + cy.get('.main-navbar-row a').contains('Sign in').click(); + + cy.location('pathname').should('include', '/profile/SAML2/Redirect/SSO'); + }); }); diff --git a/research-hub-web/cypress/integration/routing.e2e.ts b/research-hub-web/cypress/integration/routing.e2e.ts index 31203a49e..d50f204b2 100644 --- a/research-hub-web/cypress/integration/routing.e2e.ts +++ b/research-hub-web/cypress/integration/routing.e2e.ts @@ -1,60 +1,63 @@ describe('ResearchHubs Static Routing', () => { - it('can visit /search and load a list of Articles', () => { - cy.visit('/search'); - cy.contains('Results'); - }); - - it('can visit /article/first-article and load the correct content item', () => { - cy.visit('/article/communicating-your-research'); - cy.get('#article-container .content-summary small').should('exist'); - }); + it('can visit /search and load a list of Articles', () => { + cy.visit('/search'); + cy.contains('Results'); + }); + + it('can visit /article/first-article and load the correct content item', () => { + cy.visit('/article/communicating-your-research'); + cy.get('#article-container .content-summary small').should('exist'); + }); }); describe('ResearchHubs Dynamic SubHub Routing', () => { - it('can visit article Research Impact and load a SubHub', () => { - cy.visit('/research-impact'); - cy.get('h1.content-title').should('exist'); - // SubHub should contain links to other pages (subhub child pages). - cy.get('#subhub-children').should('exist'); - cy.get('app-standard-card').first().click(); - // should be on a new page which should be a sub page of research impact - cy.url().should('include', 'research-impact'); - }); - - it('can visit Support for Impactful Research and load an Article', () => { - cy.visit('/research-impact/support-for-impactful-research'); - cy.get('h1.content-title').should('exist'); - cy.get('#article-container .content-summary small').should('exist'); - }); - - it('can visit Research Virtual Machines page and load a Service', () => { - cy.visit('/research-software-and-computing/advanced-compute/research-virtual-machines'); - cy.get('#you-might-be-interested-in > app-standard-card').should('have.length.greaterThan', 0); - }) - - it('will update a content item\'s URL when it is visited from outside the SubHub', () => { - cy.visit('/article/support-for-impactful-research'); - cy.url().should('include', '/research-impact/'); - }) + const subHub = '/he-korowai-matauranga' + const childSubHub = '/how-to-understand-and-apply' + + it('can visit article Research Impact and load a SubHub', () => { + cy.visit(subHub); + cy.get('h1.content-title').should('exist'); + // SubHub should contain links to other pages (subhub child pages). + cy.get('#subhub-children').should('exist'); + cy.get('app-standard-card').contains('How can Vision Mātauranga').click(); + // should be on a new page which should be a sub page of research impact + cy.url().should('include', childSubHub); + }); + + it('can visit Support for Impactful Research and load an Article', () => { + cy.visit(`${subHub}${childSubHub}`); + cy.get('h1.content-title').should('exist'); + cy.get('#subhub-container .content-summary small').should('exist'); + }); + + it('can visit Research Virtual Machines page and load a Service', () => { + cy.visit('/research-software-and-computing/advanced-compute/research-virtual-machines'); + cy.get('#you-might-be-interested-in > app-standard-card').should('have.length.greaterThan', 0); + }) + + it('will update a content item\'s URL when it is visited from outside the SubHub', () => { + cy.visit(`/subhub${childSubHub}`); + cy.url().should('include', subHub); + }) }); describe("ResearchHubs legacy routing", () => { - it('can visit an old-style content route and be redirected to right page', () => { - cy.visit('/#/content/1'); - cy.get('h1.content-title').should('exist'); - }); + it('can visit an old-style content route and be redirected to right page', () => { + cy.visit('/#/content/1'); + cy.get('h1.content-title').should('exist'); + }); - it('should redirect invalid content route to not found page', () => { - cy.visit('/#/content/thisdoesntexist'); - cy.contains("Page Not Found"); - }) + it('should redirect invalid content route to not found page', () => { + cy.visit('/#/content/thisdoesntexist'); + cy.contains("Page Not Found"); + }) }); describe("ResearchHubs SSO protected content", () => { - it('visiting an SSO protected item redirects to SSO login page', () => { - cy.visit('/subhub/internal-funding'); - cy.location('pathname').should('include', '/profile/SAML2/Redirect/SSO'); - }); + it('visiting an SSO protected item redirects to SSO login page', () => { + cy.visit('/subhub/internal-funding'); + cy.location('pathname').should('include', '/profile/SAML2/Redirect/SSO'); + }); }); diff --git a/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.html b/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.html new file mode 100644 index 000000000..8e5b125ac --- /dev/null +++ b/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.html @@ -0,0 +1 @@ + diff --git a/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.scss b/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.spec.ts b/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.spec.ts new file mode 100644 index 000000000..37c913370 --- /dev/null +++ b/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { SharedModule } from '@app/components/shared/app.shared.module'; +import { CollectionListComponent } from '@app/components/shared/collection-list/collection-list.component'; +import { CapabilityCollection } from '@app/graphql/schema'; +import { PageTitleService } from '@services/page-title.service'; +import { ApolloTestingModule } from 'apollo-angular/testing'; +import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; +import { Observable, of } from 'rxjs'; + +import { CapabilityListComponent } from './capability-list.component'; + +describe('CapabilityListComponent', () => { + let component: CapabilityListComponent; + let fixture: ComponentFixture; + + const mockAllCapabilities$: Observable = of({ + 'items': [ + { + '__typename': 'Capability', + 'slug': 'first-capability', + 'title': 'First capability', + 'summary': 'A brief description of the first capability. I\'m writing some more stuff here just so that this seems a little more realistic. Sam was here. Have a good day.', + 'ssoProtected': false, + 'searchable': true + }, + { + '__typename': 'Capability', + 'slug': 'top-secret-capability', + 'title': 'Top Secret Capability', + 'summary': 'For testing SSO', + 'ssoProtected': true, + 'searchable': true + } + ], + '__typename': 'CapabilityCollection' + } as CapabilityCollection); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + CapabilityListComponent, + MockComponent(CollectionListComponent) + ], + imports: [ + ApolloTestingModule, + MockModule(SharedModule), + RouterTestingModule.withRoutes([]) + ], + providers: [ + MockProvider(PageTitleService) + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CapabilityListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('Should get all capabilities', () => { + spyOn(component, 'loadContent').and.returnValue(mockAllCapabilities$); + + fixture.whenStable().then(() => { + component.loadContent().subscribe(res => { + expect(res.items.length).toBe(1); + }); + }) + }); +}); diff --git a/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.ts b/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.ts new file mode 100644 index 000000000..97fcc1aa6 --- /dev/null +++ b/research-hub-web/src/app/components/capabilitys/capability-list/capability-list.component.ts @@ -0,0 +1,40 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { AllCapabilitiesGQL, CapabilityCollection } from '@app/graphql/schema'; +import { PageTitleService } from '@services/page-title.service'; +import { map, Observable, Subscription, tap } from 'rxjs'; + +@Component({ + selector: 'app-capability-list', + templateUrl: './capability-list.component.html', + styleUrls: ['./capability-list.component.scss'] +}) +export class CapabilityListComponent implements OnInit, OnDestroy { + public capabilities: CapabilityCollection; + public title: string = 'Capability Collection'; + + private subscription = new Subscription(); + + constructor( + private allCapabilitiesGQL: AllCapabilitiesGQL, + public pageTitleService: PageTitleService + ) { } + + ngOnInit(): void { + this.pageTitleService.title = this.title; + this.subscription.add( + this.loadContent().subscribe((collection) => this.capabilities = collection) + ); + } + + public loadContent(): Observable { + return this.allCapabilitiesGQL.fetch().pipe( + map((result) => result.data.capabilityCollection as CapabilityCollection), + tap((c) => console.log(c)) + ) + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + +} diff --git a/research-hub-web/src/app/components/capabilitys/capability-routing.module.ts b/research-hub-web/src/app/components/capabilitys/capability-routing.module.ts new file mode 100644 index 000000000..4ee61ad0f --- /dev/null +++ b/research-hub-web/src/app/components/capabilitys/capability-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { CapabilityListComponent } from './capability-list/capability-list.component'; +import { CapabilityComponent } from './capability/capability.component'; + +const routes: Routes = [ // TODO add components + { path: '', component: CapabilityComponent }, + { path: 'list', component: CapabilityListComponent }, + { path: ':slug', component: CapabilityComponent } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class CapabilityRoutingModule { } diff --git a/research-hub-web/src/app/components/capabilitys/capability/capability.component.html b/research-hub-web/src/app/components/capabilitys/capability/capability.component.html new file mode 100644 index 000000000..bd1b1330c --- /dev/null +++ b/research-hub-web/src/app/components/capabilitys/capability/capability.component.html @@ -0,0 +1,205 @@ +
+ + +
+ + +
+ + + + +
+ +

+ Contacts +
+

+
+ +
+ +

+ Support Materials +
+

+
+ +
+ +

+ Organisations +
+

+
+ +
+
+ + +
+

+ Explore Related +
+

+
+ +
+
+
+
+
+ + +
+
+ +
+
diff --git a/research-hub-web/src/app/components/capabilitys/capability/capability.component.scss b/research-hub-web/src/app/components/capabilitys/capability/capability.component.scss new file mode 100644 index 000000000..dbf947c8e --- /dev/null +++ b/research-hub-web/src/app/components/capabilitys/capability/capability.component.scss @@ -0,0 +1,39 @@ +@use "variables" as v; + +/** + * banner Image + */ +.mobile-banner { + background: linear-gradient( rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)); + background-repeat: no-repeat; + padding: 72px 0px; +} + +.standard-banner { + background: linear-gradient( rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)); + background-repeat: no-repeat; + background-attachment: fixed; + background-position: center; + padding: 72px 0px; +} + +/** + * Custom Button Styles + */ +.standard-button { + @extend .standard-button; + border: 3px solid v.$light-blue !important; + background-color: v.$light-blue !important; +} +.standard-button:hover { + color: v.$light-blue !important; + border-radius: 50px !important; + background-color: white !important; + transition: background-color 0.1s ease-in-out, color 0.1s ease-in-out; +} + +@media (max-width: 1350px) { + .site-padding { + padding: 0 2.5vw; + } +} diff --git a/research-hub-web/src/app/components/capabilitys/capability/capability.component.spec.ts b/research-hub-web/src/app/components/capabilitys/capability/capability.component.spec.ts new file mode 100644 index 000000000..f46732985 --- /dev/null +++ b/research-hub-web/src/app/components/capabilitys/capability/capability.component.spec.ts @@ -0,0 +1,96 @@ +import { CommonModule } from '@angular/common'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { MaterialModule } from '@app/app.material.module'; +import { ArticleListComponent } from '@app/components/articles/article-list/article-list.component'; +import { SharedModule } from '@app/components/shared/app.shared.module'; +import { Capability } from '@app/graphql/schema'; +import { PageTitleService } from '@services/page-title.service'; +import { ApolloTestingController, ApolloTestingModule } from 'apollo-angular/testing'; +import { MockModule, MockProvider } from 'ng-mocks'; +import { Observable, of } from 'rxjs'; + +import { CapabilityComponent } from './capability.component'; + +const testSlug = 'first-capability'; + +describe('CapabilityComponent', () => { + let component: CapabilityComponent; + let fixture: ComponentFixture; + let controller: ApolloTestingController; + + const mockCapability$: Observable = of( + { + '__typename': 'Capability', + 'sys': { + 'id': '111' + }, + 'slug': 'first-capability', + 'title': 'Death Star', + 'summary': 'Mobile space station and galactic superweapon.', + 'ssoProtected': false, + 'searchable': false + } as unknown as Capability); + + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [CapabilityComponent], + imports: [ + ApolloTestingModule, + MockModule(CommonModule), + MockModule(MaterialModule), + MockModule(SharedModule), + MockModule(BrowserAnimationsModule), + RouterTestingModule.withRoutes([ + { path: 'article/list', component: ArticleListComponent } + ]) + ], + providers: [ + MockProvider(PageTitleService) + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + TestBed.inject(ActivatedRoute).params = of({ + slug: testSlug + }); + fixture = TestBed.createComponent(CapabilityComponent); + controller = TestBed.inject(ApolloTestingController); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('When a url slug is present', async () => { + beforeEach(() => { + controller = TestBed.inject(ApolloTestingController); + fixture = TestBed.createComponent(CapabilityComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + // component.ngOnInit(); + }); + + it('Should get a single article data', () => { + spyOn(component, 'getCapabilityBySlug').and.returnValue(mockCapability$); + + fixture.whenStable().then(() => { + component.getCapabilityBySlug(testSlug).subscribe(res => { + expect(res.slug).toEqual(testSlug); + }); + }) + }); + }); +}); diff --git a/research-hub-web/src/app/components/capabilitys/capability/capability.component.ts b/research-hub-web/src/app/components/capabilitys/capability/capability.component.ts new file mode 100644 index 000000000..5b694a1f7 --- /dev/null +++ b/research-hub-web/src/app/components/capabilitys/capability/capability.component.ts @@ -0,0 +1,136 @@ +import { Component, OnDestroy, OnInit, Type } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ApolloError } from '@apollo/client/errors'; +import { notEmpty } from '@app/global/notEmpty'; +import { Capability, CapabilityRelatedItemsItem, GetCapabilityBySlugGQL, OfficialDocuments, OrgUnit, Person } from '@app/graphql/schema'; +import { BodyMediaService } from '@services/body-media.service'; +import { PageTitleService } from '@services/page-title.service'; +import { MarkRenderer, NodeRenderer } from 'ngx-contentful-rich-text'; +import { map, Observable, Subscription, switchMap, throwError } from 'rxjs'; +import supportsWebP from 'supports-webp'; + +@Component({ + selector: 'app-capability', + templateUrl: './capability.component.html', + styleUrls: ['./capability.component.scss'] +}) +export class CapabilityComponent implements OnInit, OnDestroy { + public nodeRenderers: Record>; + public markRenderers: Record>; + + private subscriptions = new Subscription(); + + public bannerTextStyling = 'color: white; text-shadow: 0px 0px 8px #333333;'; + public capability: Capability; + public supportsWebp: Boolean; + public bannerImageUrl: string | undefined; + + public relatedItems: CapabilityRelatedItemsItem[]; + public relatedContacts: Person[]; + public relatedOrgs: OrgUnit[]; + public relatedDocs: OfficialDocuments[]; + + constructor( + public route: ActivatedRoute, + public getCapabilityBySlugGQL: GetCapabilityBySlugGQL, + public pageTitleService: PageTitleService, + public bodyMediaService: BodyMediaService, + public router: Router + ) { + this.detectWebP(); + + this.nodeRenderers = this.bodyMediaService.nodeRenderers; + this.markRenderers = this.bodyMediaService.markRenderers; + } + + detectWebP() { + supportsWebP.then(supported => { + this.supportsWebp = supported; + }); + } + + ngOnInit() { + this.subscriptions.add(this.route.params.pipe( + map((params) => { + return (params.slug || this.route.snapshot.data.slug) as string; + }), + switchMap((slug) => slug + ? this.loadCapability(slug) + : throwError(() => new Error('No slug included in URL. Redirect to Collection page.')) + ) + ).subscribe({ + next: (capability: Capability) => this.capability = capability, + error: (error: Error) => { + if (error instanceof ApolloError && error.message.includes('Authentication required')) { + console.warn('Waiting for redirect to Login page'); + } else if (error.message.includes('No slug')) { + console.warn('Waiting for redirect to Capability Collection page'); + this.router.navigate(['capability', 'list']) + } else if (error.message.includes('Not found')) { + console.error(error); + this.router.navigate(['error', 404]); + } else { + console.error(error); + this.router.navigate(['error', 500]); + } + } + })); + } + + ngOnDestroy() { + this.subscriptions.unsubscribe(); + } + + private loadCapability(slug: string): Observable { + return this.getCapabilityBySlug(slug).pipe( + map(data => { + // Strip nulls from related collection data. + if (data.relatedContactsCollection) this.relatedContacts = data.relatedContactsCollection.items.filter(notEmpty); + if (data.relatedDocsCollection) this.relatedDocs = (data.relatedDocsCollection.items.filter(notEmpty)).filter(item => item.title); + if (data.relatedItemsCollection) this.relatedItems = data.relatedItemsCollection.items.filter(notEmpty); + if (data.relatedOrgsCollection) this.relatedOrgs = (data.relatedOrgsCollection.items.filter(notEmpty)).filter(item => item.name); + + // If Call To Action is an email address + if (data.callToAction?.match(/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/)) { + data['callToAction'] = 'mailto:' + data['callToAction']; + } + + // Set banner image URL for webp format if webp is supported + if (data.banner?.url) { + this.bannerImageUrl = this.supportsWebp ? data.banner?.url + '?w=1900&fm=webp' : data.banner?.url + '?w=1900'; + } else { + this.bannerImageUrl = undefined; + } + + // For each rich text field add the links to the link maps in the body media service to enable rich text rendering + this.bodyMediaService.buildLinkMaps(data.bodyText?.links); + + this.pageTitleService.title = data.title ?? ''; + + return data; + }) + ); + } + + /** + * Function that returns an individual capability from the CapabilityCollection by it's slug + * as an observable of type Capability. + * + * @param slug The capability's slug. Retrieved from the route parameter of the same name. + */ + public getCapabilityBySlug(slug: string): Observable { + return this.getCapabilityBySlugGQL.fetch({ slug }).pipe( + map(x => { + if (x?.data?.capabilityCollection) { + if (x.data.capabilityCollection.items.length === 0) { + throw new Error(`Not found. Could not find capability with slug "${slug}"`) + } else { + return x.data.capabilityCollection.items[0] as Capability + } + } else { + throw new Error('Unable to fetch capabilityCollection'); + } + }) + ); + } +} diff --git a/research-hub-web/src/app/components/capabilitys/capabilitys.module.ts b/research-hub-web/src/app/components/capabilitys/capabilitys.module.ts new file mode 100644 index 000000000..f35462160 --- /dev/null +++ b/research-hub-web/src/app/components/capabilitys/capabilitys.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CapabilityRoutingModule } from './capability-routing.module'; +import { SharedModule } from '../shared/app.shared.module'; +import { NgxContentfulRichTextModule } from 'ngx-contentful-rich-text'; +import { CardsModule } from '../cards/cards.module'; +import { CapabilityComponent } from './capability/capability.component'; +import { CapabilityListComponent } from './capability-list/capability-list.component'; + + + +@NgModule({ + declarations: [ + CapabilityComponent, + CapabilityListComponent + ], + imports: [ + CommonModule, + CapabilityRoutingModule, + SharedModule, + NgxContentfulRichTextModule, + CardsModule + ] +}) +export class CapabilitysModule { } diff --git a/research-hub-web/src/app/components/cards/standard-card/standard-card.component.html b/research-hub-web/src/app/components/cards/standard-card/standard-card.component.html index 7f2a4607c..7523275a2 100644 --- a/research-hub-web/src/app/components/cards/standard-card/standard-card.component.html +++ b/research-hub-web/src/app/components/cards/standard-card/standard-card.component.html @@ -2,17 +2,11 @@ [routerLink]="['/', contentItem.__typename!.toLowerCase(), contentItem.slug]" [ngClass]="{ 'subhub-child-card': isSubhubChild }" > - + + + {{ contentItem.banner ? contentItem.title : 'default background' }} + + {{ contentItem.__typename! | contentTypeDisplayName }} @@ -42,11 +36,3 @@ - - - - - - {{ title }} - - diff --git a/research-hub-web/src/app/components/cards/standard-card/standard-card.component.scss b/research-hub-web/src/app/components/cards/standard-card/standard-card.component.scss index b399b15e2..cf6f0429d 100644 --- a/research-hub-web/src/app/components/cards/standard-card/standard-card.component.scss +++ b/research-hub-web/src/app/components/cards/standard-card/standard-card.component.scss @@ -33,5 +33,6 @@ mat-icon { img { object-fit: cover; - height: 200px; + min-height: 200px; + height: 200px; // this is likely redundant, but I want to assure that the element is never anything else but 200px in height } diff --git a/research-hub-web/src/app/components/cards/standard-card/standard-card.component.spec.ts b/research-hub-web/src/app/components/cards/standard-card/standard-card.component.spec.ts index 516009092..de8b4032c 100644 --- a/research-hub-web/src/app/components/cards/standard-card/standard-card.component.spec.ts +++ b/research-hub-web/src/app/components/cards/standard-card/standard-card.component.spec.ts @@ -6,6 +6,7 @@ import { ContentTypeDisplayNamePipe } from '@pipes/content-type-display-name.pip import { MockModule, MockPipe } from 'ng-mocks'; import { RouterTestingModule } from '@angular/router/testing'; import { StandardCardComponent } from './standard-card.component'; +import { ApolloTestingModule } from 'apollo-angular/testing'; describe('StandardCardComponent', () => { let component: StandardCardComponent; @@ -19,6 +20,7 @@ describe('StandardCardComponent', () => { ], imports: [ + ApolloTestingModule, MockModule(MatCardModule), MockModule(MatIconModule), RouterTestingModule.withRoutes([]) diff --git a/research-hub-web/src/app/components/cards/standard-card/standard-card.component.ts b/research-hub-web/src/app/components/cards/standard-card/standard-card.component.ts index c58a4dd09..f634723a5 100644 --- a/research-hub-web/src/app/components/cards/standard-card/standard-card.component.ts +++ b/research-hub-web/src/app/components/cards/standard-card/standard-card.component.ts @@ -1,5 +1,6 @@ -import { Component, Input } from '@angular/core'; -import { Article, CaseStudy, Equipment, Event, Funding, Service, Software, SubHub } from '@app/graphql/schema'; +import { Component, Input, OnInit } from '@angular/core'; +import { Article, Capability, CaseStudy, Equipment, Event, Funding, GetAssetByIdGQL, Service, Software, SubHub } from '@app/graphql/schema'; +import { map, Observable } from 'rxjs'; export type PossibleContentItems = Article @@ -10,6 +11,7 @@ export type PossibleContentItems | Funding | Service | Software + | Capability @Component({ selector: 'app-standard-card', @@ -19,20 +21,41 @@ export type PossibleContentItems '../cards-common.scss' ] }) -export class StandardCardComponent { +export class StandardCardComponent implements OnInit { @Input() contentItem: PossibleContentItems; @Input() isSubhubChild = false; - public defaultImage = new Map, string>([ - ['Article', 'https://images.ctfassets.net/vbuxn5csp0ik/7dPrwEcbk56xKfz5zTLvEP/0efddb4b6c9e1eda80d2fb8d1ee47275/card-background-article.png'], - ['CaseStudy', 'https://images.ctfassets.net/vbuxn5csp0ik/2qmi1RS1lZSgXj9xP47h8E/54eb4e54bfc00d26f34401293af1ed80/card-background-case-study.png'], - ['Equipment', 'https://images.ctfassets.net/vbuxn5csp0ik/1aSspX7erQzo9jVKStvwO9/6b141535dc463e8af1394f269100b9d7/card-background-equipment.png'], - ['Event', 'https://images.ctfassets.net/vbuxn5csp0ik/lwzGPgcdAAwSIR7PruzdG/de3e9093f3504a0e64d12a7751d5752e/card-background-event.png'], - ['Funding', 'https://images.ctfassets.net/vbuxn5csp0ik/3n0YDaiEq2xCtp8sols7LG/2ff1b20bb49bfc0da132f2500c8ab0eb/card-background-funding.png'], - ['Service', 'https://images.ctfassets.net/vbuxn5csp0ik/5NwXkH2EEsYbGj20IefKyp/102be11747b9de2e07980926e4498d27/card-background-service.png'], - ['Software', 'https://images.ctfassets.net/vbuxn5csp0ik/16eyRnz65svAAyUF08yfAZ/ea0cff6e1c51e1800e7d210168532516/card-background-software.png'], - ['SubHub', 'https://images.ctfassets.net/vbuxn5csp0ik/4A7fKybLu0221iqacf7BAz/df2882a80873bea2e3297e7ebc3f6f41/card-background-subhub.png'] - ]); - - constructor() { } + defaultImageUrl$: Observable; + url: string | null | undefined; + + private readonly fallbackUrl = 'https://images.ctfassets.net/vbuxn5csp0ik/7dPrwEcbk56xKfz5zTLvEP/0efddb4b6c9e1eda80d2fb8d1ee47275/card-background-article.png'; + + private readonly defaultImageId: Record, string> = { + 'Article': '7dPrwEcbk56xKfz5zTLvEP', + 'Capability': 'QT1QGR7KkqaSswnmg7L97', + 'CaseStudy': '2qmi1RS1lZSgXj9xP47h8E', + 'Equipment': '1aSspX7erQzo9jVKStvwO9', + 'Event': 'lwzGPgcdAAwSIR7PruzdG', + 'Funding': '3n0YDaiEq2xCtp8sols7LG', + 'Service': '5NwXkH2EEsYbGj20IefKyp', + 'Software': '16eyRnz65svAAyUF08yfAZ', + 'SubHub': '4A7fKybLu0221iqacf7BAz' + } + + constructor(private getAssetById: GetAssetByIdGQL) { } + + ngOnInit(): void { + this.url = this.contentItem.banner?.url; + this.defaultImageUrl$ = this.getDefaultImageUrl(this.contentItem.__typename); + } + + private getDefaultImageUrl(type: PossibleContentItems['__typename']): Observable { + if (!type) { + type = 'Article'; + } + const id = this.defaultImageId[type]; + return this.getAssetById.fetch({ id }).pipe( + map(result => result.data.asset?.url ? result.data.asset.url : this.fallbackUrl), + ) + } } diff --git a/research-hub-web/src/app/components/content-graph/color-legend/color-legend.component.ts b/research-hub-web/src/app/components/content-graph/color-legend/color-legend.component.ts index 3192e86b4..2881e0a81 100644 --- a/research-hub-web/src/app/components/content-graph/color-legend/color-legend.component.ts +++ b/research-hub-web/src/app/components/content-graph/color-legend/color-legend.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input } from '@angular/core'; @Component({ selector: 'app-color-legend', diff --git a/research-hub-web/src/app/components/content-graph/graph-container/graph-container.component.ts b/research-hub-web/src/app/components/content-graph/graph-container/graph-container.component.ts index 869cdb680..a7bb19ec3 100644 --- a/research-hub-web/src/app/components/content-graph/graph-container/graph-container.component.ts +++ b/research-hub-web/src/app/components/content-graph/graph-container/graph-container.component.ts @@ -111,7 +111,7 @@ export class GraphContainerComponent implements OnInit, AfterViewInit, OnDestroy .d3AlphaDecay(0.04) .d3VelocityDecay(0.2) .maxZoom(3) - .width(this.graphElement.nativeElement.with) + .width(this.graphElement.nativeElement.width) .height(this.graphElement.nativeElement.height); } diff --git a/research-hub-web/src/app/components/content-graph/graph-layout/graph-layout.component.ts b/research-hub-web/src/app/components/content-graph/graph-layout/graph-layout.component.ts index 7327baa8b..7d0cdd944 100644 --- a/research-hub-web/src/app/components/content-graph/graph-layout/graph-layout.component.ts +++ b/research-hub-web/src/app/components/content-graph/graph-layout/graph-layout.component.ts @@ -34,7 +34,7 @@ export class GraphLayoutComponent { public selectedNode: ContentNode | null; // colorbrewer qualitative Set1 - private colors = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00', '#ffff33', '#a65628', '#f781bf']; + private colors = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00', '#ffff33', '#a65628', '#f781bf', '#999999']; public colorMap = new Map([ ['subHub', this.colors[0]], @@ -45,5 +45,6 @@ export class GraphLayoutComponent { ['service', this.colors[5]], ['software', this.colors[6]], ['caseStudy', this.colors[7]], + ['capability', this.colors[8]] ]); } diff --git a/research-hub-web/src/app/components/layout/navbar/navbar.component.html b/research-hub-web/src/app/components/layout/navbar/navbar.component.html index cf528a07f..b76afd591 100644 --- a/research-hub-web/src/app/components/layout/navbar/navbar.component.html +++ b/research-hub-web/src/app/components/layout/navbar/navbar.component.html @@ -58,7 +58,7 @@ [matTooltip]="userInfo?.firstName + ' ' + userInfo.lastName" > person - Sign Out + Sign out @@ -71,6 +71,6 @@ (click)="loginService.doLogin(currentUrl)" (keydown.enter)="loginService.doLogin(currentUrl)" > - Sign In + Sign in diff --git a/research-hub-web/src/app/components/layout/navbar/navbar.component.scss b/research-hub-web/src/app/components/layout/navbar/navbar.component.scss index 98890e803..7e828f107 100644 --- a/research-hub-web/src/app/components/layout/navbar/navbar.component.scss +++ b/research-hub-web/src/app/components/layout/navbar/navbar.component.scss @@ -80,3 +80,8 @@ mat-toolbar { text-decoration:underline } } + +.overflow-ellipsis { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/research-hub-web/src/app/components/layout/search-bar/search-bar.component.spec.ts b/research-hub-web/src/app/components/layout/search-bar/search-bar.component.spec.ts index 55aa21134..e5da53467 100644 --- a/research-hub-web/src/app/components/layout/search-bar/search-bar.component.spec.ts +++ b/research-hub-web/src/app/components/layout/search-bar/search-bar.component.spec.ts @@ -37,7 +37,8 @@ describe('SearchBarComponent', () => { fundingTitles: ['a funding'], serviceTitles: ['a service'], softwareTitles: ['a software'], - subHubTitles: ['a subHub'] + subHubTitles: ['a subHub'], + capabilityTitles: ['a capability'] }; beforeEach(async () => { @@ -68,7 +69,7 @@ describe('SearchBarComponent', () => { }), MockProvider(SearchAutocompleteService, { allTitles$: of(pageTitles), - getAutocompleteTerms: () => {return ['Covfefé']} + getAutocompleteTerms: () => { return ['Covfefé'] } }), MockProvider(BreakpointObserver, { observe: () => EMPTY @@ -193,7 +194,7 @@ describe('SearchBarComponent', () => { component.toggleMobileSearch(); fixture.detectChanges(); - const button = await loader.getHarness(MatButtonHarness.with({selector: '#searchBackButton'})); + const button = await loader.getHarness(MatButtonHarness.with({ selector: '#searchBackButton' })); expect(button) .withContext('back button should exist') @@ -209,7 +210,7 @@ describe('SearchBarComponent', () => { let filteredTerms: string[] = []; component.filteredTerms.subscribe((terms) => filteredTerms = terms); - expect(filteredTerms.length).toBe(9); + expect(filteredTerms.length).toBe(10); }); it('Should filter autocomplete terms correctly', async () => { @@ -218,7 +219,7 @@ describe('SearchBarComponent', () => { component.filteredTerms.subscribe((terms) => filteredTerms = terms); - const input = await loader.getHarness(MatAutocompleteHarness.with({selector: '#search'})); + const input = await loader.getHarness(MatAutocompleteHarness.with({ selector: '#search' })); await input.enterText('covfefe'); expect(filteredTerms.length).toBe(1); diff --git a/research-hub-web/src/app/components/layout/search-bar/search-bar.component.ts b/research-hub-web/src/app/components/layout/search-bar/search-bar.component.ts index 313356804..e44aff766 100644 --- a/research-hub-web/src/app/components/layout/search-bar/search-bar.component.ts +++ b/research-hub-web/src/app/components/layout/search-bar/search-bar.component.ts @@ -55,13 +55,14 @@ export class SearchBarComponent implements OnInit, OnDestroy { filter(event => event instanceof NavigationStart) ).subscribe(() => this.showFilters = false)); this.subscriptions.add(this.breakpointObserver.observe('(max-width: 1100px)').subscribe(isSmallScreen => this.isMobile = isSmallScreen.matches)); - + // Search autocomplete initialisation this.subscriptions.add(this.searchAutocompleteService.allTitles$.subscribe({ next: titles => { this.autoCompleteTerms = [ ...this.searchAutocompleteService.getAutocompleteTerms(), ...titles.articleTitles, + ...titles.capabilityTitles, ...titles.caseStudyTitles, ...titles.equipmentTitles, ...titles.eventTitles, @@ -117,12 +118,12 @@ export class SearchBarComponent implements OnInit, OnDestroy { /** * Filters out the autocomplete terms to match a user's search text input. - * + * * The filtering process also handles lowercasing, removes leading and trailing white space, and removal of diacritics/accents, so that for example, * a user input of 'creme brulee' will match 'Crème Brulée' in the autocomplete list (and vice-versa). * Ref: https://stackoverflow.com/a/37511463/9803180 * Ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Unicode_Property_Escapes - * + * * @param value - the user input search term * @returns string[] of filtered autocomplete terms that match the user input */ diff --git a/research-hub-web/src/app/components/search-page/search-page.component.ts b/research-hub-web/src/app/components/search-page/search-page.component.ts index 4dab5d8dc..27d092b20 100644 --- a/research-hub-web/src/app/components/search-page/search-page.component.ts +++ b/research-hub-web/src/app/components/search-page/search-page.component.ts @@ -95,7 +95,7 @@ export class SearchPageComponent implements OnInit, OnDestroy { detectWebP() { supportsWebP.then(supported => { - this.bannerImageUrl = supported ? this.bannerImageUrl + '?w=1900&fm=webp' : this.bannerImageUrl + '?w=1900'; + this.bannerImageUrl = supported ? this.bannerImageUrl + '?w=1900&fm=webp' : this.bannerImageUrl + '?w=1900'; }); } @@ -110,7 +110,7 @@ export class SearchPageComponent implements OnInit, OnDestroy { private search(size: number = 10, from: number = 0): Observable { this.loading = true; - const contentTypes: ContentType[] = ['article', 'caseStudy', 'equipment', 'event', 'funding', 'service', 'software', 'subHub'] + const contentTypes: ContentType[] = ['article', 'capability', 'caseStudy', 'equipment', 'event', 'funding', 'service', 'software', 'subHub'] const searchQuery: SearchQuery = { query: this.searchText, diff --git a/research-hub-web/src/app/components/shared/collection-list/collection-list.component.ts b/research-hub-web/src/app/components/shared/collection-list/collection-list.component.ts index ff303ecc7..ce17ae382 100644 --- a/research-hub-web/src/app/components/shared/collection-list/collection-list.component.ts +++ b/research-hub-web/src/app/components/shared/collection-list/collection-list.component.ts @@ -10,10 +10,10 @@ export class CollectionListComponent implements OnChanges { @Input() collection; - constructor() { } + constructor() { } ngOnChanges(changes: SimpleChanges) { - try { this.collection = changes['collection'].currentValue; this.loading = false } catch {} + try { this.collection = changes['collection'].currentValue; this.loading = false } catch { } } // Scrolling to top of page on page change diff --git a/research-hub-web/src/app/global/searchTypes.ts b/research-hub-web/src/app/global/searchTypes.ts index 498a36d1a..adc2065f9 100644 --- a/research-hub-web/src/app/global/searchTypes.ts +++ b/research-hub-web/src/app/global/searchTypes.ts @@ -6,7 +6,7 @@ export interface SearchQuery { from: number, sort?: SortOrder, filters?: SearchFilters, - includeContentTypes : ContentType[] + includeContentTypes: ContentType[] } export interface SearchFilters { @@ -15,7 +15,7 @@ export interface SearchFilters { category: string[] } -export interface AllFilters { +export interface AllFilters { allCategories: CategoryCollection | null, allStages: StageCollection | null, allOrganisations: OrgUnitCollection | null @@ -41,10 +41,10 @@ export interface SearchResultChip { } export type SortOrder = 'A-Z' | 'Z-A' | 'relevance' -export type ContentType = 'article' | 'caseStudy' | 'equipment' | 'event' | 'funding' | 'service' | 'software' | 'subHub' +export type ContentType = 'article' | 'capability' | 'caseStudy' | 'equipment' | 'event' | 'funding' | 'service' | 'software' | 'subHub' export enum FilterType { ResearchActivity = 1, ResearchCategory, Organisation -} \ No newline at end of file +} diff --git a/research-hub-web/src/app/graphql/fragments/public-fields.fragment.graphql b/research-hub-web/src/app/graphql/fragments/public-fields.fragment.graphql index 09a605b84..1307f910e 100644 --- a/research-hub-web/src/app/graphql/fragments/public-fields.fragment.graphql +++ b/research-hub-web/src/app/graphql/fragments/public-fields.fragment.graphql @@ -9,7 +9,20 @@ fragment PublicFields on Entry { banner { url } - } + } + + ... on Capability { + __typename + slug + title + summary + ssoProtected + searchable + banner { + url + } + } + ... on CaseStudy { __typename banner { @@ -114,7 +127,7 @@ fragment PublicFields on Entry { ... on Person { __typename name - role + role email phone link @@ -128,4 +141,4 @@ fragment PublicFields on Entry { name table } -} \ No newline at end of file +} diff --git a/research-hub-web/src/app/graphql/queries/all-capabilities.query.graphql b/research-hub-web/src/app/graphql/queries/all-capabilities.query.graphql new file mode 100644 index 000000000..8cc3f5d6f --- /dev/null +++ b/research-hub-web/src/app/graphql/queries/all-capabilities.query.graphql @@ -0,0 +1,7 @@ +query AllCapabilities { + capabilityCollection(limit: 250) { + items { + ...PublicFields + } + } +} diff --git a/research-hub-web/src/app/graphql/queries/all-page-titles.query.graphql b/research-hub-web/src/app/graphql/queries/all-page-titles.query.graphql index c7af00a3a..36454f507 100644 --- a/research-hub-web/src/app/graphql/queries/all-page-titles.query.graphql +++ b/research-hub-web/src/app/graphql/queries/all-page-titles.query.graphql @@ -4,6 +4,11 @@ query AllPageTitles { title } } + capabilityCollection (limit: 1000, where: {searchable: true}) { + items { + title + } + } caseStudyCollection (limit: 1000, where: {searchable: true}) { items { title @@ -39,4 +44,4 @@ query AllPageTitles { title } } -} \ No newline at end of file +} diff --git a/research-hub-web/src/app/graphql/queries/all-subhub-child-pages-slugs.query.graphql b/research-hub-web/src/app/graphql/queries/all-subhub-child-pages-slugs.query.graphql index 40b2ffcda..4ae138578 100644 --- a/research-hub-web/src/app/graphql/queries/all-subhub-child-pages-slugs.query.graphql +++ b/research-hub-web/src/app/graphql/queries/all-subhub-child-pages-slugs.query.graphql @@ -8,6 +8,9 @@ query GetAllSubHubChildPagesSlugs { ...on Article { slug } + ...on Capability { + slug + } ...on CaseStudy { slug } diff --git a/research-hub-web/src/app/graphql/queries/get-asset-by-id.query.graphql b/research-hub-web/src/app/graphql/queries/get-asset-by-id.query.graphql new file mode 100644 index 000000000..f4195d565 --- /dev/null +++ b/research-hub-web/src/app/graphql/queries/get-asset-by-id.query.graphql @@ -0,0 +1,5 @@ +query GetAssetById($id: String!) { + asset(id: $id) { + url + } +} diff --git a/research-hub-web/src/app/graphql/queries/get-capability-by-slug.query.graphql b/research-hub-web/src/app/graphql/queries/get-capability-by-slug.query.graphql new file mode 100644 index 000000000..7f15914ce --- /dev/null +++ b/research-hub-web/src/app/graphql/queries/get-capability-by-slug.query.graphql @@ -0,0 +1,74 @@ +query GetCapabilityBySlug($slug: String!) { + capabilityCollection(limit: 1, where: { slug: $slug }) { + items { + __typename + sys { + id + } + title + maoriProverb + slug + ssoProtected + searchable + callToAction + callToActionLabel + banner { + url + } + summary + bodyText { + json + links { + entries { + block { + ...PublicFields + sys { + id + } + } + inline { + ...PublicFields + sys { + id + } + } + hyperlink { + ...PublicFields + sys { + id + } + } + } + assets { + block { + ...AssetFields + } + hyperlink { + ...AssetFields + } + } + } + } + relatedItemsCollection { + items { + ...PublicFields + } + } + relatedOrgsCollection { + items { + ...PublicFields + } + } + relatedDocsCollection { + items { + ...PublicFields + } + } + relatedContactsCollection { + items { + ...PublicFields + } + } + } + } +} diff --git a/research-hub-web/src/app/pipes/content-type-display-name.pipe.ts b/research-hub-web/src/app/pipes/content-type-display-name.pipe.ts index 50bc0227d..d15a80c35 100644 --- a/research-hub-web/src/app/pipes/content-type-display-name.pipe.ts +++ b/research-hub-web/src/app/pipes/content-type-display-name.pipe.ts @@ -3,6 +3,7 @@ import { HumanCasePipe } from './human-case.pipe'; export const ContentTypeDisplayNames = { 'article': 'Article', + 'capability': 'Capability', 'casestudy': 'Case Study', 'equipment': 'Equipment', 'event': 'Event', diff --git a/research-hub-web/src/app/routing/routing.ts b/research-hub-web/src/app/routing/routing.ts index 33920bc2c..493eb1e52 100644 --- a/research-hub-web/src/app/routing/routing.ts +++ b/research-hub-web/src/app/routing/routing.ts @@ -82,6 +82,10 @@ export const appRoutes: Routes = [ { path: 'software', loadChildren: () => import('@components/softwares/softwares.module').then(m => m.SoftwaresModule) + }, + { + path: 'capability', + loadChildren: () => import('@components/capabilitys/capabilitys.module').then(m => m.CapabilitysModule) } ] }, diff --git a/research-hub-web/src/app/services/body-media.service.ts b/research-hub-web/src/app/services/body-media.service.ts index 4ea4f0ed2..578e6ad5f 100644 --- a/research-hub-web/src/app/services/body-media.service.ts +++ b/research-hub-web/src/app/services/body-media.service.ts @@ -10,10 +10,11 @@ import { InlinesAssetHyperlinkComponent } from '@components/shared/body-media/in import { InlinesEmbeddedEntryComponent } from '@components/shared/body-media/inlines-embedded-entry/inlines-embedded-entry.component'; import { InlinesEntryHyperlinkComponent } from '@components/shared/body-media/inlines-entry-hyperlink/inlines-entry-hyperlink.component'; import { MarksCodeComponent } from '@components/shared/body-media/marks-code/marks-code.component'; -import { ArticleBodyTextLinks, Asset, CaseStudyBodyTextLinks, CaseStudyReferencesLinks, Entry, EquipmentBodyTextLinks, EventBodyTextLinks, ExpandBodyTextLinks, FundingBodyTextLinks, FundingDeadlinesLinks, FundingPurposeLinks, Maybe, ServiceBodyTextLinks, SoftwareBodyTextLinks, SubHubBodyTextLinks } from '@app/graphql/schema'; +import { ArticleBodyTextLinks, Asset, CapabilityBodyTextLinks, CaseStudyBodyTextLinks, CaseStudyReferencesLinks, Entry, EquipmentBodyTextLinks, EventBodyTextLinks, ExpandBodyTextLinks, FundingBodyTextLinks, FundingDeadlinesLinks, FundingPurposeLinks, Maybe, ServiceBodyTextLinks, SoftwareBodyTextLinks, SubHubBodyTextLinks } from '@app/graphql/schema'; export type BodyTextLinks = ArticleBodyTextLinks + | CapabilityBodyTextLinks | SubHubBodyTextLinks | SoftwareBodyTextLinks | ServiceBodyTextLinks diff --git a/research-hub-web/src/app/services/content-graph.service.ts b/research-hub-web/src/app/services/content-graph.service.ts index 90aacb728..31713f7ad 100644 --- a/research-hub-web/src/app/services/content-graph.service.ts +++ b/research-hub-web/src/app/services/content-graph.service.ts @@ -1,6 +1,5 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { environment } from '@environments/environment'; import { LinkObject, NodeObject } from 'force-graph'; import { Observable } from 'rxjs'; diff --git a/research-hub-web/src/app/services/search-autocomplete.service.spec.ts b/research-hub-web/src/app/services/search-autocomplete.service.spec.ts index eb872ff46..17fe29c3e 100644 --- a/research-hub-web/src/app/services/search-autocomplete.service.spec.ts +++ b/research-hub-web/src/app/services/search-autocomplete.service.spec.ts @@ -8,13 +8,14 @@ describe('SearchAutocompleteService', () => { const mockPageTitles$: Observable = of({ articleTitles: ['an article'], + capabilityTitles: ['a capability'], caseStudyTitles: ['a caseStudy'], equipmentTitles: ['an equipment'], eventTitles: ['an event'], fundingTitles: ['a funding'], serviceTitles: ['a service'], softwareTitles: ['a software'], - subHubTitles: ['a subHub'] + subHubTitles: ['a subHub'], }); beforeEach(() => { diff --git a/research-hub-web/src/app/services/search-autocomplete.service.ts b/research-hub-web/src/app/services/search-autocomplete.service.ts index 62d707caa..ea4cbdf09 100644 --- a/research-hub-web/src/app/services/search-autocomplete.service.ts +++ b/research-hub-web/src/app/services/search-autocomplete.service.ts @@ -2,9 +2,11 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { AllPageTitlesGQL, Maybe } from '@app/graphql/schema'; import { pluck, map } from 'rxjs/operators'; +import { notEmpty } from '@app/global/notEmpty'; export interface PageTitles { articleTitles: string[]; + capabilityTitles: string[]; caseStudyTitles: string[]; equipmentTitles: string[]; eventTitles: string[]; @@ -41,27 +43,28 @@ export class SearchAutocompleteService { ]; // All published page titles in Contentful public allTitles$: Observable; - + constructor( private allPageTitlesGQL: AllPageTitlesGQL, ) { this.allTitles$ = this.getAllPageTitles(); } - public getAllPageTitles() { + public getAllPageTitles(): Observable { return this.allPageTitlesGQL.fetch() .pipe( map(result => ({ - articleTitles: result?.data?.articleCollection?.items.map(x=>x?.title) ?? [], - caseStudyTitles: result?.data?.caseStudyCollection?.items.map(x=>x?.title) ?? [], - equipmentTitles: result?.data?.equipmentCollection?.items.map(x=>x?.title) ?? [], - eventTitles: result?.data?.eventCollection?.items.map(x=>x?.title) ?? [], - fundingTitles: result?.data?.fundingCollection?.items.map(x=>x?.title) ?? [], - serviceTitles: result?.data?.serviceCollection?.items.map(x=>x?.title) ?? [], - softwareTitles: result?.data?.softwareCollection?.items.map(x=>x?.title) ?? [], - subHubTitles: result?.data?.subHubCollection?.items.map(x=>x?.title) ?? [] - })) - ) as Observable + articleTitles: result?.data?.articleCollection?.items.map(x => x?.title).filter(notEmpty) ?? [], + capabilityTitles: result?.data?.capabilityCollection?.items.map(x => x?.title).filter(notEmpty) ?? [], + caseStudyTitles: result?.data?.caseStudyCollection?.items.map(x => x?.title).filter(notEmpty) ?? [], + equipmentTitles: result?.data?.equipmentCollection?.items.map(x => x?.title).filter(notEmpty) ?? [], + eventTitles: result?.data?.eventCollection?.items.map(x => x?.title).filter(notEmpty) ?? [], + fundingTitles: result?.data?.fundingCollection?.items.map(x => x?.title).filter(notEmpty) ?? [], + serviceTitles: result?.data?.serviceCollection?.items.map(x => x?.title).filter(notEmpty) ?? [], + softwareTitles: result?.data?.softwareCollection?.items.map(x => x?.title).filter(notEmpty) ?? [], + subHubTitles: result?.data?.subHubCollection?.items.map(x => x?.title).filter(notEmpty) ?? [] + })) + ) } public getAutocompleteTerms(): string[] { diff --git a/research-hub-web/src/partial-styles/_variables.scss b/research-hub-web/src/partial-styles/_variables.scss index 35781cf95..648a7f508 100644 --- a/research-hub-web/src/partial-styles/_variables.scss +++ b/research-hub-web/src/partial-styles/_variables.scss @@ -24,7 +24,7 @@ $height-footer-xs: 270px; $padding-site: 5vw; $padding-site-top: 4vh; -$navbar-second-row-breakpoint: 1100px; +$navbar-second-row-breakpoint: 1300px; $single-column-max-width: 1100px; /** From 13fa660d5b60a624e441f3fee865482d558fe9b1 Mon Sep 17 00:00:00 2001 From: Lukas Trombach Date: Thu, 1 Dec 2022 17:16:54 +1300 Subject: [PATCH 2/2] update package versions --- cer-graphql/package-lock.json | 4 +-- cer-graphql/package.json | 2 +- hub-search-proxy/package-lock.json | 39 ++---------------------------- hub-search-proxy/package.json | 2 +- research-hub-web/package-lock.json | 4 +-- research-hub-web/package.json | 2 +- 6 files changed, 9 insertions(+), 44 deletions(-) diff --git a/cer-graphql/package-lock.json b/cer-graphql/package-lock.json index df2390ccb..b706da6be 100644 --- a/cer-graphql/package-lock.json +++ b/cer-graphql/package-lock.json @@ -1,12 +1,12 @@ { "name": "cer-graphql", - "version": "2.7.1", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cer-graphql", - "version": "2.7.1", + "version": "2.8.0", "license": "ISC", "dependencies": { "apollo-link-http": "^1.5.17", diff --git a/cer-graphql/package.json b/cer-graphql/package.json index 6711afa0d..a51312f77 100644 --- a/cer-graphql/package.json +++ b/cer-graphql/package.json @@ -1,6 +1,6 @@ { "name": "cer-graphql", - "version": "2.7.1", + "version": "2.8.0", "description": "A GraphQL server used to proxy and authorise requests to external data sources.", "main": "build/index.js", "scripts": { diff --git a/hub-search-proxy/package-lock.json b/hub-search-proxy/package-lock.json index 582ef4747..752dda2d1 100644 --- a/hub-search-proxy/package-lock.json +++ b/hub-search-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "hub-search-proxy", - "version": "2.7.1", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hub-search-proxy", - "version": "2.7.1", + "version": "2.8.0", "license": "ISC", "dependencies": { "@elastic/elasticsearch": "7.13.0", @@ -1242,13 +1242,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "peer": true - }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -6499,23 +6492,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/ora/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/ora/node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -14868,17 +14844,6 @@ "supports-color": "^7.1.0" } }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "peer": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, "cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", diff --git a/hub-search-proxy/package.json b/hub-search-proxy/package.json index e98642264..cc7022df2 100644 --- a/hub-search-proxy/package.json +++ b/hub-search-proxy/package.json @@ -1,6 +1,6 @@ { "name": "hub-search-proxy", - "version": "2.7.1", + "version": "2.8.0", "description": "Serverless Framework Lambda functions to interact with AWS ElasticSearch Service.", "main": "handler.js", "scripts": { diff --git a/research-hub-web/package-lock.json b/research-hub-web/package-lock.json index 4e481aedc..f3f6ed478 100644 --- a/research-hub-web/package-lock.json +++ b/research-hub-web/package-lock.json @@ -1,12 +1,12 @@ { "name": "research-hub-web", - "version": "2.7.1", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "research-hub-web", - "version": "2.7.1", + "version": "2.8.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/research-hub-web/package.json b/research-hub-web/package.json index f18e70c94..62272dd20 100644 --- a/research-hub-web/package.json +++ b/research-hub-web/package.json @@ -1,6 +1,6 @@ { "name": "research-hub-web", - "version": "2.7.1", + "version": "2.8.0", "license": "MIT", "scripts": { "ng": "ng",