From 8fe0852dca187fc3231152cd3dddc8961eaca86c Mon Sep 17 00:00:00 2001 From: Tinuola 'Tinu' Awopetu <24995224+tinuola@users.noreply.github.com> Date: Thu, 30 Jan 2025 12:17:05 -0800 Subject: [PATCH] feat: APPS 3110 Assemble Blog Listing page (#101) * wip * wip * wip: update logic for data images; adjust page styling * fix: parse article categories * wip: fix infinite scrolling * task: code cleanup * update: add displaySummary field to article gql * test: add basic cypress test * wip: add pageSummary toggle logic * task: update component library to 3.45.0 * add BlockCard ftva variant * fix: linting, remove v-html directive * update library package to 3.45.1 * update CSS after component pkg update * Add more conditions to cypress test * fix: remove custom rich-text padding-right * remove console statements * fix: ux/css updates * update query to incl. categories for featured artciles * fix: display featured articles' categories * fix: add page SectionHeaders with h3 * fix: remove unneeded css values * update featured blogs section title on mobile * update featuredArticles query with alternativeTitle field * add logic to parse alternate richtext featuredArticle title * update preview watcher to track pageTitle --------- Co-authored-by: tinuola Co-authored-by: JenDiamond --- cypress/e2e/blogListPage.cy.js | 31 +++ gql/queries/FTVAArticleList.gql | 16 +- package.json | 2 +- pages/blog/index.vue | 433 +++++++++++++++++++++++++++++--- pnpm-lock.yaml | 10 +- 5 files changed, 437 insertions(+), 55 deletions(-) create mode 100644 cypress/e2e/blogListPage.cy.js diff --git a/cypress/e2e/blogListPage.cy.js b/cypress/e2e/blogListPage.cy.js new file mode 100644 index 00000000..a87b625a --- /dev/null +++ b/cypress/e2e/blogListPage.cy.js @@ -0,0 +1,31 @@ +Cypress.on('uncaught:exception', () => { return false }) + +describe('Blog Listing page', () => { + beforeEach(() => { + cy.visit('/blog') + }) + + it('Visits Blog Listing page', () => { + cy.getByData('blog-page-title').should('be.visible') + + cy.getByData('featured-blog-0').should('be.visible') + + cy.getByData('featured-blog-1').should('be.visible') + + cy.getByData('featured-blog-2').should('be.visible') + + cy.getByData('latest-blogs').should('be.visible') + + cy.percySnapshot('eventslistpage', { widths: [768, 992, 1200] }) + }) + + it('Shows only one featured blog in mobile view', () => { + cy.viewport(750, 720) + + cy.getByData('featured-blog-0').should('be.visible') + + cy.getByData('featured-blog-1').should('not.be.visible') + + cy.getByData('featured-blog-2').should('not.be.visible') + }) +}) diff --git a/gql/queries/FTVAArticleList.gql b/gql/queries/FTVAArticleList.gql index a6e7b0d0..5597b369 100644 --- a/gql/queries/FTVAArticleList.gql +++ b/gql/queries/FTVAArticleList.gql @@ -7,9 +7,14 @@ query FTVAArticleList($slug: [String!]) { sectionHandle title: titleGeneral summary + displaySummary ftvaFeaturedArticles { title + ftvaAlternativeTitle postDate + articleCategories { + title + } ftvaHomepageDescription uri ftvaImage { @@ -17,16 +22,5 @@ query FTVAArticleList($slug: [String!]) { } } } - entries(section: ["ftvaArticle"], orderBy: "postDate DESC") { - typeHandle - id - title - postDate - ftvaHomepageDescription - uri - ftvaImage { - ...Image - } - } } diff --git a/package.json b/package.json index d07937b2..6d6eb25d 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "nuxt-graphql-request": "^7.0.5", "sass": "^1.66.1", "ucla-library-design-tokens": "^5.28.0", - "ucla-library-website-components": "3.44.0" + "ucla-library-website-components": "3.45.1" }, "engines": { "pnpm": "^9.12.1" diff --git a/pages/blog/index.vue b/pages/blog/index.vue index 8cb381e5..cc48dd41 100644 --- a/pages/blog/index.vue +++ b/pages/blog/index.vue @@ -4,6 +4,7 @@ import { SectionWrapper } from 'ucla-library-website-components' // HELPERS import _get from 'lodash/get' +import { useWindowSize, useInfiniteScroll } from '@vueuse/core' // GQL import FTVAArticleList from '../gql/queries/FTVAArticleList.gql' @@ -13,6 +14,8 @@ import { useContentIndexer } from '~/composables/useContentIndexer' const { $graphql } = useNuxtApp() +const route = useRoute() + const { data, error } = await useAsyncData('article-list', async () => { const data = await $graphql.default.request(FTVAArticleList) return data @@ -24,7 +27,7 @@ if (error.value) { }) } -if (!data.value.entries) { +if (!data.value.entry) { // console.log('no data') throw createError({ statusCode: 404, @@ -48,27 +51,213 @@ if (data.value.entry && import.meta.prerender) { // DATA const page = ref(_get(data.value, 'entry', {})) +const pageTitle = page.value.title +const pageSummary = page.value.summary const featuredArticles = page.value.ftvaFeaturedArticles -const articleList = ref(_get(data.value, 'entries', {})) -console.log('Data: ', data.value) + +// PREVIEW WATCHER FOR CRAFT CONTENT +watch(data, (newVal, oldVal) => { + // console.log('In watch preview enabled, newVal, oldVal', newVal, oldVal) + page.value = _get(newVal, 'entry', {}) + pageTitle.value = page.value.title + pageSummary.value = page.value.summary + featuredArticles.value = page.value.ftvaFeaturedArticles +}) + +// "STATE" +const desktopPage = useState('desktopPage', () => 1) // Persist desktop page +const desktopArticles = ref([]) // Desktop articles list +const mobileArticles = ref([]) // Mobile articles list +const articles = computed(() => (isMobile.value ? mobileArticles.value : desktopArticles.value)) const currentPage = ref(1) const documentsPerPage = 10 +const totalPages = ref(0) + +// INFINITE SCROLLING +const isLoading = ref(false) +const isMobile = ref(false) +const hasMore = ref(true) // Flag to control infinite scroll + +const scrollElem = ref(null) +const { reset } = useInfiniteScroll( + scrollElem, + async () => { + if (isMobile.value && hasMore.value && !isLoading.value) { + currentPage.value++ + await searchES() + } + }, + { distance: 100 } +) + +// HANDLE WINDOW SIZING +const { width } = useWindowSize() +watch(width, (newWidth) => { + const wasMobile = isMobile.value + + isMobile.value = newWidth <= 750 + // Reinitialize only when transitioning between mobile and desktop + if (wasMobile !== isMobile.value) { + handleScreenTransition() + } +}, { immediate: true }) + +// HANDLE SCREEN TRANSITIONS +function handleScreenTransition() { + if (isMobile.value) { + // Switching to mobile: save desktop page, clear query param + + desktopPage.value = currentPage.value + currentPage.value = 1 + mobileArticles.value = [] + hasMore.value = true + const { page, ...remainingQuery } = route.query + useRouter().push({ query: { ...remainingQuery } }) + } else { + // Switching to desktop: restore query param + if (totalPages.value === 1) + desktopPage.value = 1 + const restoredPage = desktopPage.value || 1 + useRouter().push({ query: { ...route.query, page: restoredPage.toString() } }) + currentPage.value = restoredPage + desktopArticles.value = [] + } + searchES() +} + +// ELASTIC SEARCH +async function searchES() { + if (isLoading.value || !hasMore.value) return + + isLoading.value = true + + // COMPOSABLE + const { paginatedArticlesQuery } = useArticlesListSearch() + + try { + const results = await paginatedArticlesQuery( + currentPage.value, + documentsPerPage, + 'postDate', + 'desc', + ['*'] + ) + + if (results && results.hits && results?.hits?.hits?.length > 0) { + const newArticles = results.hits.hits || [] -// TEST -const { paginatedArticlesQuery } = useArticlesListSearch() + if (isMobile.value) { + totalPages.value = 0 -onMounted(async () => { - const esOutput = await paginatedArticlesQuery( - currentPage.value, - documentsPerPage, - 'postDate', - 'asc' - ) + mobileArticles.value.push(...newArticles) + + hasMore.value = currentPage.value < Math.ceil(results.hits.total.value / documentsPerPage) + } else { + desktopArticles.value = newArticles + + totalPages.value = Math.ceil(results.hits.total.value / documentsPerPage) + } + } else { + totalPages.value = 0 + + hasMore.value = false + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Error fetching data:', error) + hasMore.value = false + } finally { + isLoading.value = false + } +} - console.log('ES output total hits: ', esOutput.hits.total.value) // 445 +watch( + () => route.query, + (newVal, oldVal) => { + isLoading.value = false + currentPage.value = route.query.page ? parseInt(route.query.page) : 1 + isMobile.value ? mobileArticles.value = [] : desktopArticles.value = [] + hasMore.value = true + searchES() + }, { deep: true, immediate: true } +) + +// PAGE SUMMARY +const showPageSummary = computed(() => { + return page.value?.summary && page.value?.displaySummary === 'yes' +}) + +// PARSED FEATURED ARTICLES +const parsedFeaturedArticles = computed(() => { + if (featuredArticles.length === 0) { + return + } + + return featuredArticles.map((obj) => { + const parsedTitle = parseRichTextTitle(obj) + + return { + image: obj.ftvaImage[0], + to: obj.uri, + title: parsedTitle, + category: parseArticleCategories(obj.articleCategories), + text: obj.ftvaHomepageDescription, + dateCreated: obj.postDate + } + }) +}) + +// PARSED ARTICLE LIST +const parsedArticles = computed(() => { + if (articles.value.length === 0) return [] + + return articles.value.map((obj) => { + return { + ...obj._source, + to: `/${obj._source.uri}`, + title: obj._source.title, + category: parseArticleCategories(obj._source.articleCategories), + description: obj._source.aboutTheAuthor, + startDate: obj._source.postDate, + endDate: obj._source.postDate, + image: isImageExists(obj) + } + }) }) +// GET FEATURED ARTICLES RICH TEXT TITLE +function parseRichTextTitle(obj) { + return !obj.ftvaAlternativeTitle ? obj.title : obj.ftvaAlternativeTitle +} + +// GET ARTICLE CATEGORIES +function parseArticleCategories(arr) { + if (arr.length === 0) return + return arr.map(obj => obj.title).join(', ') +} + +// GET IMAGE +function parsedCarouselImage(obj) { + return obj._source.imageCarousel +} + +function parsedFTVAImage(obj) { + return obj._source.ftvaImage +} + +function isImageExists(obj) { + // Use FTVA Image + if (parsedFTVAImage(obj) && parsedFTVAImage(obj).length === 1) { + return parsedFTVAImage(obj)[0] + } else if (parsedCarouselImage(obj) && parsedCarouselImage(obj).length >= 1) { + // Use ImageCarousel + return parsedCarouselImage(obj)[0] + } else { + return null + } +} + useHead({ title: page.value ? page.value.title : '... loading', meta: [ @@ -82,33 +271,201 @@ useHead({ - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd64767c..da1c50b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,8 +79,8 @@ importers: specifier: ^5.28.0 version: 5.28.0 ucla-library-website-components: - specifier: 3.44.0 - version: 3.44.0(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))(vuetify@3.7.4(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))) + specifier: 3.45.1 + version: 3.45.1(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))(vuetify@3.7.4(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))) packages: @@ -4459,8 +4459,8 @@ packages: ucla-library-design-tokens@5.28.0: resolution: {integrity: sha512-qgxGlK3/m0VwYGumzLZTTB/ae03DY41+80vFhm4+gukzh8XiqUsN71cg3ijk23Dl9awyicoELbRgnUV4nj3e0g==} - ucla-library-website-components@3.44.0: - resolution: {integrity: sha512-3Zi8VKvVt3iMoAW72YPRoeGPTmomZQF5AO5r42vbLz72TwyQ+M/sDunA5/rFjLXdF3TZ1Lx2mkEZJ4Jh1RwMng==} + ucla-library-website-components@3.45.1: + resolution: {integrity: sha512-XrgtE2o+Zv/uR2dfG0OqtCpsT3CGyiFP6HvVZPasWLE+4kPqA3r6AWSSVPhpRbg6ZCyVr0b+Kf6n7COUsN+7Lw==} engines: {pnpm: ^9.12.1} peerDependencies: vue: ^3.5.12 @@ -10001,7 +10001,7 @@ snapshots: ucla-library-design-tokens@5.28.0: {} - ucla-library-website-components@3.44.0(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))(vuetify@3.7.4(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))): + ucla-library-website-components@3.45.1(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))(vuetify@3.7.4(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))): dependencies: '@vuepic/vue-datepicker': 8.8.1(vue@3.5.12(typescript@5.6.3)) '@vueuse/components': 11.3.0(vue@3.5.12(typescript@5.6.3))