Skip to content

Commit bac3091

Browse files
NEW Add search/filter to campaign admin (silverstripe#380)
1 parent d0790ba commit bac3091

8 files changed

Lines changed: 226 additions & 15 deletions

File tree

client/dist/js/bundle.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/dist/styles/bundle.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/containers/CampaignAdmin/CampaignAdmin.js

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import * as breadcrumbsActions from 'state/breadcrumbs/BreadcrumbsActions';
1010
import * as recordActions from 'state/records/RecordsActions';
1111
import Breadcrumb from 'components/Breadcrumb/Breadcrumb';
1212
import FormAction from 'components/FormAction/FormAction';
13+
import Search, { hasFilters } from 'components/Search/Search';
14+
import SearchToggle from 'components/Search/SearchToggle';
1315
import i18n from 'i18n';
1416
import Toolbar from 'components/Toolbar/Toolbar';
1517
import FormBuilderLoader from 'containers/FormBuilderLoader/FormBuilderLoader';
@@ -29,6 +31,8 @@ class CampaignAdmin extends Component {
2931
this.state = {
3032
loading: false,
3133
focusIntroCloseButton: false,
34+
showSearch: false,
35+
filters: {},
3236
};
3337

3438
this.helpButtonRef = React.createRef();
@@ -51,6 +55,11 @@ class CampaignAdmin extends Component {
5155
},
5256
});
5357

58+
this.searchCampaignsApi = backend.createEndpointFetcher({
59+
...props.sectionConfig.searchCampaignsEndpoint,
60+
payloadSchema: {},
61+
});
62+
5463
// Bind
5564
this.handleBackButtonClick = this.handleBackButtonClick.bind(this);
5665
this.handleCreateCampaignSubmit = this.handleCreateCampaignSubmit.bind(this);
@@ -60,6 +69,9 @@ class CampaignAdmin extends Component {
6069
this.addCampaign = this.addCampaign.bind(this);
6170
this.handleHideMessage = this.handleHideMessage.bind(this);
6271
this.handleToggleMessage = this.handleToggleMessage.bind(this);
72+
this.toggleSearch = this.toggleSearch.bind(this);
73+
this.handleDoSearch = this.handleDoSearch.bind(this);
74+
this.handleClearSearch = this.handleClearSearch.bind(this);
6375
}
6476

6577
componentDidMount() {
@@ -87,6 +99,30 @@ class CampaignAdmin extends Component {
8799
}
88100
}
89101

102+
toggleSearch() {
103+
this.setState(
104+
prevState => ({ showSearch: !prevState.showSearch })
105+
);
106+
}
107+
108+
handleClearSearch() {
109+
this.setState({ showSearch: false });
110+
this.handleDoSearch({});
111+
}
112+
113+
handleDoSearch(filters) {
114+
this.setState({ filters });
115+
// If there are no filters, or the filter values are empty, just fetch everything
116+
if (!hasFilters(filters) || Object.values(filters).filter((val) => val || val === 0).length === 0) {
117+
return this.fetchCampaignsList();
118+
}
119+
return this.props.campaignActions.searchCampaigns(
120+
this.props.sectionConfig.treeClass,
121+
this.searchCampaignsApi,
122+
filters
123+
);
124+
}
125+
90126
setBreadcrumbs(view, id, title) {
91127
const { sectionConfig: { reactRoutePath } } = this.props;
92128

@@ -364,11 +400,11 @@ By removing this item all linked items will be removed unless used elsewhere.`;
364400
* @returns {object}
365401
*/
366402
renderDetailEditView() {
367-
const { FormBuilderLoaderComponent, BreadcrumbComponent } = this.props;
403+
const { FormBuilderLoaderComponent, BreadcrumbComponent, sectionConfig } = this.props;
368404
if (this.props.router.params.id <= 0) {
369405
return this.renderCreateView();
370406
}
371-
const baseSchemaUrl = this.props.sectionConfig.form.campaignEditForm.schemaUrl;
407+
const baseSchemaUrl = sectionConfig.form.campaignEditForm.schemaUrl;
372408
const schemaUrl = joinUrlPaths(baseSchemaUrl, '/', this.props.router.params.id);
373409

374410
return (
@@ -419,8 +455,8 @@ By removing this item all linked items will be removed unless used elsewhere.`;
419455
* @returns {object}
420456
*/
421457
renderIndexView() {
422-
const { showMessage, BreadcrumbComponent, FormBuilderLoaderComponent } = this.props;
423-
const { schemaUrl } = this.props.sectionConfig.form.EditForm;
458+
const { showMessage, BreadcrumbComponent, FormBuilderLoaderComponent, sectionConfig } = this.props;
459+
const { schemaUrl } = sectionConfig.form.EditForm;
424460
const formActionProps = {
425461
title: i18n._t('CampaignAdmin.ADDNEWCAMPAIGN', 'Add new campaign'),
426462
icon: 'plus',
@@ -433,11 +469,27 @@ By removing this item all linked items will be removed unless used elsewhere.`;
433469
identifier: 'Campaign.IndexView',
434470
};
435471

472+
const showSearch = hasFilters(this.state.filters) || this.state.showSearch;
473+
436474
return (
437475
<div className="fill-height" aria-expanded="true">
438476
<Toolbar>
439477
<BreadcrumbComponent multiline />
478+
<div className="campaign--toolbar__extra pull-xs-right fill-width vertical-align-items">
479+
<SearchToggle toggled={showSearch} onToggle={this.toggleSearch} />
480+
</div>
440481
</Toolbar>
482+
{showSearch && <Search
483+
onSearch={this.handleDoSearch}
484+
id="CampaignSearchForm"
485+
formSchemaUrl={sectionConfig.form.campaignSearchForm.schemaUrl}
486+
onHide={this.handleClearSearch}
487+
displayBehavior="HIDEABLE"
488+
filters={this.state.filters}
489+
filterPrefix="Search__"
490+
addFilterPrefix
491+
name={sectionConfig.searchCampaignsGeneralField}
492+
/>}
441493
<div className="panel panel--scrollable flexbox-area-grow">
442494
<IntroScreen
443495
show={showMessage}

client/src/containers/CampaignAdmin/CampaignAdmin.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,11 @@
173173
.campaign-toolbar {
174174
flex-direction: row-reverse;
175175
}
176+
177+
.campaign--toolbar__extra {
178+
width: auto;
179+
180+
button:last-of-type {
181+
margin-right: -10px;
182+
}
183+
}

client/src/state/campaign/CampaignActions.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,27 @@ export function removeCampaignItem(removeItemApi, campaignId, itemId) {
129129
});
130130
};
131131
}
132+
133+
export function searchCampaigns(recordType, searchCampaignsApi, filters) {
134+
return (dispatch) => {
135+
dispatch({
136+
type: RECORD_ACTION_TYPES.FETCH_RECORDS_REQUEST,
137+
payload: { recordType },
138+
});
139+
140+
return searchCampaignsApi({ filters })
141+
.then((response) => {
142+
dispatch({
143+
type: RECORD_ACTION_TYPES.FETCH_RECORDS_SUCCESS,
144+
payload: { recordType, data: response },
145+
});
146+
})
147+
.catch((error) => {
148+
dispatch({
149+
type: RECORD_ACTION_TYPES.FETCH_RECORDS_FAILURE,
150+
payload: { error, recordType },
151+
});
152+
throw error;
153+
});
154+
};
155+
}

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
],
2323
"require": {
2424
"php": "^8.3",
25-
"silverstripe/admin": "^3",
25+
"silverstripe/admin": "^3.2",
2626
"silverstripe/framework": "^6.1",
27-
"silverstripe/versioned": "^3"
27+
"silverstripe/versioned": "^3.2"
2828
},
2929
"require-dev": {
3030
"phpunit/phpunit": "^11.3",

src/CampaignAdmin.php

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
use SilverStripe\Model\List\SS_List;
2121
use SilverStripe\ORM\UnexpectedDataException;
2222
use SilverStripe\Core\Validation\ValidationResult;
23+
use SilverStripe\ORM\DataList;
24+
use SilverStripe\ORM\Search\SearchContext;
25+
use SilverStripe\ORM\Search\SearchContextForm;
2326
use SilverStripe\Security\PermissionProvider;
2427
use SilverStripe\Security\Security;
2528
use SilverStripe\Security\SecurityToken;
@@ -38,6 +41,8 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider
3841
'EditForm',
3942
'campaignEditForm',
4043
'campaignCreateForm',
44+
'campaignSearchForm',
45+
'searchCampaigns',
4146
'readCampaigns',
4247
'readCampaign',
4348
'deleteCampaign',
@@ -126,6 +131,9 @@ public function getClientConfig(): array
126131
'campaignCreateForm' => [
127132
'schemaUrl' => $this->Link('schema/campaignCreateForm')
128133
],
134+
'campaignSearchForm' => [
135+
'schemaUrl' => $this->Link('schema/campaignSearchForm')
136+
],
129137
],
130138
'readCampaignsEndpoint' => [
131139
'url' => $this->Link('sets'),
@@ -143,6 +151,11 @@ public function getClientConfig(): array
143151
'url' => $this->Link('removeCampaignItem/:id/:itemId'),
144152
'method' => 'post'
145153
],
154+
'searchCampaignsEndpoint' => [
155+
'url' => $this->Link('searchCampaigns'),
156+
'method' => 'post'
157+
],
158+
'searchCampaignsGeneralField' => $this->getCampaignSearchForm()->getSearchField(),
146159
'treeClass' => $this->config()->get('model_class')
147160
]);
148161
}
@@ -231,7 +244,11 @@ protected function getPlaceholderGroups()
231244
*/
232245
protected function getListResource()
233246
{
234-
$items = $this->getListItems();
247+
return $this->getResourceForList($this->getListItems());
248+
}
249+
250+
private function getResourceForList(SS_List $items): array
251+
{
235252
$count = $items->count();
236253
/** @var string $treeClass */
237254
$treeClass = $this->config()->get('model_class');
@@ -406,6 +423,18 @@ protected function getChangeSetItemResource(ChangeSetItem $changeSetItem)
406423
* @return SS_List<ChangeSet>
407424
*/
408425
protected function getListItems()
426+
{
427+
return $this->getRawListItems()
428+
->filterByCallback(function ($item) {
429+
/** @var ChangeSet $item */
430+
return ($item->canView());
431+
});
432+
}
433+
434+
/**
435+
* Gets list of campaigns whether they can be viewed or not
436+
*/
437+
private function getRawListItems(): DataList
409438
{
410439
$changesets = ChangeSet::get();
411440
// Filter out published items if disabled
@@ -416,14 +445,9 @@ protected function getListItems()
416445
if (!$this->config()->get('show_inferred')) {
417446
$changesets = $changesets->filter('IsInferred', 0);
418447
}
419-
return $changesets
420-
->filterByCallback(function ($item) {
421-
/** @var ChangeSet $item */
422-
return ($item->canView());
423-
});
448+
return $changesets;
424449
}
425450

426-
427451
/**
428452
* REST endpoint to get a campaign.
429453
*
@@ -720,6 +744,40 @@ public function getCampaignCreateForm()
720744
return $form;
721745
}
722746

747+
/**
748+
* Get a SearchContextForm for searching campaigns, based on searchable fields config in ChangeSet
749+
*/
750+
public function getCampaignSearchForm(?SearchContext $searchContext = null): SearchContextForm
751+
{
752+
if (!$searchContext) {
753+
$searchContext = ChangeSet::singleton()->getDefaultSearchContext();
754+
}
755+
$form = SearchContextForm::create($this, $searchContext, 'campaignSearchForm');
756+
return $form;
757+
}
758+
759+
/**
760+
* Search for campaigns using ChangeSet's SearchContext
761+
*/
762+
public function searchCampaigns(HTTPRequest $request): HTTPResponse
763+
{
764+
$searchContext = ChangeSet::singleton()->getDefaultSearchContext();
765+
$filters = $request->requestVar('filters');
766+
$form = $this->getCampaignSearchForm($searchContext);
767+
$filterArguments = $form->prepareValuesForSearchContext($filters);
768+
$results = $searchContext->getQuery($filterArguments, existingQuery: $this->getRawListItems());
769+
$results = $results->filterByCallback(function ($item) {
770+
/** @var ChangeSet $item */
771+
return $item->canView();
772+
});
773+
774+
$response = $this->getResponse();
775+
$resourceArray = $this->getResourceForList($results);
776+
$response->setBody(json_encode($resourceArray));
777+
$response->addHeader('Content-Type', 'application/json');
778+
return $response;
779+
}
780+
723781
/**
724782
* Save handler
725783
*/
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
@javascript @retry
2+
Feature: Manage campaigns
3+
As a cms author
4+
I want to search/filter campaigns within the CMS
5+
So that I can find specific campaigns quickly
6+
7+
Background:
8+
Given a "ChangeSet" "First Campaign" with "Description"="this is a campaign"
9+
And a "ChangeSet" "Second Campaign" with "Description"="lorem ipsum" and "State"="published" and "PublishDate"="2025-01-01 12:00:00"
10+
And a "ChangeSet" "Third Campaign" with "Description"="lorem ipsum" and "State"="published" and "PublishDate"="2025-05-06 12:00:00"
11+
And the "group" "CAMPAIGNS_EDITOR" has permissions "Access to 'Campaigns' section"
12+
And I am logged in as a member of "CAMPAIGNS_EDITOR" group
13+
And I go to "/admin/campaigns"
14+
15+
Scenario: I can search for campaigns using the general search
16+
Given I should not see a "#CampaignSearchForm_searchbox" element
17+
When I press the "Show search" button
18+
# Validate we see the expected campaigns before searching
19+
Then I should see a "#CampaignSearchForm_searchbox" element
20+
And I should see the campaign "First Campaign"
21+
And I should see the campaign "Second Campaign"
22+
And I should see the campaign "Third Campaign"
23+
# Search for something in the description
24+
When I fill in "SearchBox__q" with "ipsum"
25+
And I press the "Enter" key in the "SearchBox__q" field
26+
Then I should not see the campaign "First Campaign"
27+
And I should see the campaign "Second Campaign"
28+
And I should see the campaign "Third Campaign"
29+
# Clear search
30+
When I press the "Close search" button
31+
Then I should see the campaign "First Campaign"
32+
And I should see the campaign "Second Campaign"
33+
And I should see the campaign "Third Campaign"
34+
And I should not see a "#CampaignSearchForm_searchbox" element
35+
# Search for something in the title
36+
When I press the "Show search" button
37+
And I fill in "SearchBox__q" with "first"
38+
And I press the "Enter" key in the "SearchBox__q" field
39+
Then I should see the campaign "First Campaign"
40+
And I should not see the campaign "Second Campaign"
41+
And I should not see the campaign "Third Campaign"
42+
43+
Scenario: I can filter for campaigns using the search options
44+
# Filter by unpublished campaigns
45+
When I press the "Show search" button
46+
And I press the "Search options" button
47+
And I select "Active" from "Status"
48+
And I press the "Search" button
49+
Then I should see the campaign "First Campaign"
50+
And I should not see the campaign "Second Campaign"
51+
And I should not see the campaign "Third Campaign"
52+
# Filter by published campaigns from a certain date
53+
When I press the "Search options" button
54+
And I select "Published" from "Status"
55+
# We can't use `And I fill in "Search__PublishDate_SearchFrom" with "some date"` because
56+
# the HTML5 datetime widget does weird things. This manual way works though.
57+
And I focus on the "input[name='Search__PublishDate_SearchFrom']" element
58+
And I type "01-05-2025" in the field
59+
And I press the "Tab" key globally
60+
And I type "12:00pm" in the field
61+
And I press the "Search" button
62+
Then I should not see the campaign "First Campaign"
63+
And I should not see the campaign "Second Campaign"
64+
And I should see the campaign "Third Campaign"
65+
# Clear the publish date filter
66+
When I click on the ".compact-tag-list__visible li.tag-component:nth-child(2) button.tag-component__delete" element
67+
Then I should not see the campaign "First Campaign"
68+
And I should see the campaign "Second Campaign"
69+
And I should see the campaign "Third Campaign"

0 commit comments

Comments
 (0)