Skip to content

Commit ccf0833

Browse files
šŸ› Fixed missing server-side label search in recipient & segment selects (#26699)
refs https://linear.app/tryghost/issue/ONC-1529 - The paginated label loading refactor (#26655) missed adding server-side search to `gh-members-recipient-select` and `gh-members-segment-select` - Users with many labels had to manually scroll through all pages before search could find labels not yet loaded client-side - Added conditional server-side search (via `labelsManager.searchLabelsTask`) to both components, matching the pattern already used by `gh-member-label-input` - Tiers, statuses, and offers are still filtered client-side since they're always fully loaded - Fixed labels found via search result not being selectable in some cases --------- Co-authored-by: Steve Larson <9larsons@gmail.com>
1 parent bcd968a commit ccf0833

File tree

11 files changed

+578
-104
lines changed

11 files changed

+578
-104
lines changed

ā€Žghost/admin/app/components/gh-members-recipient-select.hbsā€Ž

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
</div>
5656
</div>
5757
{{/if}}
58-
{{#if this.specificOptions.length}}
58+
{{#if this.hasSpecificOptions}}
5959
<div class="gh-publish-send-to-option">
6060
<div class="for-checkbox {{if @disabled "disabled"}}">
6161
<label class="checkbox" for="send-email-to-specific">
@@ -81,20 +81,15 @@
8181
{{#if this.isSpecificChecked}}
8282
<div data-test-select="specific-members">
8383
<label class="gh-main-section-header small bn">Selection</label>
84-
<GhTokenInput
85-
@class="select-members select-members-recipient"
86-
@dropdownClass={{@dropdownClass}}
87-
@options={{this.specificOptions}}
88-
@selected={{this.selectedSpecificOptions}}
84+
<GhSegmentTokenInput
85+
@nonLabelOptions={{this.nonLabelOptions}}
86+
@selectedSegments={{this.selectedSpecificSegments}}
8987
@disabled={{@disabled}}
90-
@searchMessage="All labels selected"
91-
@optionsComponent={{component "power-select-options-with-scroll" lastReached=(perform this.loadMoreLabelsTask)}}
92-
@allowCreation={{false}}
9388
@renderInPlace={{this.renderInPlace}}
9489
@onChange={{this.selectSpecificOptions}}
95-
as |option|
96-
>
97-
{{option.name}}
98-
</GhTokenInput>
90+
@class="select-members select-members-recipient"
91+
@dropdownClass={{@dropdownClass}}
92+
@searchMessage="All labels selected"
93+
/>
9994
</div>
100-
{{/if}}
95+
{{/if}}

ā€Žghost/admin/app/components/gh-members-recipient-select.jsā€Ž

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Component from '@glimmer/component';
2-
import flattenGroupedOptions from 'ghost-admin/utils/flatten-grouped-options';
32
import {action} from '@ember/object';
43
import {isBlank} from '@ember/utils';
54
import {inject as service} from '@ember/service';
@@ -54,28 +53,16 @@ export default class GhMembersRecipientSelect extends Component {
5453
return this.forceSpecificChecked || this.specificFilters.size > 0;
5554
}
5655

57-
get specificOptions() {
58-
const options = [...this._tierOptions];
59-
const labels = this.labelsManager.labels;
60-
61-
if (labels.length > 0) {
62-
options.push({
63-
groupName: 'Labels',
64-
options: labels.map(label => ({
65-
name: label.name,
66-
segment: `label:${label.slug}`,
67-
count: label.count?.members,
68-
class: 'segment-label'
69-
}))
70-
});
71-
}
56+
get hasSpecificOptions() {
57+
return this._tierOptions.length > 0 || this.labelsManager.labels.length > 0;
58+
}
7259

73-
return options;
60+
get nonLabelOptions() {
61+
return this._tierOptions;
7462
}
7563

76-
get selectedSpecificOptions() {
77-
return flattenGroupedOptions(this.specificOptions)
78-
.filter(o => this.specificFilters.has(o.segment));
64+
get selectedSpecificSegments() {
65+
return Array.from(this.specificFilters);
7966
}
8067

8168
@action
@@ -155,15 +142,9 @@ export default class GhMembersRecipientSelect extends Component {
155142
this.args.onChange?.(newFilter);
156143
}
157144

158-
@task({drop: true})
159-
*loadMoreLabelsTask() {
160-
yield this.labelsManager.loadMoreTask.perform();
161-
}
162-
163145
@task
164146
*fetchSpecificOptionsTask() {
165-
// fetch first page of labels (labels are last so infinite scroll works)
166-
// TODO: add `include: 'count.members` to query once API is fixed
147+
// fetch first page of labels for "Specific people" checkbox visibility
167148
yield this.labelsManager.loadMoreTask.perform();
168149

169150
// fetch all tiers w̶i̶t̶h̶ c̶o̶u̶n̶t̶s̶

ā€Žghost/admin/app/components/gh-members-segment-select.hbsā€Ž

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1-
<GhTokenInput
2-
@options={{this.options}}
3-
@selected={{this.selectedOptions}}
1+
<GhSegmentTokenInput
2+
@nonLabelOptions={{this.nonLabelOptions}}
3+
@selectedSegments={{this.selectedSegments}}
4+
@hideLabels={{this.hideLabelsComputed}}
45
@disabled={{or @disabled this.fetchOptionsTask.isRunning}}
5-
@optionsComponent={{component "power-select-options-with-scroll" lastReached=(perform this.loadMoreLabelsTask)}}
6-
@allowCreation={{false}}
76
@renderInPlace={{this.renderInPlace}}
87
@onChange={{this.setSegment}}
98
@class="select-members"
109
@placeholder="Select a tier"
11-
as |option|
12-
>
13-
{{option.name}}
14-
</GhTokenInput>
10+
/>
1511

1612
<GhMembersSegmentCount
1713
@segment={{@segment}}

ā€Žghost/admin/app/components/gh-members-segment-select.jsā€Ž

Lines changed: 9 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {tracked} from '@glimmer/tracking';
77
export default class GhMembersSegmentSelect extends Component {
88
@service store;
99
@service feature;
10-
@service labelsManager;
1110

1211
@tracked _baseOptions = [];
1312

@@ -20,55 +19,23 @@ export default class GhMembersSegmentSelect extends Component {
2019
this.fetchOptionsTask.perform();
2120
}
2221

23-
get _options() {
24-
const options = [...this._baseOptions];
25-
const labels = this.labelsManager.labels;
26-
27-
if (labels.length > 0 && !this.args.hideLabels) {
28-
options.push({
29-
groupName: 'Labels',
30-
options: labels.map(label => ({
31-
name: label.name,
32-
segment: `label:${label.slug}`,
33-
count: label.count?.members,
34-
class: 'segment-label'
35-
}))
36-
});
37-
}
38-
39-
return options;
40-
}
41-
42-
get options() {
22+
get nonLabelOptions() {
4323
if (this.args.hideOptionsWhenAllSelected) {
44-
const selectedSegments = this.selectedOptions.mapBy('segment');
45-
if (selectedSegments.includes('status:free') && selectedSegments.includes('status:-free')) {
46-
return this._options.filter(option => !option.groupName);
24+
const segments = (this.args.segment || '').split(',');
25+
if (segments.includes('status:free') && segments.includes('status:-free')) {
26+
return this._baseOptions.filter(option => !option.groupName);
4727
}
4828
}
4929

50-
return this._options;
30+
return this._baseOptions;
5131
}
5232

53-
get flatOptions() {
54-
const options = [];
55-
56-
function getOptions(option) {
57-
if (option.options) {
58-
return option.options.forEach(getOptions);
59-
}
60-
61-
options.push(option);
62-
}
63-
64-
this._options.forEach(getOptions);
65-
66-
return options;
33+
get selectedSegments() {
34+
return (this.args.segment || '').split(',').filter(Boolean);
6735
}
6836

69-
get selectedOptions() {
70-
const segments = (this.args.segment || '').split(',');
71-
return this.flatOptions.filter(option => segments.includes(option.segment));
37+
get hideLabelsComputed() {
38+
return !!this.args.hideLabels;
7239
}
7340

7441
@action
@@ -77,11 +44,6 @@ export default class GhMembersSegmentSelect extends Component {
7744
this.args.onChange?.(segment);
7845
}
7946

80-
@task({drop: true})
81-
*loadMoreLabelsTask() {
82-
yield this.labelsManager.loadMoreTask.perform();
83-
}
84-
8547
@task
8648
*fetchOptionsTask() {
8749
const options = yield [];
@@ -157,9 +119,5 @@ export default class GhMembersSegmentSelect extends Component {
157119
}
158120

159121
this._baseOptions = options;
160-
161-
// fetch first page of labels (labels are last so infinite scroll works)
162-
// TODO: add `include: 'count.members` to query once API is fixed
163-
yield this.labelsManager.loadMoreTask.perform();
164122
}
165123
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<GhTokenInput
2+
@options={{this.mergedOptions}}
3+
@selected={{this.selectedOptions}}
4+
@disabled={{@disabled}}
5+
@optionsComponent={{component "power-select-options-with-scroll" lastReached=(perform this.loadMoreLabelsTask)}}
6+
@allowCreation={{false}}
7+
@search={{if this.useServerSideSearch (perform this.searchTask) null}}
8+
@renderInPlace={{this.renderInPlace}}
9+
@onChange={{@onChange}}
10+
@class={{@class}}
11+
@dropdownClass={{@dropdownClass}}
12+
@searchMessage={{@searchMessage}}
13+
@placeholder={{@placeholder}}
14+
as |option|
15+
>
16+
{{option.name}}
17+
</GhTokenInput>
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Component from '@glimmer/component';
2+
import flattenGroupedOptions from 'ghost-admin/utils/flatten-grouped-options';
3+
import {inject as service} from '@ember/service';
4+
import {task} from 'ember-concurrency';
5+
6+
export default class GhSegmentTokenInput extends Component {
7+
@service labelsManager;
8+
9+
constructor() {
10+
super(...arguments);
11+
if (!this.args.hideLabels && !this.labelsManager.hasLoaded) {
12+
this.labelsManager.loadMoreTask.perform();
13+
}
14+
}
15+
16+
get mergedOptions() {
17+
const options = [...(this.args.nonLabelOptions || [])];
18+
const labels = this.labelsManager.labels;
19+
20+
if (labels.length > 0 && !this.args.hideLabels) {
21+
options.push({
22+
groupName: 'Labels',
23+
options: labels.map(label => ({
24+
name: label.name,
25+
segment: `label:${label.slug}`,
26+
count: label.count?.members,
27+
class: 'segment-label'
28+
}))
29+
});
30+
}
31+
32+
return options;
33+
}
34+
35+
get selectedOptions() {
36+
const segments = this.args.selectedSegments || [];
37+
const segmentSet = new Set(segments);
38+
return flattenGroupedOptions(this.mergedOptions)
39+
.filter(option => segmentSet.has(option.segment));
40+
}
41+
42+
get useServerSideSearch() {
43+
if (this.args.hideLabels) {
44+
return false;
45+
}
46+
return !this.labelsManager.hasLoadedAll;
47+
}
48+
49+
get renderInPlace() {
50+
return this.args.renderInPlace === undefined ? false : this.args.renderInPlace;
51+
}
52+
53+
@task({restartable: true})
54+
*searchTask(term) {
55+
const results = [];
56+
const selectedSegments = new Set(this.args.selectedSegments || []);
57+
const lowerTerm = term.toLowerCase();
58+
59+
// Client-side filter non-label options
60+
for (const item of (this.args.nonLabelOptions || [])) {
61+
if (item.options) {
62+
for (const opt of item.options) {
63+
if (opt.name.toLowerCase().includes(lowerTerm) && !selectedSegments.has(opt.segment)) {
64+
results.push(opt);
65+
}
66+
}
67+
} else if (item.name.toLowerCase().includes(lowerTerm) && !selectedSegments.has(item.segment)) {
68+
results.push(item);
69+
}
70+
}
71+
72+
// Server-side search labels
73+
if (!this.args.hideLabels) {
74+
const labels = yield this.labelsManager.searchLabelsTask.perform(term);
75+
labels.forEach((label) => {
76+
const segment = `label:${label.slug}`;
77+
if (!selectedSegments.has(segment)) {
78+
results.push({
79+
name: label.name,
80+
segment,
81+
count: label.count?.members,
82+
class: 'segment-label'
83+
});
84+
}
85+
});
86+
}
87+
88+
return results;
89+
}
90+
91+
@task({drop: true})
92+
*loadMoreLabelsTask() {
93+
if (this.args.hideLabels) {
94+
return;
95+
}
96+
yield this.labelsManager.loadMoreTask.perform();
97+
}
98+
}

ā€Žghost/admin/app/services/labels-manager.jsā€Ž

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ export default class LabelsManagerService extends Service {
7474
*searchLabelsTask(term, {page = 1} = {}) {
7575
yield timeout(250);
7676
const safeTerm = term.replace(/'/g, `\\'`);
77-
return yield this.store.query('label', {filter: `name:~'${safeTerm}'`, limit: PAGE_SIZE, page, order: 'name asc'});
77+
const labels = yield this.store.query('label', {filter: `name:~'${safeTerm}'`, limit: PAGE_SIZE, page, order: 'name asc'});
78+
79+
// Register search results so they can be resolved by findBySlug/selectedOptions
80+
// even if they weren't in the initially paginated set
81+
labels.forEach(label => this.addLabel(label));
82+
83+
return labels;
7884
}
7985
}

ā€Žghost/admin/mirage/config/labels.jsā€Ž

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
1-
import {paginatedResponse} from '../utils';
1+
import {extractFilterParam, paginateModelCollection} from '../utils';
22

33
export default function mockLabels(server) {
44
server.post('/labels/');
5-
server.get('/labels/', paginatedResponse('labels'));
5+
6+
server.get('/labels/', function ({labels}, request) {
7+
let page = +request.queryParams.page || 1;
8+
let limit = request.queryParams.limit;
9+
let collection = labels.all();
10+
11+
// Handle filter param for server-side search (e.g. filter=name:~'term')
12+
const nameFilter = extractFilterParam('name', request.queryParams.filter);
13+
if (nameFilter) {
14+
const term = nameFilter.toLowerCase();
15+
collection.models = collection.models.filter(
16+
label => label.name.toLowerCase().includes(term)
17+
);
18+
}
19+
20+
if (limit !== 'all') {
21+
limit = +limit || 15;
22+
}
23+
24+
return paginateModelCollection('labels', collection, page, limit);
25+
});
626

727
server.get('/labels/:id/', function ({labels}, {params}) {
828
let {id} = params;

0 commit comments

Comments
Ā (0)