fix(webapp): fix search + search e2e (#9376)

This commit is contained in:
Ulf Gebhardt 2026-03-13 20:10:38 +01:00 committed by GitHub
parent 237798b0f0
commit 83df85001d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 348 additions and 127 deletions

View File

@ -233,6 +233,12 @@ jobs:
timeout 120 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 5; done'
echo "Webapp is ready."
- name: Initialize database
run: docker compose -f docker-compose.yml -f docker-compose.test.yml exec -T backend yarn db:migrate init
- name: Migrate database
run: docker compose -f docker-compose.yml -f docker-compose.test.yml exec -T backend yarn db:migrate up
- name: Full stack tests | run tests
id: e2e-tests
run: yarn run cypress:run --spec "cypress/e2e/${{ matrix.feature }}"

View File

@ -230,6 +230,55 @@ Factory.define('post')
return post
})
Factory.define('group')
.option('ownerId', null)
.option('owner', ['ownerId'], (ownerId) => {
if (ownerId) return neode.find('User', ownerId)
return Factory.build('user')
})
.attrs({
id: uuid,
name: faker.company.name,
about: faker.lorem.sentence,
description: faker.lorem.paragraphs,
groupType: 'public',
actionRadius: 'regional',
deleted: false,
disabled: false,
})
.attr('slug', ['slug', 'name'], (slug, name) => {
return slug || slugify(name, { lower: true })
})
.attr(
'descriptionExcerpt',
['descriptionExcerpt', 'description'],
(descriptionExcerpt, description) => {
return descriptionExcerpt || description
},
)
.after(async (buildObject, options) => {
const [group, owner] = await Promise.all([neode.create('Group', buildObject), options.owner])
const session = driver.session()
try {
await session.writeTransaction((txc) =>
txc.run(
`
MATCH (owner:User {id: $ownerId}), (group:Group {id: $groupId})
MERGE (owner)-[:CREATED]->(group)
MERGE (owner)-[membership:MEMBER_OF]->(group)
SET membership.createdAt = toString(datetime()),
membership.updatedAt = toString(datetime()),
membership.role = 'owner'
`,
{ ownerId: owner.get('id'), groupId: buildObject.id },
),
)
} finally {
await session.close()
}
return group
})
Factory.define('comment')
.option('postId', null)
.option('post', ['postId'], (postId) => {

View File

@ -19,7 +19,7 @@ async function setupNodeEvents(on, config) {
webpackPreprocessor({
webpackOptions: {
mode: 'development',
devtool: 'source-map',
devtool: 'eval-source-map',
resolve: {
extensions: ['.js', '.json'],
fallback: {

View File

@ -0,0 +1,67 @@
Feature: Search Results Page
As a user
I would like to see search results for posts, users, groups, and hashtags
In order to find specific content on the platform
Background:
Given the following "users" are in the database:
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
| narrator | narrator@example.org | 1234 | narrator | Nathan Narrator | 0.0.4 |
| jenny | jenny@example.org | 1234 | jenny-id | Jenny Rostock | 0.0.4 |
| finduser | finduser@example.org | 1234 | finduser | Find me user | 0.0.4 |
And the following "tags" are in the database:
| id |
| find-me-tag |
And the following "posts" are in the database:
| id | title | content | authorId | tagIds |
| p1 | Find me post one | This is the first result | narrator | find-me-tag |
| p2 | Find me post two | This is the second result | narrator | find-me-tag |
And the following "groups" are in the database:
| id | name | slug | about | description | ownerId |
| group-1 | Discoverable club | discoverable-club | A group to be found | This is a detailed description for the test group so it has enough characters to pass the minimum length of one hundred | narrator |
And I am logged in as "narrator"
Scenario: Post results are displayed
When I navigate to page "/search/search-results?search=Find"
Then I should see the "Post" tab as active
And I should see 2 post results
Scenario: User results are displayed
When I navigate to page "/search/search-results?search=Jenny"
Then I should see the "User" tab as active
And I should see 1 user results
Scenario: Group results are displayed
When I navigate to page "/search/search-results?search=Discoverable club"
Then I should see the "Group" tab as active
And I should see 1 group results
Scenario: Hashtag results are displayed
When I navigate to page "/search/search-results?search=find-me-tag"
Then I should see the "Hashtag" tab as active
And I should see 1 hashtag results
Scenario: Switching tabs hides previous results
When I navigate to page "/search/search-results?search=Find"
And I click on the "User" tab
Then I should not see post results
Scenario: Pagination for many posts
Given the following "posts" are in the database:
| id | title | content | authorId |
| p3 | Find me post 3 | Some content 3 | narrator |
| p4 | Find me post 4 | Some content 4 | narrator |
| p5 | Find me post 5 | Some content 5 | narrator |
| p6 | Find me post 6 | Some content 6 | narrator |
| p7 | Find me post 7 | Some content 7 | narrator |
| p8 | Find me post 8 | Some content 8 | narrator |
| p9 | Find me post 9 | Some content 9 | narrator |
| p10 | Find me post 10 | Some content 10 | narrator |
| p11 | Find me post 11 | Some content 11 | narrator |
| p12 | Find me post 12 | Some content 12 | narrator |
| p13 | Find me post 13 | Some content 13 | narrator |
When I navigate to page "/search/search-results?search=Find"
Then I should see pagination buttons
And I should see page "Page 1 / 2"
When I click on the next page button
Then I should see page "Page 2 / 2"

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I click on the next page button', () => {
cy.get('[data-test="next-button"]').first().click()
})

View File

@ -0,0 +1,6 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I click on the {string} tab', (type) => {
cy.get(`[data-test="${type}-tab"]`).should('not.have.class', '--disabled')
cy.get(`[data-test="${type}-tab-click"]`).click()
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I should not see post results', () => {
cy.get('.post-teaser').should('not.exist')
})

View File

@ -0,0 +1,7 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I should see page {string}', (page) => {
cy.get('.pagination-pageCount').first().invoke('text').then((text) => {
expect(text.replace(/\s+/g, ' ').trim()).to.contain(page)
})
})

View File

@ -0,0 +1,6 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I should see pagination buttons', () => {
cy.get('[data-test="previous-button"]').should('exist')
cy.get('[data-test="next-button"]').should('exist')
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I should see the {string} tab as active', (type) => {
cy.get(`[data-test="${type}-tab"]`).should('have.class', '--active')
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I should see {int} group results', (count) => {
cy.get('.group-teaser').should('have.length', count)
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I should see {int} hashtag results', (count) => {
cy.get('.hc-hashtag').should('have.length', count)
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I should see {int} post results', (count) => {
cy.get('.post-teaser').should('have.length', count)
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I should see {int} user results', (count) => {
cy.get('.user-teaser').should('have.length', count)
})

View File

@ -32,6 +32,15 @@ defineStep('the following {string} are in the database:', (table,data) => {
cy.factory().build('tag', entry, entry)
})
break
case 'groups':
data.hashes().forEach( entry => {
cy.factory().build('group', {
...entry,
deleted: Boolean(entry.deleted),
disabled: Boolean(entry.disabled),
}, entry)
})
break
case 'donations':
data.hashes().forEach( entry => {
cy.factory().build('donations', entry, entry)

View File

@ -1,121 +1,110 @@
<template>
<div id="search-results" class="search-results">
<div class="search-results__content">
<masonry-grid>
<!-- search text -->
<div class="grid-total-search-results" style="grid-row-end: span 1; grid-column: 1 / -1">
<div class="ds-mb-xxx-small ds-mt-xxx-small ds-space-centered">
<p class="ds-text total-search-results">
{{ $t('search.for') }}
<strong>{{ '"' + (search || '') + '"' }}</strong>
</p>
<!-- search text -->
<div class="grid-total-search-results">
<div class="ds-mb-xxx-small ds-mt-xxx-small ds-space-centered">
<p class="ds-text total-search-results">
{{ $t('search.for') }}
<strong>{{ '"' + (search || '') + '"' }}</strong>
</p>
</div>
</div>
<!-- tabs -->
<tab-navigation :tabs="tabOptions" :activeTab="activeTab" @switch-tab="switchTab" />
<!-- search results -->
<div v-if="!(!activeResourceCount || searchCount === 0)" class="search-results-body">
<!-- pagination buttons -->
<div v-if="activeResourceCount > pageSize" class="search-results-pagination">
<pagination-buttons
:hasNext="hasNext"
:showPageCounter="true"
:hasPrevious="hasPrevious"
:activePage="activePage"
:activeResourceCount="activeResourceCount"
:key="'Top'"
:pageSize="pageSize"
@back="previousResults"
@next="nextResults"
/>
</div>
<!-- posts -->
<masonry-grid v-if="activeTab === 'Post'">
<masonry-grid-item
v-for="post in activeResources"
:key="post.id"
:imageAspectRatio="post.image && post.image.aspectRatio"
>
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
:showGroupPinned="true"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
@pinGroupPost="pinGroupPost(post, refetchPostList)"
@unpinGroupPost="unpinGroupPost(post, refetchPostList)"
@pushPost="pushPost(post, refetchPostList)"
@unpushPost="unpushPost(post, refetchPostList)"
@toggleObservePost="
(postId, value) => toggleObservePost(postId, value, refetchPostList)
"
/>
</masonry-grid-item>
</masonry-grid>
<!-- users -->
<div v-if="activeTab === 'User'" class="search-results-list">
<div v-for="user in activeResources" :key="user.id" class="search-results-list__item">
<os-card>
<user-teaser :user="user" />
</os-card>
</div>
</div>
<!-- groups -->
<div v-if="activeTab === 'Group'" class="search-results-list">
<div v-for="group in activeResources" :key="group.id" class="search-results-list__item">
<group-teaser :group="{ ...group, name: group.groupName }" />
</div>
</div>
<!-- hashtags -->
<div v-if="activeTab === 'Hashtag'" class="search-results-list">
<div
v-for="hashtag in activeResources"
:key="hashtag.id"
class="search-results-list__item"
>
<os-card>
<hc-hashtag :id="hashtag.id" />
</os-card>
</div>
</div>
<!-- tabs -->
<tab-navigation :tabs="tabOptions" :activeTab="activeTab" @switch-tab="switchTab" />
<!-- search results -->
<template v-if="!(!activeResourceCount || searchCount === 0)">
<!-- pagination buttons -->
<div
v-if="activeResourceCount > pageSize"
style="grid-row-end: span 2; grid-column: 1 / -1"
>
<div class="ds-mb-large ds-space-centered">
<pagination-buttons
:hasNext="hasNext"
:showPageCounter="true"
:hasPrevious="hasPrevious"
:activePage="activePage"
:activeResourceCount="activeResourceCount"
:key="'Top'"
:pageSize="pageSize"
@back="previousResults"
@next="nextResults"
/>
</div>
</div>
<!-- posts -->
<template v-if="activeTab === 'Post'">
<masonry-grid-item
v-for="post in activeResources"
:key="post.id"
:imageAspectRatio="post.image && post.image.aspectRatio"
>
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
:showGroupPinned="true"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
@pinGroupPost="pinGroupPost(post, refetchPostList)"
@unpinGroupPost="unpinGroupPost(post, refetchPostList)"
@pushPost="pushPost(post, refetchPostList)"
@unpushPost="unpushPost(post, refetchPostList)"
@toggleObservePost="
(postId, value) => toggleObservePost(postId, value, refetchPostList)
"
/>
</masonry-grid-item>
</template>
<!-- users -->
<template v-if="activeTab === 'User'">
<div v-for="user in activeResources" :key="user.id" style="grid-row-end: span 2">
<os-card>
<user-teaser :user="user" />
</os-card>
</div>
</template>
<!-- groups -->
<template v-if="activeTab === 'Group'">
<div v-for="group in activeResources" :key="group.id" style="grid-row-end: span 2">
<os-card class="group-teaser-card-wrapper">
<group-teaser :group="{ ...group, name: group.groupName }" />
</os-card>
</div>
</template>
<!-- hashtags -->
<template v-if="activeTab === 'Hashtag'">
<div v-for="hashtag in activeResources" :key="hashtag.id" style="grid-row-end: span 2">
<os-card>
<hc-hashtag :id="hashtag.id" />
</os-card>
</div>
</template>
<!-- pagination buttons -->
<div
v-if="activeResourceCount > pageSize"
style="grid-row-end: span 2; grid-column: 1 / -1"
>
<div class="ds-mb-large ds-space-centered">
<pagination-buttons
:hasNext="hasNext"
:hasPrevious="hasPrevious"
:activePage="activePage"
:showPageCounter="true"
:activeResourceCount="activeResourceCount"
:key="'Bottom'"
:pageSize="pageSize"
:srollTo="'#search-results'"
@back="previousResults"
@next="nextResults"
/>
</div>
</div>
</template>
<!-- no results -->
<div v-else style="grid-row-end: span 7; grid-column: 1 / -1">
<div class="ds-mb-large ds-space-centered">
<hc-empty icon="tasks" :message="$t('search.no-results', { search })" />
</div>
<!-- pagination buttons -->
<div v-if="activeResourceCount > pageSize" class="search-results-pagination">
<pagination-buttons
:hasNext="hasNext"
:hasPrevious="hasPrevious"
:activePage="activePage"
:showPageCounter="true"
:activeResourceCount="activeResourceCount"
:key="'Bottom'"
:pageSize="pageSize"
:srollTo="'#search-results'"
@back="previousResults"
@next="nextResults"
/>
</div>
</masonry-grid>
</div>
<!-- no results -->
<div v-else class="search-results-empty">
<div class="ds-space-centered">
<hc-empty icon="tasks" :message="$t('search.no-results', { search })" />
</div>
</div>
</div>
</div>
</template>
@ -474,4 +463,34 @@ export default {
padding: 0;
margin: 0;
}
.search-results-empty {
padding-top: $space-small;
@media (max-width: 810px) {
padding-top: $space-x-small;
}
}
.search-results-pagination {
padding-top: $space-small;
display: flex;
justify-content: center;
@media (max-width: 810px) {
padding-top: $space-x-small;
}
}
.search-results-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
gap: $space-small;
padding-top: $space-small;
@media (max-width: 810px) {
gap: $space-x-small;
padding-top: $space-x-small;
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="tab-navigation" :style="{ gridRowEnd: 'span ' + tabs.length, gridColumn: '1 / -1' }">
<div class="tab-navigation">
<os-card class="ds-tab-nav">
<ul class="Tabs">
<li

View File

@ -71,7 +71,7 @@ export const searchPosts = gql`
`
export const searchGroups = (i18n) => {
const lang = i18n ? i18n.locale().toUpperCase() : 'EN'
const lang = i18n ? i18n.locale() : 'en'
return gql`
${imageUrls}
@ -103,7 +103,7 @@ export const searchGroups = (i18n) => {
}
locationName
location {
name: name${lang}
name(lang: "${lang}")
}
myRole
}

View File

@ -59,6 +59,7 @@ describe('PostIndex', () => {
push: jest.fn(),
},
push: jest.fn(),
replace: jest.fn(),
},
$toast: {
success: jest.fn(),

View File

@ -1,5 +1,10 @@
<template>
<div>
<!-- hashtag filter -->
<div v-if="hashtag" class="hashtag-filter-bar">
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
</div>
<!-- feed top row: filter (left) + create post (right) -->
<div class="feed-top-row">
<div
@ -101,14 +106,11 @@
</div>
<div
v-if="hashtag || showDonations"
v-if="showDonations"
class="newsfeed-controls"
:class="{ 'newsfeed-controls--no-filter': !SHOW_CONTENT_FILTER_MASONRY_GRID }"
>
<div v-if="hashtag">
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
</div>
<div v-if="showDonations" class="top-info-bar donation-desktop-only">
<div class="top-info-bar donation-desktop-only">
<donation-info :goal="goal" :progress="progress" />
</div>
</div>
@ -116,7 +118,7 @@
<masonry-grid
:single-column="singleColumn"
:class="[
!hashtag && !showDonations ? 'grid-margin-top' : '',
!showDonations ? 'grid-margin-top' : '',
!isMobile && !singleColumn && posts.length <= 2 ? 'grid-column-helper' : '',
]"
>
@ -253,8 +255,11 @@ export default {
return this.$route.query && this.$route.query.categoryId ? this.$route.query.categoryId : null
},
},
watchQuery: ['hashtag'],
watch: {
'$route.query.hashtag'(value) {
this.hashtag = value || null
this.resetPostList()
},
postsFilter() {
this.resetPostList()
},
@ -317,8 +322,10 @@ export default {
this.prevScrollpos = currentScrollPos
},
clearSearch() {
this.$router.push({ path: '/' })
this.hashtag = null
const query = { ...this.$route.query }
delete query.hashtag
this.$router.replace({ query })
},
href(post) {
return this.$router.resolve({
@ -390,6 +397,11 @@ export default {
display: none;
}
.hashtag-filter-bar {
margin-top: -$space-x-small;
margin-bottom: $space-small;
}
.feed-top-row {
display: flex;
align-items: center;
@ -419,6 +431,10 @@ export default {
transition: top 0.3s ease !important;
}
.hashtag-filter-bar + .feed-top-row .post-add-button {
top: 146px !important;
}
.main-navigation:has(.hide-navbar) ~ .ds-container .post-add-button {
top: 20px !important;
}
@ -519,6 +535,10 @@ export default {
top: 67px !important;
}
.hashtag-filter-bar + .feed-top-row .post-add-button {
top: 125px !important;
}
.newsfeed-controls {
margin-top: 8px;
}

View File

@ -452,7 +452,6 @@ exports[`ProfileSlug given an authenticated user given another profile user and
>
<div
class="tab-navigation ds-mb-large"
style="grid-row-end: span 3; grid-column: 1 / -1;"
>
<div
class="ds-tab-nav os-card relative rounded-[5px] break-words bg-white shadow-[0px_12px_26px_-4px_rgba(0,0,0,0.1)] p-6 ds-tab-nav"
@ -1187,7 +1186,6 @@ exports[`ProfileSlug given an authenticated user given another profile user and
>
<div
class="tab-navigation ds-mb-large"
style="grid-row-end: span 3; grid-column: 1 / -1;"
>
<div
class="ds-tab-nav os-card relative rounded-[5px] break-words bg-white shadow-[0px_12px_26px_-4px_rgba(0,0,0,0.1)] p-6 ds-tab-nav"
@ -1743,7 +1741,6 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
>
<div
class="tab-navigation ds-mb-large"
style="grid-row-end: span 3; grid-column: 1 / -1;"
>
<div
class="ds-tab-nav os-card relative rounded-[5px] break-words bg-white shadow-[0px_12px_26px_-4px_rgba(0,0,0,0.1)] p-6 ds-tab-nav"
@ -2377,7 +2374,6 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
>
<div
class="tab-navigation ds-mb-large"
style="grid-row-end: span 3; grid-column: 1 / -1;"
>
<div
class="ds-tab-nav os-card relative rounded-[5px] break-words bg-white shadow-[0px_12px_26px_-4px_rgba(0,0,0,0.1)] p-6 ds-tab-nav"