Skip to content

Commit f6373c1

Browse files
author
Marcin Wojciechowski
committed
#292 added ComboBoxListItemPicker
1 parent c3540d1 commit f6373c1

File tree

8 files changed

+421
-0
lines changed

8 files changed

+421
-0
lines changed

config/karma.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ module.exports = function (config) {
2020
};
2121
config.plugins.push(htmlReporter);
2222

23+
config.set({
24+
framework: ['jasmine']
25+
})
2326
// Add the remap-coverage - code coverage for the original files
2427
config.reporters.push('remap-coverage');
2528
config.coverageReporter = {

src/common/dal/ListItemRepository.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { RequestClient } from "@pnp/common/src/netutil";
2+
3+
export class ListItemRepository {
4+
constructor(protected SiteUrl: string, protected SPClient: RequestClient) {
5+
6+
}
7+
/**
8+
*
9+
* @param filterText text value of the filter part of oData query 'Id eq 1'
10+
* @param listId
11+
* @param internalColumnName
12+
* @param keyInternalColumnName
13+
* @param webUrl
14+
* @param top
15+
*/
16+
public async getListItemsByFilterClause(filterText: string, listId: string, internalColumnName: string, keyInternalColumnName?: string, webUrl?: string, top?: number): Promise<any[]> {
17+
let returnItems: any[];
18+
try {
19+
const webAbsoluteUrl = !webUrl ? this.SiteUrl : webUrl;
20+
const apiUrl = `${webAbsoluteUrl}/_api/web/lists('${listId}')/items?$select=${keyInternalColumnName || 'Id'},${internalColumnName}&$filter=${filterText}`;
21+
const data = await this.SPClient.get(apiUrl);
22+
if (data.ok) {
23+
const results = await data.json();
24+
if (results && results.value && results.value.length > 0) {
25+
return results.value;
26+
}
27+
}
28+
29+
return [];
30+
} catch (error) {
31+
return Promise.reject(error);
32+
}
33+
}
34+
}

src/common/mocks/RequestClientMock.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { RequestClient, FetchOptions } from "@pnp/common/src/netutil";
2+
3+
export class RequestClientMock implements RequestClient {
4+
public Requests: { url: string, method: string, options?: FetchOptions, resultString: string }[] = [];
5+
public OnRequest: (url: string, method: string, options?: FetchOptions) => void;
6+
public fetch(url: string, options?: FetchOptions): Promise<Response> {
7+
let mockedResponse = this.Requests.filter(req => req.method === options.method && req.url == url)[0];
8+
let response: Response;
9+
if (mockedResponse) {
10+
response = new Response(mockedResponse.resultString, {
11+
status: 200,
12+
statusText: "Ok"
13+
});
14+
}
15+
else {
16+
response = new Response(null, {
17+
status: 404,
18+
statusText: "Not fount",
19+
});
20+
}
21+
return Promise.resolve(response);
22+
}
23+
public fetchRaw(url: string, options?: FetchOptions): Promise<Response> {
24+
return this.fetch(url,options);
25+
}
26+
public get(url: string, options?: FetchOptions): Promise<Response> {
27+
options = options || {};
28+
options.method = "GET";
29+
return this.fetch(url,options);
30+
}
31+
public post(url: string, options?: FetchOptions): Promise<Response> {
32+
options = options || {};
33+
options.method = "POST";
34+
return this.fetch(url,options);
35+
}
36+
public patch(url: string, options?: FetchOptions): Promise<Response> {
37+
options = options || {};
38+
options.method = "PATCH";
39+
return this.fetch(url,options);
40+
}
41+
public delete(url: string, options?: FetchOptions): Promise<Response> {
42+
options = options || {};
43+
options.method = "DELETE";
44+
return this.fetch(url,options);
45+
}
46+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/// <reference types="sinon" />
2+
3+
import * as React from 'react';
4+
import { assert, expect } from 'chai';
5+
import { mount, ReactWrapper } from 'enzyme';
6+
import { ComboBoxListItemPicker } from './ComboBoxListItemPicker';
7+
import { RequestClientMock } from '../../common/mocks/RequestClientMock';
8+
9+
declare const sinon;
10+
11+
let mockHttpClient: RequestClientMock = new RequestClientMock();
12+
mockHttpClient.Requests.push({
13+
url: "/sites/test-site/_api/web/lists('TestId')/items?$select=Id,Title&$filter=Id gt 0",
14+
method: "GET",
15+
resultString: JSON.stringify({ "odata.metadata": "", "value": [{ "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "9624204a-c049-49a1-8a18-a417872f8883", "odata.etag": "\"2\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(1)", "Id": 1, "ID": 1, "Title": "Test 1" }, { "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "8b0e4ce8-f1e4-4b17-8f95-1ea7d63fefd6", "odata.etag": "\"1\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(2)", "Id": 2, "ID": 2, "Title": "Test 2" }, { "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "62aea125-e442-44cf-ab08-c555ffdd2798", "odata.etag": "\"1\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(3)", "Id": 3, "ID": 3, "Title": "Test 3" }, { "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "a25c55b2-9a4f-4976-8a12-f77393abe437", "odata.etag": "\"1\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(4)", "Id": 4, "ID": 4, "Title": "Test 4" }, { "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "8c57be67-a9a8-46c8-8954-6e51d3464be2", "odata.etag": "\"1\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(5)", "Id": 5, "ID": 5, "Title": "Test 5" }, { "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "d130664b-4e28-4ae5-b567-a3189f887922", "odata.etag": "\"1\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(6)", "Id": 6, "ID": 6, "Title": "Test 6" }, { "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "6e333b93-3f19-40ee-943c-19817193a3de", "odata.etag": "\"1\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(7)", "Id": 7, "ID": 7, "Title": "Test 7" }, { "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "a0063d1f-66ab-461b-8032-7dd38d7b8749", "odata.etag": "\"1\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(8)", "Id": 8, "ID": 8, "Title": "Test 8" }, { "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "8aca4c7c-4922-45e9-9f03-7d4a4bb6f926", "odata.etag": "\"1\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(9)", "Id": 9, "ID": 9, "Title": "Test 9" }, { "odata.type": "SP.Data.Test_x0020_listListItem", "odata.id": "f352a0c8-711b-47e4-9990-2a7121f961c3", "odata.etag": "\"1\"", "odata.editLink": "Web/Lists(guid'490fae3c-f8cb-4b50-9737-f2c94f6b6727')/Items(10)", "Id": 10, "ID": 10, "Title": "Test 10" }] })
16+
});
17+
describe('<ComboBoxListItemPicker />', () => {
18+
it("Should render initial data", () => {
19+
return new Promise((resolve, error) => {
20+
let comboBox = mount(<ComboBoxListItemPicker
21+
columnInternalName="Title"
22+
spHttpClient={mockHttpClient}
23+
webUrl="/sites/test-site"
24+
filter="Id gt 0"
25+
listId="TestId"
26+
itemLimit={20}
27+
onInitialized={() => {
28+
29+
expect(comboBox.state('availableOptions')).to.have.length(10);
30+
resolve();
31+
}}
32+
onSelectedItem={(item) => { }} />);
33+
});
34+
});
35+
it("Should call onSelectedItem", () => {
36+
return new Promise((resolve, error) => {
37+
let comboBox = mount(<ComboBoxListItemPicker
38+
columnInternalName="Title"
39+
spHttpClient={mockHttpClient}
40+
webUrl="/sites/test-site"
41+
filter="Id gt 0"
42+
listId="TestId"
43+
itemLimit={20}
44+
onInitialized={() => {
45+
46+
let ddBtn = comboBox.find('.ms-Button-flexContainer').first();
47+
ddBtn.simulate('click');
48+
//actual list is not part of the component
49+
let checkBoxBtn = document.querySelectorAll('.ms-ComboBox-option')[3];
50+
(checkBoxBtn as HTMLButtonElement).click();
51+
52+
//ddBtn.simulate('click');
53+
}}
54+
onSelectedItem={(item) => {
55+
expect(item.Id).to.equal(4);
56+
57+
resolve();
58+
}} />);
59+
});
60+
});
61+
it("Should initialize with default selection (id)", () => {
62+
return new Promise((resolve, error) => {
63+
let comboBox = mount(<ComboBoxListItemPicker
64+
columnInternalName="Title"
65+
spHttpClient={mockHttpClient}
66+
webUrl="/sites/test-site"
67+
defaultSelectedItems={[1]}
68+
filter="Id gt 0"
69+
listId="TestId"
70+
itemLimit={20}
71+
onInitialized={() => {
72+
let ddInput = comboBox.find('.ms-ComboBox-Input').first();
73+
expect((ddInput.getNode() as any).value).to.be.equal("Test 1");
74+
75+
resolve();
76+
}}
77+
onSelectedItem={(item) => {
78+
}} />);
79+
});
80+
});
81+
it("Should initialize with default selection (object)", () => {
82+
return new Promise((resolve, error) => {
83+
let comboBox = mount(<ComboBoxListItemPicker
84+
columnInternalName="Title"
85+
spHttpClient={mockHttpClient}
86+
webUrl="/sites/test-site"
87+
defaultSelectedItems={[{Id:1, Title: "Test 1"}]}
88+
filter="Id gt 0"
89+
listId="TestId"
90+
itemLimit={20}
91+
onInitialized={() => {
92+
93+
let ddInput = comboBox.find('.ms-ComboBox-Input').first();
94+
expect((ddInput.getNode() as any).value).to.be.equal("Test 1");
95+
96+
resolve();
97+
}}
98+
onSelectedItem={(item) => {
99+
}} />);
100+
});
101+
});
102+
it("Should call onSelectedItem (multi)", () => {
103+
return new Promise((resolve, error) => {
104+
let comboBox = mount(<ComboBoxListItemPicker
105+
columnInternalName="Title"
106+
spHttpClient={mockHttpClient}
107+
webUrl="/sites/test-site"
108+
filter="Id gt 0"
109+
listId="TestId"
110+
itemLimit={20}
111+
multiSelect={true}
112+
onInitialized={() => {
113+
114+
let ddBtn = comboBox.find('.ms-Button-flexContainer').first();
115+
ddBtn.simulate('click');
116+
//actual list is not part of the component
117+
let checkBoxBtn = document.querySelectorAll('.ms-ComboBox-option')[3];
118+
(checkBoxBtn as HTMLButtonElement).click();
119+
//ddBtn.simulate('click');
120+
}}
121+
onSelectedItem={(item) => {
122+
expect(item.Id).to.equal(4);
123+
expect(item.selected).to.be.equal(true);
124+
125+
let ddBtn = comboBox.find('.ms-Button-flexContainer').first();
126+
ddBtn.simulate('click');
127+
resolve();
128+
}} />);
129+
});
130+
});
131+
it("Should initialize with default selection (multi) (object)", () => {
132+
return new Promise((resolve, error) => {
133+
let comboBox = mount(<ComboBoxListItemPicker
134+
columnInternalName="Title"
135+
spHttpClient={mockHttpClient}
136+
webUrl="/sites/test-site"
137+
defaultSelectedItems={[{Id:1, Title: "Test 1"},{ Id:2, Title: "Test 2"}]}
138+
filter="Id gt 0"
139+
listId="TestId"
140+
multiSelect={true}
141+
itemLimit={20}
142+
onInitialized={() => {
143+
144+
let ddBtn = comboBox.find('.ms-Button-flexContainer').first();
145+
ddBtn.simulate('click');
146+
let checkBoxBtn = document.querySelectorAll('.ms-ComboBox-option')[0];
147+
expect(checkBoxBtn.classList.contains("is-checked")).to.be.equal(true);
148+
let ddInput = comboBox.find('.ms-ComboBox-Input').first();
149+
expect((ddInput.getNode() as any).value).to.be.equal("Test 1, Test 2");
150+
ddBtn.simulate('click');
151+
resolve();
152+
}}
153+
onSelectedItem={(item) => {
154+
}} />);
155+
});
156+
});
157+
it("Should initialize with default selection (multi) (id)", () => {
158+
return new Promise((resolve, error) => {
159+
let comboBox = mount(<ComboBoxListItemPicker
160+
columnInternalName="Title"
161+
spHttpClient={mockHttpClient}
162+
webUrl="/sites/test-site"
163+
defaultSelectedItems={[1,2]}
164+
filter="Id gt 0"
165+
listId="TestId"
166+
itemLimit={20}
167+
multiSelect={true}
168+
onInitialized={() => {
169+
170+
let ddBtn = comboBox.find('.ms-Button-flexContainer').first();
171+
ddBtn.simulate('click');
172+
let checkBoxBtn = document.querySelectorAll('.ms-ComboBox-option')[0];
173+
expect(checkBoxBtn.classList.contains("is-checked")).to.be.equal(true);
174+
175+
let ddInput = comboBox.find('.ms-ComboBox-Input').first();
176+
expect((ddInput.getNode() as any).value).to.be.equal("Test 1, Test 2");
177+
ddBtn.simulate('click');
178+
resolve();
179+
}}
180+
onSelectedItem={(item) => {
181+
}} />);
182+
});
183+
});
184+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import * as strings from 'ControlStrings';
2+
import * as React from "react";
3+
import { Label } from "office-ui-fabric-react/lib/Label";
4+
import { IComboBoxListItemPickerProps, IComboBoxListItemPickerState } from ".";
5+
import * as telemetry from '../../common/telemetry';
6+
import { ComboBox, IComboBoxOption } from "office-ui-fabric-react/lib/ComboBox";
7+
import { ListItemRepository } from '../../common/dal/ListItemRepository';
8+
9+
10+
export class ComboBoxListItemPicker extends React.Component<IComboBoxListItemPickerProps, IComboBoxListItemPickerState> {
11+
private _listItemRepo: ListItemRepository;
12+
public SelectedItems: any[];
13+
14+
constructor(props: IComboBoxListItemPickerProps) {
15+
super(props);
16+
17+
telemetry.track('ComboBoxListItemPicker', {});
18+
19+
// States
20+
this.state = {
21+
noresultsFoundText: !this.props.noResultsFoundText ? strings.genericNoResultsFoundText : this.props.noResultsFoundText,
22+
showError: false,
23+
errorMessage: "",
24+
suggestionsHeaderText: !this.props.suggestionsHeaderText ? strings.ListItemPickerSelectValue : this.props.suggestionsHeaderText
25+
};
26+
27+
// Get SPService Factory
28+
this._listItemRepo = new ListItemRepository(this.props.webUrl, this.props.spHttpClient);
29+
30+
this.SelectedItems = [];
31+
this.loadOptions();
32+
}
33+
34+
protected async loadOptions(): Promise<void> {
35+
let query = "";
36+
query += this.props.filter || "Id gt 0";
37+
let keyColumnName = this.props.keyColumnInternalName || "Id";
38+
let listItems = await this._listItemRepo.getListItemsByFilterClause(query,
39+
this.props.listId,
40+
this.props.columnInternalName,
41+
this.props.keyColumnInternalName,
42+
this.props.webUrl,
43+
this.props.itemLimit || 100);
44+
45+
let options = listItems.map(option => {
46+
return {
47+
key: option[keyColumnName],
48+
text: option[this.props.columnInternalName || "Id"]
49+
};
50+
});
51+
if (this.props.defaultSelectedItems) {
52+
//if passed only ids
53+
if (!isNaN(this.props.defaultSelectedItems[0])) {
54+
this.SelectedItems = options.filter(opt => this.props.defaultSelectedItems.indexOf(opt.key) >= 0);
55+
}
56+
else {
57+
this.SelectedItems = options.filter(opt => this.props.defaultSelectedItems.map(selected => selected[keyColumnName]).indexOf(opt.key) >= 0);
58+
}
59+
}
60+
this.setState({
61+
availableOptions: options
62+
});
63+
if(this.props.onInitialized){
64+
this.props.onInitialized();
65+
}
66+
}
67+
68+
public componentDidUpdate(prevProps: IComboBoxListItemPickerProps, prevState: IComboBoxListItemPickerState): void {
69+
if (this.props.listId !== prevProps.listId) {
70+
this.SelectedItems = [];
71+
}
72+
//this.loadOptions();
73+
}
74+
75+
/**
76+
* Render the field
77+
*/
78+
public render(): React.ReactElement<IComboBoxListItemPickerProps> {
79+
const { className, disabled, itemLimit } = this.props;
80+
81+
return (this.state.availableOptions ? (
82+
<div>
83+
<ComboBox
84+
options={this.state.availableOptions}
85+
autoComplete={this.props.autoComplete}
86+
comboBoxOptionStyles={this.props.comboBoxOptionStyles}
87+
allowFreeform={this.props.allowFreeform}
88+
keytipProps={this.props.keytipProps}
89+
onMenuDismissed={this.props.onMenuDismiss}
90+
onMenuOpen={this.props.onMenuOpen}
91+
text={this.props.text}
92+
onChanged={this.onChanged.bind(this)}
93+
multiSelect={this.props.multiSelect}
94+
defaultSelectedKey={this.SelectedItems.map(item=>item.key) || []}
95+
className={className}
96+
disabled={disabled} />
97+
98+
<Label style={{ color: '#FF0000' }}> {this.state.errorMessage} </Label>
99+
</div>) : <span>Loading...</span>
100+
);
101+
}
102+
103+
/**
104+
* On Selected Item
105+
*/
106+
private onChanged(option?: IComboBoxOption, index?: number, value?: string, submitPendingValueEvent?: any): void {
107+
this.props.onSelectedItem({
108+
[this.props.keyColumnInternalName || "Id"]: option.key,
109+
[this.props.columnInternalName]: option.text,
110+
selected: option.selected
111+
});
112+
}
113+
}

0 commit comments

Comments
 (0)