Repository: TryGhost/Ghost (js)

Issue:
Cannot filter pages by tags
### Issue Summary

<img width="469" height="79" alt="Image" src="https://github.com/user-attachments/assets/a63016ee-4929-4a64-b136-d90bc2204d72" />

Attempting to filter pages by tags doesn't work.  Typing in the search box has no effect.



### Steps to Reproduce

Open /ghost > pages and attempt to filter by tag.


### Ghost Version

ghost pro

### Node.js Version

ghost pro

### How did you install Ghost?

ghost pro

### Database type

MySQL 5.7

### Browser & OS version

_No response_

### Relevant log / error output

```shell
On clicking the tag filter: 
chunk.383.09219fde42568dd42ed5.js:3669 Uncaught TypeError: Cannot read properties of undefined (reading 'perform')
```

### Code of Conduct

- [x] I agree to be friendly and polite to people in this repository


Patch:
diff --git a/ghost/admin/app/templates/pages.hbs b/ghost/admin/app/templates/pages.hbs
index 7dba8759701..edabc8d1c6e 100644
--- a/ghost/admin/app/templates/pages.hbs
+++ b/ghost/admin/app/templates/pages.hbs
@@ -16,6 +16,9 @@
                 @onAuthorChange={{this.changeAuthor}}
                 @selectedTag={{this.selectedTag}}
                 @availableTags={{this.availableTags}}
+                @loadInitialTags={{this.loadInitialTags}}
+                @loadMoreTagsTask={{this.loadMoreTagsTask}}
+                @searchTagsTask={{this.searchTagsTask}}
                 @onTagChange={{this.changeTag}}
                 @selectedOrder={{this.selectedOrder}}
                 @availableOrders={{this.availableOrders}}
diff --git a/ghost/admin/mirage/config/pages.js b/ghost/admin/mirage/config/pages.js
index 6faeb2b44af..4c6976f3363 100644
--- a/ghost/admin/mirage/config/pages.js
+++ b/ghost/admin/mirage/config/pages.js
@@ -1,8 +1,8 @@
 import moment from 'moment-timezone';
 import {Response} from 'miragejs';
 import {dasherize} from '@ember/string';
-import {extractFilterParam, paginateModelCollection} from '../utils';
-import {isBlank, isEmpty} from '@ember/utils';
+import {getPages} from './posts';
+import {isBlank} from '@ember/utils';
 
 // NOTE: mirage requires Model objects when saving relationships, however the
 // `attrs` on POST/PUT requests will contain POJOs for authors and tags so we
@@ -37,26 +37,7 @@ export default function mockPages(server) {
         return pages.create(attrs);
     });
 
-    server.get('/pages/', function ({pages}, {queryParams}) {
-        let {filter, page, limit} = queryParams;
-
-        page = +page || 1;
-        limit = +limit || 15;
-
-        let statusFilter = extractFilterParam('status', filter);
-
-        let collection = pages.all().filter((pageModel) => {
-            let matchesStatus = true;
-
-            if (!isEmpty(statusFilter)) {
-                matchesStatus = statusFilter.includes(pageModel.status);
-            }
-
-            return matchesStatus;
-        });
-
-        return paginateModelCollection('pages', collection, page, limit);
-    });
+    server.get('/pages/', getPages);
 
     server.get('/pages/:id/', function ({pages}, {params}) {
         let {id} = params;
diff --git a/ghost/admin/mirage/config/posts.js b/ghost/admin/mirage/config/posts.js
index e795400473d..678261ce2f5 100644
--- a/ghost/admin/mirage/config/posts.js
+++ b/ghost/admin/mirage/config/posts.js
@@ -23,7 +23,16 @@ function extractTags(postAttrs, tags) {
     });
 }
 
-export function getPosts({posts}, {queryParams}) {
+export function getPosts() {
+    return getPostsOrPages('posts', ...arguments);
+}
+
+export function getPages() {
+    return getPostsOrPages('pages', ...arguments);
+}
+
+export function getPostsOrPages(modelName, db, {queryParams}) {
+    const model = db[modelName];
     let {filter, page, limit} = queryParams;
 
     page = +page || 1;
@@ -34,7 +43,7 @@ export function getPosts({posts}, {queryParams}) {
     let visibilityFilter = extractFilterParam('visibility', filter);
     let tags = extractFilterParam('tag', filter);
 
-    let collection = posts.all().filter((post) => {
+    let collection = model.all().filter((post) => {
         let matchesStatus = true;
         let matchesAuthors = true;
         let matchesVisibility = true;
@@ -65,7 +74,7 @@ export function getPosts({posts}, {queryParams}) {
         }
     }
 
-    return paginateModelCollection('posts', collection, page, limit);
+    return paginateModelCollection(modelName, collection, page, limit);
 }
 
 export default function mockPosts(server) {
diff --git a/ghost/admin/mirage/models/page.js b/ghost/admin/mirage/models/page.js
index 6956b658b16..03923148dff 100644
--- a/ghost/admin/mirage/models/page.js
+++ b/ghost/admin/mirage/models/page.js
@@ -1,5 +1,7 @@
-import PostModel from './post';
+import {Model, hasMany} from 'miragejs';
 
-export default PostModel.extend({
-    postRevisions: null
+export default Model.extend({
+    tags: hasMany(),
+    authors: hasMany('user'),
+    tiers: hasMany()
 });


Test patch:
diff --git a/ghost/admin/tests/acceptance/content-test.js b/ghost/admin/tests/acceptance/content-test.js
index aadc6b511a7..c95e6dbdca6 100644
--- a/ghost/admin/tests/acceptance/content-test.js
+++ b/ghost/admin/tests/acceptance/content-test.js
@@ -329,6 +329,24 @@ describe('Acceptance: Posts / Pages', function () {
                     expect(lastRequest.queryParams.allFilter, '"posts" request filter param').to.have.string('tag:second');
                 });
 
+                it('can filter by tag with server-side search', async function () {
+                    this.server.createList('tag', 120);
+                    this.server.create('tag', {name: 'Z - Last', slug: 'last'});
+
+                    await visit('/posts');
+
+                    await selectSearch('[data-test-tag-select]', 'Last');
+
+                    let options = findAll('.ember-power-select-option');
+                    expect(options.length, 'options count').to.equal(1);
+                    expect(options[0].textContent.trim()).to.equal('Z - Last');
+
+                    await selectChoose('[data-test-tag-select]', 'Z - Last');
+
+                    let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
+                    expect(lastRequest.queryParams.allFilter, '"posts" request filter param').to.have.string('tag:last');
+                });
+
                 it('can open with a filtered tag', async function () {
                     const tag = this.server.create('tag', {name: 'B - Second', slug: 'second'});
                     this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post with Second tag', tags: [tag]});
@@ -936,7 +954,7 @@ describe('Acceptance: Posts / Pages', function () {
             it('shows visitor count column when webAnalyticsEnabled is enabled and trafficAnalytics feature is enabled', async function () {
                 // Enable webAnalyticsEnabled setting
                 this.server.db.settings.update({key: 'web_analytics_enabled'}, {value: 'true'});
-                
+
                 // Enable trafficAnalytics feature flag
                 this.server.db.settings.update({key: 'labs'}, {value: JSON.stringify({trafficAnalytics: true})});
 
@@ -945,7 +963,7 @@ describe('Acceptance: Posts / Pages', function () {
                 // When both settings are enabled, the visitor count column container should exist
                 // even if it shows "—" without actual data
                 expect(find('.gh-post-list-metrics-container'), 'metrics container').to.exist;
-                
+
                 // The page should load without errors when analytics are enabled
                 expect(currentURL(), 'current URL').to.equal('/posts');
             });
@@ -1007,7 +1025,7 @@ describe('Acceptance: Posts / Pages', function () {
                 // Email analytics should still be visible as they have their own conditions
                 // The metrics container should exist for email analytics even when other analytics are disabled
                 expect(find('.gh-post-list-metrics-container'), 'metrics container').to.exist;
-                
+
                 // The page should load without errors
                 expect(currentURL(), 'current URL').to.equal('/posts');
             });
@@ -1106,6 +1124,68 @@ describe('Acceptance: Posts / Pages', function () {
                 expect(findAll('[data-test-post-id]').length, 'scheduled count').to.equal(1);
                 expect(find('[data-test-post-id="4"]'), 'scheduled page').to.exist;
             });
+
+            it('can filter by tag', async function () {
+                this.server.create('tag', {name: 'B - Second', slug: 'second'});
+                this.server.create('tag', {name: 'Z - Last', slug: 'last'});
+                this.server.create('tag', {name: 'A - First', slug: 'first'});
+
+                await visit('/pages');
+                await clickTrigger('[data-test-tag-select]');
+
+                // defaults to "All tags"
+                let options = findAll('.ember-power-select-option');
+                expect(options.length, 'options count').to.equal(4); // 3 tags + "All tags", we populate the tags when opening the dropdown
+                expect(options[0].textContent.trim()).to.equal('All tags');
+
+                // search lazy-loads tags from the API, and sorts them alphabetically
+                await selectSearch('[data-test-tag-select]', 's');
+
+                options = findAll('.ember-power-select-option');
+                expect(options[0].textContent.trim()).to.equal('A - First');
+                expect(options[1].textContent.trim()).to.equal('B - Second');
+                expect(options[2].textContent.trim()).to.equal('Z - Last');
+
+                // select one
+                await selectChoose('[data-test-tag-select]', 'B - Second');
+                // affirm request
+                let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
+                expect(lastRequest.queryParams.allFilter, '"pages" request filter param').to.have.string('tag:second');
+            });
+
+            it('can filter by tag with server-side search', async function () {
+                this.server.createList('tag', 120);
+                this.server.create('tag', {name: 'Z - Last', slug: 'last'});
+
+                await visit('/pages');
+
+                await selectSearch('[data-test-tag-select]', 'Last');
+
+                let options = findAll('.ember-power-select-option');
+                expect(options.length, 'options count').to.equal(1);
+                expect(options[0].textContent.trim()).to.equal('Z - Last');
+
+                await selectChoose('[data-test-tag-select]', 'Z - Last');
+
+                let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
+                expect(lastRequest.queryParams.allFilter, '"pages" request filter param').to.have.string('tag:last');
+            });
+
+            it('can open with a filtered tag', async function () {
+                const tag = this.server.create('tag', {name: 'B - Second', slug: 'second'});
+                this.server.create('page', {authors: [admin], status: 'published', title: 'Published Page with Second tag', tags: [tag]});
+
+                await visit('/pages?tag=second');
+
+                // Pages list is filtered by tag
+                const pages = findAll('[data-test-post-id]');
+                expect(pages.length, 'all pages count').to.equal(1);
+                expect(pages[0].querySelector('.gh-content-entry-title').textContent, 'post title').to.contain('Published Page with Second tag');
+
+                // Filter shows selected tag
+                const filter = find('[data-test-tag-select]');
+                expect(filter.textContent.trim(), 'filter text').to.contain('B - Second');
+            });
         });
     });
 });