From dec32a1df7ad604dc8a67e0240cc0e99dd105d5c Mon Sep 17 00:00:00 2001 From: Cosimo Chetta <45094836+onim-at@users.noreply.github.com> Date: Wed, 11 Dec 2024 15:20:43 +0100 Subject: [PATCH 01/62] feat: create API for questions search (#3996) * feat: create API for questions search * fix: reset question search string on page reload * test: cypress for questions search * fix: search params extraction in api.questions --- .../src/integration/questions/search.spec.ts | 60 ++++++ src/pages/Question/QuestionFilterHeader.tsx | 4 + src/pages/Question/QuestionListing.tsx | 21 +- src/pages/Question/question.service.test.ts | 95 --------- src/pages/Question/question.service.ts | 185 ++++-------------- src/routes/api.questions.categories.ts | 24 +++ src/routes/api.questions.ts | 131 +++++++++++++ 7 files changed, 263 insertions(+), 257 deletions(-) create mode 100644 packages/cypress/src/integration/questions/search.spec.ts delete mode 100644 src/pages/Question/question.service.test.ts create mode 100644 src/routes/api.questions.categories.ts create mode 100644 src/routes/api.questions.ts diff --git a/packages/cypress/src/integration/questions/search.spec.ts b/packages/cypress/src/integration/questions/search.spec.ts new file mode 100644 index 0000000000..f46b679f3b --- /dev/null +++ b/packages/cypress/src/integration/questions/search.spec.ts @@ -0,0 +1,60 @@ +describe('[How To]', () => { + beforeEach(() => { + cy.visit('/questions') + }) + + describe('[By Everyone]', () => { + it('should clear filters after navigation', () => { + cy.get('[data-cy=questions-search-box]').clear().type(`raincoat`) + cy.url().should('include', 'q=raincoat') + cy.url().should('include', 'sort=MostRelevant') + + cy.get('[data-cy=category-select]').click() + cy.get('[id^="react-select-"]').contains('screening').click() + cy.url().should('include', 'category=categoryoix4r6grC1mMA0Xz3K') + + cy.get('[data-cy=page-link]').contains('Questions').click() + + cy.wait(2000) + cy.get('[data-cy=questions-search-box]') + .invoke('val') + .then((searchText) => expect(searchText).to.equal('')) + cy.get('[data-cy=category-select]').should('have.value', '') + }) + + it('should remove category filter after back navigation', () => { + cy.get('[data-cy=category-select]').click() + cy.get('[id^="react-select-"]').contains('screening').click() + cy.url().should('include', 'category=categoryoix4r6grC1mMA0Xz3K') + cy.go('back') + cy.get('[data-cy=category-select]').should('have.value', '') + cy.url().should('not.include', 'category=categoryoix4r6grC1mMA0Xz3K') + }) + + it('should remove search filter after back navigation', () => { + cy.get('[data-cy=questions-search-box]').clear().type(`raincoat`) + cy.url().should('include', 'q=raincoat') + + cy.go('back') + cy.wait(2000) + + cy.get('[data-cy=questions-search-box]') + .invoke('val') + .then((searchText) => expect(searchText).to.equal('')) + cy.url().should('not.include', 'q=raincoat') + }) + + it('should show question list items after visit a question', () => { + cy.get('[data-cy=question-list-item]:eq(0)').click() + cy.get('[data-cy=question-title]').should('be.visible') + cy.go('back') + cy.get('[data-cy=question-list-item]').should('be.visible') + }) + + it('should load more questions', () => { + cy.get('[data-cy=question-list-item]:eq(21)').should('not.exist') + cy.get('[data-cy=load-more]').click() + cy.get('[data-cy=question-list-item]:eq(21)').should('exist') + }) + }) +}) diff --git a/src/pages/Question/QuestionFilterHeader.tsx b/src/pages/Question/QuestionFilterHeader.tsx index 6d797ac710..22372b74bf 100644 --- a/src/pages/Question/QuestionFilterHeader.tsx +++ b/src/pages/Question/QuestionFilterHeader.tsx @@ -45,6 +45,10 @@ export const QuestionFilterHeader = () => { initCategories() }, []) + useEffect(() => { + setSearchString(q || '') + }, [q]) + const updateFilter = useCallback( (key: QuestionSearchParams, value: string) => { const params = new URLSearchParams(searchParams.toString()) diff --git a/src/pages/Question/QuestionListing.tsx b/src/pages/Question/QuestionListing.tsx index 86514fceb5..c7e12cadc1 100644 --- a/src/pages/Question/QuestionListing.tsx +++ b/src/pages/Question/QuestionListing.tsx @@ -7,12 +7,10 @@ import { questionService } from 'src/pages/Question/question.service' import { commentService } from 'src/services/commentService' import { Flex, Heading } from 'theme-ui' -import { ITEMS_PER_PAGE } from './constants' import { headings, listing } from './labels' import { QuestionFilterHeader } from './QuestionFilterHeader' import { QuestionListItem } from './QuestionListItem' -import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore' import type { IQuestion } from 'oa-shared' import type { QuestionSortOption } from './QuestionSortOptions' @@ -20,9 +18,9 @@ export const QuestionListing = () => { const [isFetching, setIsFetching] = useState(true) const [questions, setQuestions] = useState([]) const [total, setTotal] = useState(0) - const [lastVisible, setLastVisible] = useState< - QueryDocumentSnapshot | undefined - >(undefined) + const [lastVisibleId, setLastVisibleId] = useState( + undefined, + ) const { userStore } = useCommonStores().stores const [searchParams, setSearchParams] = useSearchParams() @@ -47,9 +45,7 @@ export const QuestionListing = () => { } }, [q, category, sort]) - const fetchQuestions = async ( - skipFrom?: QueryDocumentSnapshot, - ) => { + const fetchQuestions = async (skipFrom?: string | undefined) => { setIsFetching(true) try { @@ -60,7 +56,6 @@ export const QuestionListing = () => { category, sort, skipFrom, - ITEMS_PER_PAGE, ) if (result) { @@ -71,7 +66,7 @@ export const QuestionListing = () => { setQuestions(result.items) } - setLastVisible(result.lastVisible) + setLastVisibleId(result.lastVisibleId) setTotal(result.total) @@ -168,7 +163,11 @@ export const QuestionListing = () => { justifyContent: 'center', }} > - diff --git a/src/pages/Question/question.service.test.ts b/src/pages/Question/question.service.test.ts deleted file mode 100644 index 0b0b8e9b8a..0000000000 --- a/src/pages/Question/question.service.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import '@testing-library/jest-dom/vitest' - -import { describe, expect, it, vi } from 'vitest' - -import { exportedForTesting } from './question.service' - -const mockWhere = vi.fn() -const mockOrderBy = vi.fn() -const mockLimit = vi.fn() -vi.mock('firebase/firestore', () => ({ - collection: vi.fn(), - query: vi.fn(), - and: vi.fn(), - where: (path, op, value) => mockWhere(path, op, value), - limit: (limit) => mockLimit(limit), - orderBy: (field, direction) => mockOrderBy(field, direction), -})) - -vi.mock('../../stores/databaseV2/endpoints', () => ({ - DB_ENDPOINTS: { - questions: 'questions', - questionCategories: 'questionCategories', - }, -})) - -vi.mock('../../config/config', () => ({ - getConfigurationOption: vi.fn(), - FIREBASE_CONFIG: { - apiKey: 'AIyChVN', - databaseURL: 'https://test.firebaseio.com', - projectId: 'test', - storageBucket: 'test.appspot.com', - }, - localStorage: vi.fn(), - SITE: 'unit-tests', -})) - -describe('question.search', () => { - it('searches for text', () => { - // prepare - const words = ['test', 'text'] - - // act - exportedForTesting.createQueries(words, '', 'MostRelevant') - - // assert - expect(mockWhere).toHaveBeenCalledWith( - 'keywords', - 'array-contains-any', - words, - ) - }) - - it('filters by category', () => { - // prepare - const category = 'cat1' - - // act - exportedForTesting.createQueries([], category, 'MostRelevant') - - // assert - expect(mockWhere).toHaveBeenCalledWith( - 'questionCategory._id', - '==', - category, - ) - }) - - it('should not call orderBy if sorting by most relevant', () => { - // act - exportedForTesting.createQueries(['test'], '', 'MostRelevant') - - // assert - expect(mockOrderBy).toHaveBeenCalledTimes(0) - }) - - it('should call orderBy when sorting is not MostRelevant', () => { - // act - exportedForTesting.createQueries(['test'], '', 'Newest') - - // assert - expect(mockOrderBy).toHaveBeenLastCalledWith('_created', 'desc') - }) - - it('should limit results', () => { - // prepare - const take = 12 - - // act - exportedForTesting.createQueries(['test'], '', 'Newest', undefined, take) - - // assert - expect(mockLimit).toHaveBeenLastCalledWith(take) - }) -}) diff --git a/src/pages/Question/question.service.ts b/src/pages/Question/question.service.ts index 7a133c066d..3164e95a20 100644 --- a/src/pages/Question/question.service.ts +++ b/src/pages/Question/question.service.ts @@ -1,26 +1,13 @@ +import { collection, getDocs, query, where } from 'firebase/firestore' import { - and, - collection, - getCountFromServer, - getDocs, - limit, - orderBy, - query, - startAfter, - where, -} from 'firebase/firestore' -import { IModerationStatus } from 'oa-shared' -import { DB_ENDPOINTS } from 'src/models/dbEndpoints' + DB_ENDPOINTS, + type ICategory, + type IQuestion, + type IQuestionDB, +} from 'oa-shared' +import { logger } from 'src/logger' +import { firestore } from 'src/utils/firebase' -import { firestore } from '../../utils/firebase' - -import type { - DocumentData, - QueryDocumentSnapshot, - QueryFilterConstraint, - QueryNonFilterConstraint, -} from 'firebase/firestore' -import type { ICategory, IQuestion, IQuestionDB } from 'oa-shared' import type { QuestionSortOption } from './QuestionSortOptions' export enum QuestionSearchParams { @@ -33,141 +20,43 @@ const search = async ( words: string[], category: string, sort: QuestionSortOption, - snapshot?: QueryDocumentSnapshot, - take: number = 10, -) => { - const { itemsQuery, countQuery } = createQueries( - words, - category, - sort, - snapshot, - take, - ) - - const documentSnapshots = await getDocs(itemsQuery) - const lastVisible = documentSnapshots.docs - ? documentSnapshots.docs[documentSnapshots.docs.length - 1] - : undefined - - const items = documentSnapshots.docs - ? documentSnapshots.docs.map((x) => { - const item = x.data() as IQuestion.Item - return { - ...item, - commentCount: 0, - } - }) - : [] - const total = (await getCountFromServer(countQuery)).data().count - - return { items, total, lastVisible } -} - -const createQueries = ( - words: string[], - category: string, - sort: QuestionSortOption, - snapshot?: QueryDocumentSnapshot, - take: number = 10, + lastDocId?: string | undefined, ) => { - const collectionRef = collection(firestore, DB_ENDPOINTS.questions) - let filters: QueryFilterConstraint[] = [ - and( - where('_deleted', '!=', true), - where('moderation', '==', IModerationStatus.ACCEPTED), - ), - ] - let constraints: QueryNonFilterConstraint[] = [] - - if (words?.length > 0) { - filters = [...filters, and(where('keywords', 'array-contains-any', words))] - } - - if (category) { - filters = [...filters, where('questionCategory._id', '==', category)] - } - - if (sort) { - const sortConstraint = getSort(sort) - - if (sortConstraint) { - constraints = [...constraints, sortConstraint] + try { + const url = new URL('/api/questions', window.location.origin) + url.searchParams.set('words', words.join(',')) + url.searchParams.set('category', category) + url.searchParams.set('sort', sort) + url.searchParams.set('lastDocId', lastDocId ?? '') + const response = await fetch(url) + + const { items, total } = (await response.json()) as { + items: IQuestion.Item[] + total: number } + const lastVisibleId = items ? items[items.length - 1]._id : undefined + return { items, total, lastVisibleId } + } catch (error) { + logger.error('Failed to fetch questions', { error }) + return { items: [], total: 0 } } - - const countQuery = query(collectionRef, and(...filters), ...constraints) - - if (snapshot) { - constraints = [...constraints, startAfter(snapshot)] - } - - const itemsQuery = query( - collectionRef, - and(...filters), - ...constraints, - limit(take), - ) - - return { countQuery, itemsQuery } } const getQuestionCategories = async () => { - const collectionRef = collection(firestore, DB_ENDPOINTS.questionCategories) - - return (await getDocs(query(collectionRef))).docs.map( - (x) => x.data() as ICategory, - ) -} - -const createDraftQuery = (userId: string) => { - const collectionRef = collection(firestore, DB_ENDPOINTS.questions) - const filters = and( - where('_createdBy', '==', userId), - where('moderation', 'in', [ - IModerationStatus.AWAITING_MODERATION, - IModerationStatus.DRAFT, - IModerationStatus.IMPROVEMENTS_NEEDED, - IModerationStatus.REJECTED, - ]), - where('_deleted', '!=', true), - ) - - const countQuery = query(collectionRef, filters) - const itemsQuery = query(collectionRef, filters, orderBy('_modified', 'desc')) - - return { countQuery, itemsQuery } -} - -const getDraftCount = async (userId: string) => { - const { countQuery } = createDraftQuery(userId) - - return (await getCountFromServer(countQuery)).data().count -} - -const getDrafts = async (userId: string) => { - const { itemsQuery } = createDraftQuery(userId) - const docs = await getDocs(itemsQuery) - - return docs.docs ? docs.docs.map((x) => x.data() as IQuestion.Item) : [] -} + try { + const response = await fetch(`/api/questions/categories`) + const responseJson = (await response.json()) as { + categories: ICategory[] + } -const getSort = (sort: QuestionSortOption) => { - switch (sort) { - case 'Comments': - return orderBy('commentCount', 'desc') - case 'LeastComments': - return orderBy('commentCount', 'asc') - case 'Newest': - return orderBy('_created', 'desc') - case 'LatestComments': - return orderBy('latestCommentDate', 'desc') - case 'LatestUpdated': - return orderBy('_modified', 'desc') + return responseJson.categories + } catch (error) { + logger.error('Failed to fetch questions', { error }) + return [] } } const getBySlug = async (slug: string) => { - // Get all that match the slug, to avoid creating an index (blocker for cypress tests) let snapshot = await getDocs( query( collection(firestore, DB_ENDPOINTS.questions), @@ -195,11 +84,5 @@ const getBySlug = async (slug: string) => { export const questionService = { search, getQuestionCategories, - getDraftCount, - getDrafts, getBySlug, } - -export const exportedForTesting = { - createQueries, -} diff --git a/src/routes/api.questions.categories.ts b/src/routes/api.questions.categories.ts new file mode 100644 index 0000000000..a1053aabdc --- /dev/null +++ b/src/routes/api.questions.categories.ts @@ -0,0 +1,24 @@ +import { json } from '@remix-run/node' +import { collection, getDocs, query } from 'firebase/firestore' +import Keyv from 'keyv' +import { DB_ENDPOINTS } from 'src/models/dbEndpoints' +import { firestore } from 'src/utils/firebase' + +import type { ICategory } from 'oa-shared' + +const cache = new Keyv({ ttl: 3600000 }) // ttl: 60 minutes + +export const loader = async () => { + const cachedCategories = await cache.get('questionCategories') + + // check if cached categories are available, if not - load from db and cache them + if (cachedCategories) return json({ categories: cachedCategories }) + + const collectionRef = collection(firestore, DB_ENDPOINTS.questionCategories) + const categories = (await getDocs(query(collectionRef))).docs.map( + (x) => x.data() as ICategory, + ) + + cache.set('questionCategories', categories) + return json({ categories }) +} diff --git a/src/routes/api.questions.ts b/src/routes/api.questions.ts new file mode 100644 index 0000000000..6936af3e7d --- /dev/null +++ b/src/routes/api.questions.ts @@ -0,0 +1,131 @@ +import { + and, + collection, + doc, + getCountFromServer, + getDoc, + getDocs, + limit, + orderBy, + query, + startAfter, + where, +} from 'firebase/firestore' +import { IModerationStatus } from 'oa-shared' +import { DB_ENDPOINTS } from 'src/models/dbEndpoints' +import { ITEMS_PER_PAGE } from 'src/pages/Question/constants' +import { firestore } from 'src/utils/firebase' + +import type { + QueryFilterConstraint, + QueryNonFilterConstraint, +} from 'firebase/firestore' +import type { IQuestion } from 'oa-shared' +import type { QuestionSortOption } from 'src/pages/Question/QuestionSortOptions' + +export const loader = async ({ request }) => { + const url = new URL(request.url) + const params = new URLSearchParams(url.search) + const words: string[] = + params.get('words') != '' ? params.get('words')?.split(',') ?? [] : [] + const category = params.get('category') || '' + const sort = params.get('sort') as QuestionSortOption + const lastDocId = params.get('lastDocId') || '' + const { itemsQuery, countQuery } = await createQueries( + words, + category, + sort, + lastDocId, + ITEMS_PER_PAGE, + ) + + const documentSnapshots = await getDocs(itemsQuery) + + const items = documentSnapshots.docs + ? documentSnapshots.docs.map((x) => { + const item = x.data() as IQuestion.Item + return { + ...item, + commentCount: 0, + } + }) + : [] + const total = (await getCountFromServer(countQuery)).data().count + + return { items, total } +} + +const createQueries = async ( + words: string[], + category: string, + sort: QuestionSortOption, + lastDocId?: string | undefined, + take: number = 10, +) => { + const collectionRef = collection(firestore, DB_ENDPOINTS.questions) + let filters: QueryFilterConstraint[] = [ + and( + where('_deleted', '!=', true), + where('moderation', '==', IModerationStatus.ACCEPTED), + ), + ] + let constraints: QueryNonFilterConstraint[] = [] + + if (words?.length > 0) { + filters = [...filters, and(where('keywords', 'array-contains-any', words))] + } + + if (category) { + filters = [...filters, where('questionCategory._id', '==', category)] + } + + if (sort) { + const sortConstraint = getSort(sort) + + if (sortConstraint) { + constraints = [...constraints, sortConstraint] + } + } + + const countQuery = query(collectionRef, and(...filters), ...constraints) + + if (lastDocId) { + const lastDocSnapshot = await getDoc( + doc(collection(firestore, DB_ENDPOINTS.questions), lastDocId), + ) + + if (!lastDocSnapshot.exists) { + throw new Error('Document with the provided ID does not exist.') + } + startAfter(lastDocSnapshot) + constraints.push(startAfter(lastDocSnapshot)) + } + + const itemsQuery = query( + collectionRef, + and(...filters), + ...constraints, + limit(take), + ) + + return { countQuery, itemsQuery } +} + +const getSort = (sort: QuestionSortOption) => { + switch (sort) { + case 'Comments': + return orderBy('commentCount', 'desc') + case 'LeastComments': + return orderBy('commentCount', 'asc') + case 'Newest': + return orderBy('_created', 'desc') + case 'LatestComments': + return orderBy('latestCommentDate', 'desc') + case 'LatestUpdated': + return orderBy('_modified', 'desc') + } +} + +export const exportedForTesting = { + createQueries, +} From 2e7446e78a56f6b325f6119da860c871cfbc5590 Mon Sep 17 00:00:00 2001 From: Ben Furber Date: Wed, 11 Dec 2024 11:12:03 +0000 Subject: [PATCH 02/62] ci: uncomment howto discussion spec --- .../src/integration/howto/discussions.spec.ts | 24 +++++++++---------- shared/mocks/data/discussions.ts | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/cypress/src/integration/howto/discussions.spec.ts b/packages/cypress/src/integration/howto/discussions.spec.ts index ae6bef9b35..e819d287d5 100644 --- a/packages/cypress/src/integration/howto/discussions.spec.ts +++ b/packages/cypress/src/integration/howto/discussions.spec.ts @@ -3,24 +3,24 @@ import { ExternalLinkLabel } from 'oa-shared' -// import { MOCK_DATA } from '../../data' +import { MOCK_DATA } from '../../data' import { howto } from '../../fixtures/howto' import { generateNewUserDetails } from '../../utils/TestUtils' -// const howtos = Object.values(MOCK_DATA.howtos) +const howtos = Object.values(MOCK_DATA.howtos) -// const item = howtos[0] -// const howtoDiscussion = Object.values(MOCK_DATA.discussions).find( -// ({ sourceId }) => sourceId === item._id, -// ) +const item = howtos[0] +const howtoDiscussion = Object.values(MOCK_DATA.discussions).find( + ({ sourceId }) => sourceId === item._id, +) describe('[Howto.Discussions]', () => { - // it('can open using deep links', () => { - // const firstComment = howtoDiscussion.comments[0] - // cy.visit(`/how-to/${item.slug}#comment:${firstComment._id}`) - // cy.wait(2000) - // cy.checkCommentItem(firstComment.text, 2) - // }) + it('can open using deep links', () => { + const firstComment = howtoDiscussion.comments[0] + cy.visit(`/how-to/${item.slug}#comment:${firstComment._id}`) + cy.wait(2000) + cy.checkCommentItem(firstComment.text, 2) + }) it('allows authenticated users to contribute to discussions', () => { const visitor = generateNewUserDetails() diff --git a/shared/mocks/data/discussions.ts b/shared/mocks/data/discussions.ts index 5462806a3b..d8fafc43c8 100644 --- a/shared/mocks/data/discussions.ts +++ b/shared/mocks/data/discussions.ts @@ -201,7 +201,7 @@ export const discussions = { creatorCountry: 'bo', creatorName: 'demo_admin', parentCommentId: null, - text: "Thanks for this how-to, it's taught me loads.", + text: "Thanks for this project, it's taught me loads.", }, { _created: '2022-03-29T22:10:12.271Z', From 7a248b8d94143c428ec2d006d54f088245aaf895 Mon Sep 17 00:00:00 2001 From: Ben Furber Date: Wed, 11 Dec 2024 11:20:53 +0000 Subject: [PATCH 03/62] fix: spec that related to db data change --- packages/cypress/src/integration/map.spec.ts | 255 +++++++++---------- 1 file changed, 123 insertions(+), 132 deletions(-) diff --git a/packages/cypress/src/integration/map.spec.ts b/packages/cypress/src/integration/map.spec.ts index 987841b340..cd9ae8487d 100644 --- a/packages/cypress/src/integration/map.spec.ts +++ b/packages/cypress/src/integration/map.spec.ts @@ -1,141 +1,132 @@ -// const userId = 'demo_user' -// const profileTypesCount = 5 -// const urlLondon = -// 'https://nominatim.openstreetmap.org/search?format=json&q=london&accept-language=en' +const userId = 'demo_user' +const profileTypesCount = 5 +const urlLondon = + 'https://nominatim.openstreetmap.org/search?format=json&q=london&accept-language=en' describe('[Map]', () => { beforeEach(() => { localStorage.setItem('VITE_THEME', 'fixing-fashion') }) - // it('[Shows expected pins]', () => { - // cy.viewport('macbook-16') - - // cy.step('Shows all pins onload') - // cy.visit('/map') - - // cy.step('Old map pins can be clicked on') - // cy.get(`[data-cy=pin-${userId}]`).click() - // cy.get('[data-cy=MapMemberCard]').within(() => { - // cy.get('[data-cy=Username]').contains(userId) - // }) - // cy.url().should('include', `#${userId}`) - - // cy.step('Old map pins can be hidden') - // cy.get('.markercluster-map').click(0, 0) - // cy.get('[data-cy=MapMemberCard]').should('not.exist') - // cy.url().should('not.include', `#${userId}`) - - // cy.step('Link to new map visible and clickable') - // cy.wait(500) // wait for interaction - // cy.get('[data-cy=Banner]').contains('Test it out!').click() - // cy.get('[data-cy=Banner]').contains('go back to the old one!') - - // cy.step('New map shows the cards') - // cy.get('[data-cy="welome-header"]').should('be.visible') - // cy.get('[data-cy="CardList-desktop"]').should('be.visible') - // cy.get('[data-cy="list-results"]').contains(/\d+ results in view/) - - // cy.step('Map filters can be used') - // cy.get('[data-cy=MapFilterProfileTypeCardList]') - // .first() - // .children() - // .should('have.length', profileTypesCount) - // cy.get('[data-cy=MapListFilter]').first().click() - - // cy.get('[data-cy=MapListFilter-active]').first().click() - // cy.get('[data-cy="list-results"]').contains(/\d+ results in view/) - - // cy.step('Clusters show up') - // cy.get('.icon-cluster-many') - // .first() - // .within(() => { - // cy.get('.icon-cluster-text').contains(/\d+/) - // }) - - // cy.step('Users can select filters') - // cy.get('[data-cy=MapFilterList]').should('not.exist') - // cy.get('[data-cy=MapFilterList-OpenButton]').first().click() - // cy.get('[data-cy=MapFilterList]').should('be.visible') - // cy.get('[data-cy=MapFilterListItem-profile]').first().click() - // cy.get('[data-cy=MapFilterListItem-profile-active]').first().click() - // cy.get('[data-cy=MapFilterListItem-tag]').first().click() - // cy.get('[data-cy=MapFilterListItem-tag-active]').first().click() - // cy.get('[data-cy=MapFilterList-ShowResultsButton]').first().click() - - // cy.step('As the user moves in the list updates') - // for (let i = 0; i < 6; i++) { - // cy.get('.leaflet-control-zoom-in').click() - // } - // cy.get('[data-cy="list-results"]').contains('1 result') - // cy.get('[data-cy="CardList-desktop"]').within(() => { - // cy.get('[data-cy=CardListItem]').within(() => { - // cy.contains(userId) - // cy.get('[data-cy="MemberBadge-member"]') - // }) - // }) - // cy.get('[data-cy=CardListItem]').contains(userId).click() - // cy.get('[data-cy="PinProfile"]') - // .get('[data-cy="Username"]') - // .contains(userId) - // cy.get('[data-cy=CardListItem-selected]').first().click() - - // cy.step('New map pins can be clicked on') - // cy.get(`[data-cy=pin-${userId}]`).click() - // cy.get('[data-cy=PinProfile]').within(() => { - // cy.get('[data-cy=Username]').contains(userId) - // cy.get('[data-cy=ProfileTagsList]').contains('Organise Meetups') - // }) - // cy.url().should('include', `#${userId}`) - - // cy.step('New map pins can be hidden with the cross button') - // cy.get('[data-cy=PinProfile]').should('be.visible') - // cy.get('[data-cy=PinProfileCloseButton]').click() - // cy.url().should('not.include', `#${userId}`) - // cy.get('[data-cy=PinProfile]').should('not.exist') - // cy.get(`[data-cy=pin-${userId}]`).click() - // cy.url().should('include', `#${userId}`) - - // cy.step('New map pins can be hidden by clicking the map') - // cy.get('[data-cy=PinProfile]').should('be.visible') - // cy.get('.markercluster-map').click(10, 10) - // cy.url().should('not.include', `#${userId}`) - // cy.get('[data-cy=PinProfile]').should('not.exist') - - // cy.step('Mobile list view can be shown') - // cy.viewport('samsung-note9') - // cy.get('.leaflet-control-zoom-out').click() - // cy.get('.leaflet-control-zoom-out').click() - // cy.get('.leaflet-control-zoom-out').click() - // cy.get('[data-cy="CardList-desktop"]').should('not.be.visible') - // cy.get('[data-cy="CardList-mobile"]').should('not.be.visible') - - // cy.get('[data-cy="ShowMobileListButton"]').click() - // cy.get('[data-cy="CardList-mobile"]').within(() => { - // cy.get('[data-cy=CardListItem]') - // .last() - // .within(() => { - // cy.contains(userId) - // cy.get('[data-cy="MemberBadge-member"]') - // }) - // }) - // cy.get('[data-cy=MapFilterProfileTypeCardList-ButtonRight]') - // .last() - // .click() - // .click() - // cy.get('[data-cy=MapListFilter]').last().click() - - // cy.step('Mobile list view can be hidden') - // cy.get('[data-cy="ShowMapButton"]').click() - // cy.get('[data-cy="CardList-mobile"]').should('not.be.visible') - - // cy.step('The whole map can be searched') - // cy.get('[data-cy="ShowMobileListButton"]').click() - // cy.get('[data-cy=osm-geocoding]').last().click().type('london') - // cy.intercept(urlLondon).as('londonSearch') - // cy.wait('@londonSearch') - // cy.contains('London, Greater London, England, United Kingdom').click() - // }) + it('[Shows expected pins]', () => { + cy.viewport('macbook-16') + + cy.step('Shows all pins onload') + cy.visit('/map') + + cy.step('Old map pins can be clicked on') + cy.get(`[data-cy=pin-${userId}]`).click() + cy.get('[data-cy=MapMemberCard]').within(() => { + cy.get('[data-cy=Username]').contains(userId) + }) + cy.url().should('include', `#${userId}`) + + cy.step('Old map pins can be hidden') + cy.get('.markercluster-map').click(0, 0) + cy.get('[data-cy=MapMemberCard]').should('not.exist') + cy.url().should('not.include', `#${userId}`) + + cy.step('Link to new map visible and clickable') + cy.wait(500) // wait for interaction + cy.get('[data-cy=Banner]').contains('Test it out!').click() + cy.get('[data-cy=Banner]').contains('go back to the old one!') + + cy.step('New map shows the cards') + cy.get('[data-cy="welome-header"]').should('be.visible') + cy.get('[data-cy="CardList-desktop"]').should('be.visible') + cy.get('[data-cy="list-results"]').contains(/\d+ results in view/) + + cy.step('Map filters can be used') + cy.get('[data-cy=MapFilterProfileTypeCardList]') + .first() + .children() + .should('have.length', profileTypesCount) + cy.get('[data-cy=MapListFilter]').first().click() + + cy.get('[data-cy=MapListFilter-active]').first().click() + cy.get('[data-cy="list-results"]').contains(/\d+ results in view/) + + cy.step('Clusters show up') + cy.get('.icon-cluster-many') + .first() + .within(() => { + cy.get('.icon-cluster-text').contains(/\d+/) + }) + + cy.step('Users can select filters') + cy.get('[data-cy=MapFilterList]').should('not.exist') + cy.get('[data-cy=MapFilterList-OpenButton]').first().click() + cy.get('[data-cy=MapFilterList]').should('be.visible') + cy.get('[data-cy=MapFilterListItem-profile]').first().click() + cy.get('[data-cy=MapFilterListItem-profile-active]').first().click() + cy.get('[data-cy=MapFilterListItem-tag]').first().click() + cy.get('[data-cy=MapFilterListItem-tag-active]').first().click() + cy.get('[data-cy=MapFilterList-ShowResultsButton]').first().click() + + cy.step('As the user moves in the list updates') + for (let i = 0; i < 6; i++) { + cy.get('.leaflet-control-zoom-in').click() + } + cy.get('[data-cy="list-results"]').contains('1 result') + cy.get('[data-cy="CardList-desktop"]').within(() => { + cy.get('[data-cy=CardListItem]').within(() => { + cy.contains(userId) + cy.get('[data-cy="MemberBadge-workshop"]') + }) + }) + cy.get('[data-cy=CardListItem]').contains(userId).click() + cy.get('[data-cy="PinProfile"]') + .get('[data-cy="Username"]') + .contains(userId) + cy.get('[data-cy=CardListItem-selected]').first().click() + + cy.step('New map pins can be clicked on') + cy.get(`[data-cy=pin-${userId}]`).click() + cy.get('[data-cy=PinProfile]').within(() => { + cy.get('[data-cy=Username]').contains(userId) + cy.get('[data-cy=ProfileTagsList]').contains('Organise Meetups') + }) + cy.url().should('include', `#${userId}`) + + cy.step('New map pins can be hidden with the cross button') + cy.get('[data-cy=PinProfile]').should('be.visible') + cy.get('[data-cy=PinProfileCloseButton]').click() + cy.url().should('not.include', `#${userId}`) + cy.get('[data-cy=PinProfile]').should('not.exist') + cy.get(`[data-cy=pin-${userId}]`).click() + cy.url().should('include', `#${userId}`) + + cy.step('New map pins can be hidden by clicking the map') + cy.get('[data-cy=PinProfile]').should('be.visible') + cy.get('.markercluster-map').click(10, 10) + cy.url().should('not.include', `#${userId}`) + cy.get('[data-cy=PinProfile]').should('not.exist') + + cy.step('Mobile list view can be shown') + cy.viewport('samsung-note9') + cy.get('.leaflet-control-zoom-out').click() + cy.get('.leaflet-control-zoom-out').click() + cy.get('.leaflet-control-zoom-out').click() + cy.get('[data-cy="CardList-desktop"]').should('not.be.visible') + cy.get('[data-cy="CardList-mobile"]').should('not.be.visible') + + cy.get('[data-cy="ShowMobileListButton"]').click() + cy.get('[data-cy="CardList-mobile"]').within(() => { + cy.contains(userId) + cy.get('[data-cy="MemberBadge-workshop"]') + }) + + cy.step('Mobile list view can be hidden') + cy.get('[data-cy="ShowMapButton"]').click() + cy.get('[data-cy="CardList-mobile"]').should('not.be.visible') + + cy.step('The whole map can be searched') + cy.get('[data-cy="ShowMobileListButton"]').click() + cy.get('[data-cy=osm-geocoding]').last().click().type('london') + cy.intercept(urlLondon).as('londonSearch') + cy.wait('@londonSearch') + cy.contains('London, Greater London, England, United Kingdom').click() + }) it('Test zoom out/ globe button + zoom in to users location button', () => { cy.viewport('macbook-16') From c410a3f1e6b1a0c23a8ee0f8f1a2d2d4ce48c482 Mon Sep 17 00:00:00 2001 From: Devkuni <155117116+detrina@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:13:55 +0100 Subject: [PATCH 04/62] fix: typo on questions data mock (#4028) --- shared/mocks/data/questions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/mocks/data/questions.ts b/shared/mocks/data/questions.ts index da344a1d33..6dd3e48b35 100644 --- a/shared/mocks/data/questions.ts +++ b/shared/mocks/data/questions.ts @@ -85,7 +85,7 @@ export const questions = { _modified: '2024-03-18T15:14:25.029Z', commentCount: 0, creatorCountry: '', - description: "What's the deal with sreenings?", + description: "What's the deal with screenings?", images: [], keywords: ['screening', 'question', 'intro'], latestCommentDate: '20 March 2024 at 10:10:10 UTC', From 25af96ea67147a127681ef1e7414253a6954f9d3 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:15:21 +0000 Subject: [PATCH 05/62] docs: add detrina as a contributor for doc (#4040) --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 1ec9455278..6a01382042 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -880,6 +880,15 @@ "contributions": [ "code" ] + }, + { + "login": "detrina", + "name": "Devkuni", + "avatar_url": "https://avatars.githubusercontent.com/u/155117116?v=4", + "profile": "https://github.com/detrina", + "contributions": [ + "doc" + ] } ], "projectName": "community-platform", diff --git a/README.md b/README.md index 0d082ea3b1..c66550e791 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ Thanks go to these wonderful people ([emoji key](https://allcontributors.org/doc Dalibor Mrลกka
Dalibor Mrลกka

๐ŸŽจ Skylar Ray
Skylar Ray

๐Ÿ“– Johannes RoรŸ
Johannes RoรŸ

๐Ÿ’ป + Devkuni
Devkuni

๐Ÿ“– From 50a2e09d7a9854090e07672f829fd8b0f1496408 Mon Sep 17 00:00:00 2001 From: Bilog WEB3 <155262265+Bilogweb3@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:17:09 +0100 Subject: [PATCH 06/62] fix: typos on documentation (#4029) --- BOUNTIES.md | 2 +- docs/supabase.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/BOUNTIES.md b/BOUNTIES.md index a8febf39b3..b4edd7ae6a 100644 --- a/BOUNTIES.md +++ b/BOUNTIES.md @@ -24,7 +24,7 @@ _Note_ - the exact bounty level is not included as knowing in advance how much w ## How do I claim them? -### Step 1 - Assigned the issue +### Step 1 - Assign the issue Drop a message in the issue thread to say that you are interested taking it on. If the issue is already assigned, or we think it might be too much for a new or individual contributor we might suggest looking at other issues instead. Otherwise it's all yours :D diff --git a/docs/supabase.md b/docs/supabase.md index 18fa5a30c1..89e588a0c7 100644 --- a/docs/supabase.md +++ b/docs/supabase.md @@ -24,7 +24,7 @@ After making schema changes, use the this command to create a migration file: Multi-tenancy is a requirement because: - Single login for all websites. -- Easier maintenence and migrations. +- Easier maintenance and migrations. With supabase there are a few ways we can do multi-tenancy: From 25ad53187694ee78fa63beb894efdf536aef45c8 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:17:52 +0000 Subject: [PATCH 07/62] docs: add Bilogweb3 as a contributor for doc (#4041) --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 6a01382042..73950c2952 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -889,6 +889,15 @@ "contributions": [ "doc" ] + }, + { + "login": "Bilogweb3", + "name": "Bilog WEB3", + "avatar_url": "https://avatars.githubusercontent.com/u/155262265?v=4", + "profile": "https://github.com/Bilogweb3", + "contributions": [ + "doc" + ] } ], "projectName": "community-platform", diff --git a/README.md b/README.md index c66550e791..91332451fa 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ Thanks go to these wonderful people ([emoji key](https://allcontributors.org/doc Skylar Ray
Skylar Ray

๐Ÿ“– Johannes RoรŸ
Johannes RoรŸ

๐Ÿ’ป Devkuni
Devkuni

๐Ÿ“– + Bilog WEB3
Bilog WEB3

๐Ÿ“– From 62ca78c87c6deafce46bac49cce2cef45ae55cef Mon Sep 17 00:00:00 2001 From: Ben Furber Date: Fri, 13 Dec 2024 16:43:08 +0000 Subject: [PATCH 08/62] feat: update ui of map badges on list view --- packages/components/package.json | 1 + .../components/src/CardButton/CardButton.tsx | 12 +- packages/components/src/Icon/Icon.tsx | 2 +- .../MapFilterList/MapFilterList.stories.tsx | 2 +- .../MapFilterProfileTypeCardList.tsx | 161 ------------------ .../MemberTypeVerticalList.client.tsx | 72 ++++++++ .../MemberTypeVerticalList.stories.tsx} | 67 ++------ .../MemberTypeVerticalList.test.tsx | 38 +++++ .../components/src/VerticalList/Arrows.tsx | 52 ++++++ .../src/VerticalList/VerticalList.stories.tsx | 32 ++++ .../src/VerticalList/VerticalList.tsx | 63 +++++++ packages/components/src/index.ts | 3 +- packages/cypress/src/integration/map.spec.ts | 14 +- packages/themes/src/common/commonStyles.ts | 2 +- .../Content/MapView/MapWithListHeader.tsx | 8 +- yarn.lock | 38 +++++ 16 files changed, 341 insertions(+), 226 deletions(-) delete mode 100644 packages/components/src/MapFilterProfileTypeCardList/MapFilterProfileTypeCardList.tsx create mode 100644 packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.client.tsx rename packages/components/src/{MapFilterProfileTypeCardList/MapFilterProfileTypeCardList.stories.tsx => MemberTypeVerticalList/MemberTypeVerticalList.stories.tsx} (50%) create mode 100644 packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.test.tsx create mode 100644 packages/components/src/VerticalList/Arrows.tsx create mode 100644 packages/components/src/VerticalList/VerticalList.stories.tsx create mode 100644 packages/components/src/VerticalList/VerticalList.tsx diff --git a/packages/components/package.json b/packages/components/package.json index f002f6d46b..16a411080f 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -33,6 +33,7 @@ "oa-themes": "workspace:^", "photoswipe": "^5.4.1", "react-country-flag": "^3.1.0", + "react-horizontal-scrolling-menu": "^8.2.0", "react-icons": "^5.3.0", "react-image-crop": "^11.0.5", "react-player": "^2.16.0", diff --git a/packages/components/src/CardButton/CardButton.tsx b/packages/components/src/CardButton/CardButton.tsx index 84bf3d5f32..db5cf0d7b5 100644 --- a/packages/components/src/CardButton/CardButton.tsx +++ b/packages/components/src/CardButton/CardButton.tsx @@ -16,27 +16,31 @@ export const CardButton = (props: IProps) => { sx={{ alignItems: 'center', alignContent: 'center', - marginTop: '2px', + marginTop: '4px', borderRadius: 2, padding: 0, transition: 'borderBottom 0.2s, transform 0.2s', '&:hover': !isSelected && { animationSpeed: '0.3s', cursor: 'pointer', - marginTop: '0', - borderBottom: '4px solid', + marginTop: '2px', + marginBottom: '2px', + borderBottom: '2px solid', transform: 'translateY(-2px)', borderColor: 'black', }, '&:active': { transform: 'translateY(1px)', + marginTop: '2px', + marginBottom: '2px', borderBottom: '3px solid', borderColor: 'grey', transition: 'borderBottom 0.2s, transform 0.2s, borderColor 0.2s', }, ...(isSelected ? { - marginTop: '0', + marginTop: '1px', + marginBottom: '2px', borderBottom: '4px solid', borderColor: 'grey', transform: 'translateY(-2px)', diff --git a/packages/components/src/Icon/Icon.tsx b/packages/components/src/Icon/Icon.tsx index 0730934efe..620c118207 100644 --- a/packages/components/src/Icon/Icon.tsx +++ b/packages/components/src/Icon/Icon.tsx @@ -45,7 +45,7 @@ interface IGlyphProps { glyph: keyof IGlyphs } -export interface IProps { +export interface IProps extends React.ButtonHTMLAttributes { glyph: keyof IGlyphs color?: string size?: number | string diff --git a/packages/components/src/MapFilterList/MapFilterList.stories.tsx b/packages/components/src/MapFilterList/MapFilterList.stories.tsx index 60a683ade2..5c6a0e5945 100644 --- a/packages/components/src/MapFilterList/MapFilterList.stories.tsx +++ b/packages/components/src/MapFilterList/MapFilterList.stories.tsx @@ -4,7 +4,7 @@ import type { Meta, StoryFn } from '@storybook/react' import type { MapFilterOptionsList } from 'oa-shared' export default { - title: 'Components/MapFilterList', + title: 'Map/MapFilterList', component: MapFilterList, } as Meta diff --git a/packages/components/src/MapFilterProfileTypeCardList/MapFilterProfileTypeCardList.tsx b/packages/components/src/MapFilterProfileTypeCardList/MapFilterProfileTypeCardList.tsx deleted file mode 100644 index c64eb5393e..0000000000 --- a/packages/components/src/MapFilterProfileTypeCardList/MapFilterProfileTypeCardList.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { Flex, Text } from 'theme-ui' - -import { ButtonIcon } from '../ButtonIcon/ButtonIcon' -import { CardButton } from '../CardButton/CardButton' -import { MemberBadge } from '../MemberBadge/MemberBadge' - -import type { - MapFilterOption, - MapFilterOptionsList, - ProfileTypeName, -} from 'oa-shared' - -export interface IProps { - activeFilters: MapFilterOptionsList - availableFilters: MapFilterOptionsList - onFilterChange: (filter: MapFilterOption) => void -} - -export const MapFilterProfileTypeCardList = (props: IProps) => { - const elementRef = useRef(null) - const [disableLeftArrow, setDisableLeftArrow] = useState(true) - const [disableRightArrow, setDisableRightArrow] = useState(false) - const { activeFilters, availableFilters, onFilterChange } = props - - const typeFilters = availableFilters.filter( - ({ filterType }) => filterType === 'profileType', - ) - - const handleHorizantalScroll = (step: number) => { - const distance = 121 - const element = elementRef.current - const speed = 10 - let scrollAmount = 0 - - const slideTimer = setInterval(() => { - if (element) { - element.scrollLeft += step - scrollAmount += Math.abs(step) - if (scrollAmount >= distance) { - clearInterval(slideTimer) - } - const { scrollLeft, scrollWidth, clientWidth } = element - switch (scrollLeft + clientWidth) { - case clientWidth: - setDisableLeftArrow(true) - // scrollWidth && setDisableRightArrow(true) - break - case scrollWidth: - setDisableRightArrow(true) - break - default: - setDisableLeftArrow(false) - setDisableRightArrow(false) - break - } - } - }, speed) - } - - useEffect(() => { - handleHorizantalScroll(0) - }, []) - - if (!availableFilters || availableFilters.length < 2) { - return null - } - - return ( - - - {typeFilters.map((typeFilter, index) => { - const active = activeFilters.find( - (filter) => filter.label === typeFilter.label, - ) - return ( - onFilterChange(typeFilter)} - extrastyles={{ - backgroundColor: 'offWhite', - padding: 1, - textAlign: 'center', - width: '130px', - minWidth: '130px', - height: '75px', - flexDirection: 'column', - ...(active - ? { - borderColor: 'green', - ':hover': { borderColor: 'green' }, - } - : { - borderColor: 'offWhite', - ':hover': { borderColor: 'offWhite' }, - }), - }} - > - -
- - {typeFilter.label} - -
- ) - })} -
- {typeFilters.length > 3 && ( - - handleHorizantalScroll(-10)} - icon="chevron-left" - disabled={disableLeftArrow} - sx={{ height: '28px', borderColor: 'grey' }} - /> - handleHorizantalScroll(10)} - icon="chevron-right" - disabled={disableRightArrow} - sx={{ height: '28px', borderColor: 'grey' }} - /> - - )} -
- ) -} diff --git a/packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.client.tsx b/packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.client.tsx new file mode 100644 index 0000000000..ffb1a3ee7e --- /dev/null +++ b/packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.client.tsx @@ -0,0 +1,72 @@ +import { Text } from 'theme-ui' + +import { CardButton } from '../CardButton/CardButton' +import { MemberBadge } from '../MemberBadge/MemberBadge' +import { VerticalList } from '../VerticalList/VerticalList' + +import type { + MapFilterOption, + MapFilterOptionsList, + ProfileTypeName, +} from 'oa-shared' + +export interface IProps { + activeFilters: MapFilterOptionsList + availableFilters: MapFilterOptionsList + onFilterChange: (filter: MapFilterOption) => void +} + +export const MemberTypeVerticalList = (props: IProps) => { + const { activeFilters, availableFilters, onFilterChange } = props + + const items = availableFilters?.filter( + ({ filterType }) => filterType === 'profileType', + ) + + if (!items || !items.length || items.length < 2) { + return null + } + + return ( + + {items.map((item, index) => { + const active = activeFilters.find( + (filter) => item.label === filter.label, + ) + return ( + onFilterChange(item)} + extrastyles={{ + background: 'none', + textAlign: 'center', + width: '130px', + minWidth: '130px', + marginX: 1, + height: '75px', + flexDirection: 'column', + ...(active + ? { + borderColor: 'green', + ':hover': { borderColor: 'green' }, + } + : { + borderColor: 'background', + ':hover': { borderColor: 'background' }, + }), + }} + > + +
+ + {item.label} + +
+ ) + })} +
+ ) +} diff --git a/packages/components/src/MapFilterProfileTypeCardList/MapFilterProfileTypeCardList.stories.tsx b/packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.stories.tsx similarity index 50% rename from packages/components/src/MapFilterProfileTypeCardList/MapFilterProfileTypeCardList.stories.tsx rename to packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.stories.tsx index e1996a9410..e6fadb5d74 100644 --- a/packages/components/src/MapFilterProfileTypeCardList/MapFilterProfileTypeCardList.stories.tsx +++ b/packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.stories.tsx @@ -1,43 +1,39 @@ import { useState } from 'react' -import { MapFilterProfileTypeCardList } from './MapFilterProfileTypeCardList' +import { MemberTypeVerticalList } from './MemberTypeVerticalList.client' import type { Meta, StoryFn } from '@storybook/react' -import type { - MapFilterOption, - MapFilterOptionsList, - ProfileTypeName, -} from 'oa-shared' +import type { MapFilterOption, MapFilterOptionsList } from 'oa-shared' export default { - title: 'Map/FilterList', - component: MapFilterProfileTypeCardList, -} as Meta + title: 'Map/MemberTypeVerticalList', + component: MemberTypeVerticalList, +} as Meta -const availableFilters = [ +export const availableFilters: MapFilterOption[] = [ { label: 'Workspace', - _id: 'workspace' as ProfileTypeName, - filterType: 'ProfileType', + _id: 'workspace', + filterType: 'profileType', }, { label: 'Machine Builder', - _id: 'machine-builder' as ProfileTypeName, - filterType: 'ProfileType', + _id: 'machine-builder', + filterType: 'profileType', }, { label: 'Collection Point', - _id: 'collection-point' as ProfileTypeName, - filterType: 'ProfileType', + _id: 'collection-point', + filterType: 'profileType', }, { label: 'Want to get started', - _id: 'member' as ProfileTypeName, - filterType: 'ProfileType', + _id: 'member', + filterType: 'profileType', }, ] -export const Basic: StoryFn = () => { +export const Basic: StoryFn = () => { const [activeFilters, setActiveFilters] = useState([]) const onFilterChange = (option: MapFilterOption) => { @@ -54,7 +50,7 @@ export const Basic: StoryFn = () => { return (
- = () => { ) } -export const OnlyOne: StoryFn = () => { +export const OnlyOne: StoryFn = () => { const [activeFilters, setActiveFilters] = useState([]) const onFilterChange = (option: MapFilterOption) => { @@ -80,7 +76,7 @@ export const OnlyOne: StoryFn = () => { return (
- = () => {
) } - -export const OnlyTwo: StoryFn = () => { - const [activeFilters, setActiveFilters] = useState([]) - - const onFilterChange = (option: MapFilterOption) => { - const isFilterPresent = !!availableFilters.find( - (pinFilter) => pinFilter._id == option._id, - ) - if (isFilterPresent) { - return setActiveFilters((filter) => - filter.filter((existingOption) => existingOption !== option), - ) - } - return setActiveFilters((existingOptions) => [...existingOptions, option]) - } - - return ( -
- - (No buttons rendered) -
- ) -} diff --git a/packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.test.tsx b/packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.test.tsx new file mode 100644 index 0000000000..a478b490b1 --- /dev/null +++ b/packages/components/src/MemberTypeVerticalList/MemberTypeVerticalList.test.tsx @@ -0,0 +1,38 @@ +import '@testing-library/jest-dom/vitest' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { render } from '../test/utils' +import { + availableFilters, + Basic, + OnlyOne, +} from './MemberTypeVerticalList.stories' + +import type { IProps } from './MemberTypeVerticalList.client' + +describe('MemberTypeVerticalList', () => { + // https://stackoverflow.com/a/62148101 + beforeEach(() => { + const mockIntersectionObserver = vi.fn() + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }) + window.IntersectionObserver = mockIntersectionObserver + }) + + it('renders each member type given', async () => { + const { findAllByTestId } = render() + + expect(await findAllByTestId('MemberTypeVerticalList-Item')).toHaveLength( + availableFilters.length, + ) + }) + + it("doesn't render items only one exists", () => { + const { getByTestId } = render() + expect(() => getByTestId('MemberTypeVerticalList-Item')).toThrow() + }) +}) diff --git a/packages/components/src/VerticalList/Arrows.tsx b/packages/components/src/VerticalList/Arrows.tsx new file mode 100644 index 0000000000..1a677d6d02 --- /dev/null +++ b/packages/components/src/VerticalList/Arrows.tsx @@ -0,0 +1,52 @@ +import { useContext } from 'react' +import { VisibilityContext } from 'react-horizontal-scrolling-menu' +import { Flex } from 'theme-ui' + +import { Icon } from '../Icon/Icon' + +import type { publicApiType } from 'react-horizontal-scrolling-menu' +import type { availableGlyphs } from '../Icon/types' + +interface IProps { + disabled: boolean + glyph: availableGlyphs + onClick: () => void +} + +const Arrow = ({ disabled, glyph, onClick }: IProps) => { + return ( + + {disabled ? null : ( + + )} + + ) +} + +export const LeftArrow = () => { + const visibility = useContext(VisibilityContext) + const disabled = visibility.useLeftArrowVisible() + const onClick = () => + visibility.scrollToItem(visibility.getPrevElement(), 'smooth', 'start') + + return +} + +export const RightArrow = () => { + const visibility = useContext(VisibilityContext) + const disabled = visibility.useRightArrowVisible() + const onClick = () => + visibility.scrollToItem(visibility.getNextElement(), 'smooth', 'end') + + return +} diff --git a/packages/components/src/VerticalList/VerticalList.stories.tsx b/packages/components/src/VerticalList/VerticalList.stories.tsx new file mode 100644 index 0000000000..696afb29ab --- /dev/null +++ b/packages/components/src/VerticalList/VerticalList.stories.tsx @@ -0,0 +1,32 @@ +import { Box } from 'theme-ui' + +import { VerticalList } from './VerticalList' + +import type { Meta, StoryFn } from '@storybook/react' + +export default { + title: 'Components/VerticalList', + component: VerticalList, +} as Meta + +export const Default: StoryFn = () => { + const items = ['hello', 'world!', '...', 'Yeah,', 'you!'] + return ( +
+ + {items.map((item, index) => ( + + {item} + + ))} + +
+ ) +} diff --git a/packages/components/src/VerticalList/VerticalList.tsx b/packages/components/src/VerticalList/VerticalList.tsx new file mode 100644 index 0000000000..2a35354f25 --- /dev/null +++ b/packages/components/src/VerticalList/VerticalList.tsx @@ -0,0 +1,63 @@ +// As much as possible taken directly from https://asmyshlyaev177.github.io/react-horizontal-scrolling-menu/?path=/story/examples-simple--simple +// Generally only edited for readability + +import React from 'react' +import { ScrollMenu } from 'react-horizontal-scrolling-menu' +import styled from '@emotion/styled' +import { Box } from 'theme-ui' + +import { LeftArrow, RightArrow } from './Arrows' + +import type { publicApiType } from 'react-horizontal-scrolling-menu' + +import 'react-horizontal-scrolling-menu/dist/styles.css' + +export interface IProps { + children: React.ReactElement[] + dataCy?: string +} + +export const VerticalList = ({ children, dataCy }: IProps) => { + return ( + + + + {children} + + + + ) +} + +const NoScrollbar = styled('div')({ + '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': { + display: 'none', + }, + + '& .react-horizontal-scrolling-menu--scroll-container': { + scrollbarWidth: 'none', + '-ms-overflow-style': 'none', + }, +}) + +function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void { + // NOTE: no good standard way to distinguish touchpad scrolling gestures + // but can assume that gesture will affect X axis, mouse scroll only Y axis + // of if deltaY too small probably is it touchpad + const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15 + + if (isThouchpad) { + ev.stopPropagation() + return + } + + if (ev.deltaY < 0) { + apiObj.scrollNext() + } else { + apiObj.scrollPrev() + } +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 785be35c17..006ed50445 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -44,7 +44,7 @@ export { LinkifyText } from './LinkifyText/LinkifyText' export { Loader } from './Loader/Loader' export { Map } from './Map/Map.client' export { MapFilterList } from './MapFilterList/MapFilterList' -export { MapFilterProfileTypeCardList } from './MapFilterProfileTypeCardList/MapFilterProfileTypeCardList' +export { MemberTypeVerticalList } from './MemberTypeVerticalList/MemberTypeVerticalList.client' export { MapMemberCard } from './MapMemberCard/MapMemberCard' export { MapWithPin } from './MapWithPin/MapWithPin.client' export { MemberBadge } from './MemberBadge/MemberBadge' @@ -71,6 +71,7 @@ export { UsefulStatsButton } from './UsefulStatsButton/UsefulStatsButton' export { UserEngagementWrapper } from './UserEngagementWrapper/UserEngagementWrapper' export { Username } from './Username/Username' export { UserStatistics } from './UserStatistics/UserStatistics' +export { VerticalList } from './VerticalList/VerticalList' export { VideoPlayer } from './VideoPlayer/VideoPlayer' export { CommentAvatar } from './CommentAvatar/CommentAvatar' export type { availableGlyphs } from './Icon/types' diff --git a/packages/cypress/src/integration/map.spec.ts b/packages/cypress/src/integration/map.spec.ts index cd9ae8487d..1d0b6fca40 100644 --- a/packages/cypress/src/integration/map.spec.ts +++ b/packages/cypress/src/integration/map.spec.ts @@ -37,13 +37,17 @@ describe('[Map]', () => { cy.get('[data-cy="list-results"]').contains(/\d+ results in view/) cy.step('Map filters can be used') - cy.get('[data-cy=MapFilterProfileTypeCardList]') + cy.get('[data-cy=MemberTypeVerticalList]') .first() - .children() - .should('have.length', profileTypesCount) - cy.get('[data-cy=MapListFilter]').first().click() + .within(() => { + cy.get('[data-cy=MemberTypeVerticalList-Item]').should( + 'have.length', + profileTypesCount, + ) + }) + cy.get('[data-cy=MemberTypeVerticalList-Item]').first().click() - cy.get('[data-cy=MapListFilter-active]').first().click() + cy.get('[data-cy=MemberTypeVerticalList-Item-active]').first().click() cy.get('[data-cy="list-results"]').contains(/\d+ results in view/) cy.step('Clusters show up') diff --git a/packages/themes/src/common/commonStyles.ts b/packages/themes/src/common/commonStyles.ts index 4660801912..7b2fa38a48 100644 --- a/packages/themes/src/common/commonStyles.ts +++ b/packages/themes/src/common/commonStyles.ts @@ -26,7 +26,7 @@ export const commonStyles = { error: 'red', background: '#f4f6f7', silver: '#c0c0c0', - softgrey: '#c2d4e4', + softgrey: '#c2c2c2', lightgrey: '#ababac', darkGrey: '#686868', subscribed: 'orange', diff --git a/src/pages/Maps/Content/MapView/MapWithListHeader.tsx b/src/pages/Maps/Content/MapView/MapWithListHeader.tsx index a7f1aac066..35aa8c4cfb 100644 --- a/src/pages/Maps/Content/MapView/MapWithListHeader.tsx +++ b/src/pages/Maps/Content/MapView/MapWithListHeader.tsx @@ -3,7 +3,7 @@ import { Button, CardList, MapFilterList, - MapFilterProfileTypeCardList, + MemberTypeVerticalList, Modal, OsmGeocoding, } from 'oa-components' @@ -88,7 +88,9 @@ export const MapWithListHeader = (props: IProps) => { {pins && `${pins.length} members (and counting...)`} - + { if (lat && lon) { @@ -111,7 +113,7 @@ export const MapWithListHeader = (props: IProps) => { - =16.8" + react-dom: ">=16.8" + checksum: 10/a58758726a51829cc8fb2786ac5c141fccff94d326ca07d62b98ae3ea56c134ac2ecaade7b4acb60c9f95774a34d68bb5a084059de3fa088a461ac656cd80591 + languageName: node + linkType: hard + "react-icons@npm:^5.3.0": version: 5.3.0 resolution: "react-icons@npm:5.3.0" @@ -29283,6 +29303,15 @@ __metadata: languageName: node linkType: hard +"scroll-into-view-if-needed@npm:^3.1.0": + version: 3.1.0 + resolution: "scroll-into-view-if-needed@npm:3.1.0" + dependencies: + compute-scroll-into-view: "npm:^3.0.2" + checksum: 10/1ea10d84b79db592493ed22563e307a4eaf858527b4c345e70cc26b9c51383636edda31a09d383541fafb5b50a94e59384d85351662cb7d6e5d70805c0d18798 + languageName: node + linkType: hard + "section-matter@npm:^1.0.0": version: 1.0.0 resolution: "section-matter@npm:1.0.0" @@ -29729,6 +29758,15 @@ __metadata: languageName: node linkType: hard +"smooth-scroll-into-view-if-needed@npm:^2.0.2": + version: 2.0.2 + resolution: "smooth-scroll-into-view-if-needed@npm:2.0.2" + dependencies: + scroll-into-view-if-needed: "npm:^3.1.0" + checksum: 10/2e9c26987a4af19fbc7a3c1ebb8cbfe0d5412e7ab7fa0802a374a5ec213df14cd4fa7c8fc91d81e49015617460b4562f43c0a66ed02314e834456b69687030df + languageName: node + linkType: hard + "snake-case@npm:^3.0.4": version: 3.0.4 resolution: "snake-case@npm:3.0.4" From 7b94cacc453cd09ef261e776d5476b781ef98421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Tue, 17 Dec 2024 22:58:23 +0000 Subject: [PATCH 09/62] fix: question comment counts filter out deleted comments (#4046) --- src/routes/api.comments.count.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/routes/api.comments.count.ts b/src/routes/api.comments.count.ts index 69969fa62b..7509849864 100644 --- a/src/routes/api.comments.count.ts +++ b/src/routes/api.comments.count.ts @@ -1,15 +1,16 @@ -import { json, type LoaderFunctionArgs } from '@remix-run/node' import { createSupabaseServerClient } from 'src/repository/supabase.server' +import type { LoaderFunctionArgs } from '@remix-run/node' + export async function action({ request }: LoaderFunctionArgs) { if (request.method !== 'POST') { - return json({}, { status: 405, statusText: 'method not allowed' }) + return Response.json({}, { status: 405, statusText: 'method not allowed' }) } const data = await request.json() if (!data.ids) { - return json({}, { status: 400, statusText: 'ids is required' }) + return Response.json({}, { status: 400, statusText: 'ids is required' }) } const { client, headers } = createSupabaseServerClient(request) @@ -21,13 +22,14 @@ export async function action({ request }: LoaderFunctionArgs) { .from('comments') .select('source_id_legacy') .in('source_id_legacy', data.ids) + .or('deleted.is.null,deleted.neq.true') const commentCounts = commentsResult.data?.reduce((acc, comment) => { acc[comment.source_id_legacy] = (acc[comment.source_id_legacy] || 0) + 1 return acc }, {}) - return json(commentCounts, { + return Response.json(commentCounts, { headers, status: commentsResult.error ? 500 : 201, }) From 7ecf608b541140bdf547cc7d9fecb342f546bb58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Wed, 18 Dec 2024 00:02:00 +0000 Subject: [PATCH 10/62] feat: bring comment ui improvements to comments v2 components (#4047) --- .../common/CommentsV2/CreateCommentV2.css | 44 +++++++ .../common/CommentsV2/CreateCommentV2.tsx | 120 +++++++++++------- 2 files changed, 120 insertions(+), 44 deletions(-) create mode 100644 src/pages/common/CommentsV2/CreateCommentV2.css diff --git a/src/pages/common/CommentsV2/CreateCommentV2.css b/src/pages/common/CommentsV2/CreateCommentV2.css new file mode 100644 index 0000000000..e7a6d90194 --- /dev/null +++ b/src/pages/common/CommentsV2/CreateCommentV2.css @@ -0,0 +1,44 @@ +/* https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ */ +.grow-wrap { + /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ + display: grid; +} +.grow-wrap::after { + /* Note the weird space! Needed to preventy jumpy behavior */ + content: attr(data-replicated-value) ' '; + + /* This is how textarea text behaves */ + white-space: pre-wrap; + + /* Hidden from view, clicks, and screen readers */ + visibility: hidden; +} +.grow-wrap > textarea { + /* You could leave this, but after a user resizes, then it ruins the auto sizing */ + resize: none; + + /* Firefox shows scrollbar on growth, you can hide like this. */ + overflow: hidden; +} +.grow-wrap > textarea, +.grow-wrap::after { + /* Identical styling required!! */ + background: none; + resize: none; + padding: 15px; + word-wrap: anywhere; + border-color: transparent; + font-size: 12px; + + /* Place on top of each other */ + grid-area: 1 / 1 / 2 / 2; +} +.grow-wrap > textarea:focus { + border-color: transparent; +} + +.grow-wrap.value-set > textarea, +.grow-wrap.value-set::after { + /* Identical styling required!! */ + padding-bottom: 27px !important; +} diff --git a/src/pages/common/CommentsV2/CreateCommentV2.tsx b/src/pages/common/CommentsV2/CreateCommentV2.tsx index bf3b269e82..b4fcf13e84 100644 --- a/src/pages/common/CommentsV2/CreateCommentV2.tsx +++ b/src/pages/common/CommentsV2/CreateCommentV2.tsx @@ -1,10 +1,14 @@ import { useState } from 'react' import { Link } from '@remix-run/react' import { observer } from 'mobx-react' -import { MemberBadge } from 'oa-components' +import { Button, MemberBadge } from 'oa-components' import { useCommonStores } from 'src/common/hooks/useCommonStores' import { MAX_COMMENT_LENGTH } from 'src/constants' -import { Box, Button, Flex, Text, Textarea } from 'theme-ui' +import { Box, Flex, Text, Textarea } from 'theme-ui' + +import type { ChangeEvent } from 'react' + +import './CreateCommentV2.css' export interface Props { onSubmit: (value: string) => void @@ -22,6 +26,15 @@ export const CreateCommentV2 = observer((props: Props) => { const [comment, setComment] = useState('') const { userStore } = useCommonStores().stores const isLoggedIn = !!userStore.activeUser + const [isFocused, setIsFocused] = useState(false) + + const commentIsActive = comment.length > 0 || isFocused + + const onChange = (event: ChangeEvent) => { + ;(event.target.parentNode! as HTMLDivElement).dataset.replicatedValue = + event.target.value + setComment(event.target.value) + } return ( { padding: 3, }} > - - - + + + {!isLoggedIn ? ( ) : ( - <> -