Skip to content

Commit 59ca12a

Browse files
feat(compass-aggregations): adds a project form to support $project use-cases in aggregation wizard - COMPASS-6658 (#4260)
1 parent 805a191 commit 59ca12a

File tree

5 files changed

+471
-5
lines changed

5 files changed

+471
-5
lines changed

packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import UseCaseList from './use-case-list';
22
import SortUseCase from './sort/sort';
3+
import ProjectUseCase from './project/project';
34

45
export type StageWizardUseCase = {
56
id: string;
@@ -20,6 +21,12 @@ export const STAGE_WIZARD_USE_CASES: StageWizardUseCase[] = [
2021
stageOperator: '$sort',
2122
wizardComponent: SortUseCase,
2223
},
24+
{
25+
id: 'project',
26+
title: 'Include or exclude a subset of fields from my documents',
27+
stageOperator: '$project',
28+
wizardComponent: ProjectUseCase,
29+
},
2330
];
2431

2532
export { UseCaseList };
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import React from 'react';
2+
import type { ComponentProps } from 'react';
3+
import ProjectForm, {
4+
mapProjectFormStateToStageValue,
5+
COMBOBOX_PLACEHOLDER_TEXT,
6+
getParentPaths,
7+
makeIsOptionDisabled,
8+
} from './project';
9+
import type { ProjectionType } from './project';
10+
import {
11+
render,
12+
screen,
13+
within,
14+
cleanup,
15+
fireEvent,
16+
} from '@testing-library/react';
17+
import userEvent from '@testing-library/user-event';
18+
import { expect } from 'chai';
19+
import sinon from 'sinon';
20+
21+
const renderForm = (
22+
props: Partial<ComponentProps<typeof ProjectForm>> = {}
23+
) => {
24+
render(
25+
<ProjectForm
26+
fields={['street', 'city', 'zip']}
27+
onChange={() => {}}
28+
{...props}
29+
/>
30+
);
31+
};
32+
33+
const selectProjection = (value: ProjectionType) => {
34+
const selectBox = screen.getByTestId('project-form-projection');
35+
userEvent.click(selectBox);
36+
37+
const selectAriaLabelledBy = selectBox.getAttribute('aria-labelledby');
38+
const option = within(
39+
document.querySelector(
40+
`ul[role="listbox"][aria-labelledby="${selectAriaLabelledBy}"]`
41+
)!
42+
).getByText(new RegExp(value, 'i'));
43+
44+
userEvent.click(option, undefined, { skipPointerEventsCheck: true });
45+
};
46+
47+
const selectFields = (fields: string[]) => {
48+
const comboboxField = within(
49+
screen.getByTestId('project-form-field')
50+
).getByRole('textbox', {
51+
name: new RegExp(COMBOBOX_PLACEHOLDER_TEXT, 'i'),
52+
});
53+
userEvent.click(comboboxField);
54+
55+
const comboboxOptionSelector = `.project-form-field-combobox`;
56+
fields.forEach((field) => {
57+
userEvent.click(
58+
within(document.querySelector(comboboxOptionSelector)!).getByText(
59+
new RegExp(`^${field}$`, 'i')
60+
),
61+
undefined,
62+
{
63+
skipPointerEventsCheck: true,
64+
}
65+
);
66+
});
67+
fireEvent(
68+
document.querySelector(comboboxOptionSelector)!,
69+
new MouseEvent('blur')
70+
);
71+
};
72+
73+
describe('project', function () {
74+
afterEach(cleanup);
75+
76+
it('renders a project form', function () {
77+
renderForm();
78+
expect(screen.getByTestId('project-form-projection')).to.exist;
79+
expect(screen.getByTestId('project-form-field')).to.exist;
80+
});
81+
82+
it('correctly changes the projection type', function () {
83+
renderForm();
84+
85+
selectProjection('exclude');
86+
expect(
87+
within(screen.getByTestId('project-form-projection')).getByText(
88+
/exclude/i
89+
)
90+
).to.exist;
91+
92+
selectProjection('include');
93+
expect(
94+
within(screen.getByTestId('project-form-projection')).getByText(
95+
/include/i
96+
)
97+
).to.exist;
98+
});
99+
100+
it('correctly selects a field from the combobox of fields', function () {
101+
renderForm();
102+
103+
selectFields(['street', 'city']);
104+
const selectedOptions = within(
105+
screen.getByTestId('project-form-field')
106+
).getAllByRole('option');
107+
108+
expect(selectedOptions).to.have.lengthOf(2);
109+
expect(within(selectedOptions[0]).getByText(/street/i)).to.exist;
110+
expect(within(selectedOptions[1]).getByText(/city/i)).to.exist;
111+
});
112+
113+
describe('onChange call', function () {
114+
const projectionTypes: Array<ProjectionType> = ['include', 'exclude'];
115+
116+
projectionTypes.forEach((projectionType) => {
117+
context(`when projection type is ${projectionType}`, function () {
118+
it('calls the props.onChange with form state converted to a project stage', function () {
119+
const onChangeSpy = sinon.spy();
120+
const op = projectionType === 'exclude' ? 0 : 1;
121+
renderForm({ onChange: onChangeSpy });
122+
selectProjection(projectionType);
123+
124+
selectFields(['street']);
125+
expect(onChangeSpy).to.have.been.calledWithExactly(
126+
JSON.stringify({ street: op }),
127+
null
128+
);
129+
130+
// Since we selected street above, this time it will deselect it
131+
selectFields(['street', 'city']);
132+
expect(onChangeSpy.lastCall).to.have.been.calledWithExactly(
133+
JSON.stringify({ city: op }),
134+
null
135+
);
136+
137+
// Here we select all three
138+
selectFields(['street', 'zip']);
139+
expect(onChangeSpy.lastCall).to.have.been.calledWithExactly(
140+
JSON.stringify({ city: op, street: op, zip: op }),
141+
null
142+
);
143+
});
144+
145+
it('calls the props.onChange with error if there was an error', function () {
146+
const onChangeSpy = sinon.spy();
147+
renderForm({ onChange: onChangeSpy });
148+
// Creating a scenario where form ends up empty
149+
150+
selectFields(['street', 'city']);
151+
selectFields(['street', 'city']);
152+
153+
expect(onChangeSpy.lastCall.args[0]).to.equal(JSON.stringify({}));
154+
155+
expect(onChangeSpy.lastCall.args[1].message).to.equal(
156+
'No field selected'
157+
);
158+
});
159+
});
160+
});
161+
});
162+
163+
describe('mapProjectFormStateToStageValue', function () {
164+
const variants: Array<ProjectionType> = ['include', 'exclude'];
165+
variants.forEach(function (variant) {
166+
context(`when variant is ${variant}`, function () {
167+
it('should return correct project stage for provided form state', function () {
168+
const op = variant === 'exclude' ? 0 : 1;
169+
expect(mapProjectFormStateToStageValue(variant, [])).to.deep.equal(
170+
{}
171+
);
172+
173+
expect(
174+
mapProjectFormStateToStageValue(variant, ['field1', 'field2'])
175+
).to.deep.equal({ field1: op, field2: op });
176+
177+
expect(
178+
mapProjectFormStateToStageValue(variant, [
179+
'field1',
180+
'field2',
181+
'field1',
182+
])
183+
).to.deep.equal({ field1: op, field2: op });
184+
});
185+
});
186+
});
187+
});
188+
189+
describe('getParentPaths', function () {
190+
it('should return possible parent paths for provided list of paths', function () {
191+
expect(getParentPaths([])).to.deep.equal([]);
192+
expect(getParentPaths(['address'])).to.deep.equal(['address']);
193+
expect(getParentPaths(['address'], ['address'])).to.deep.equal([]);
194+
195+
expect(getParentPaths(['address', 'city'])).to.deep.equal([
196+
'address',
197+
'address.city',
198+
]);
199+
expect(getParentPaths(['address', 'city'], ['address'])).to.deep.equal([
200+
'address.city',
201+
]);
202+
203+
expect(getParentPaths(['address', 'country', 'city'])).to.deep.equal([
204+
'address',
205+
'address.country',
206+
'address.country.city',
207+
]);
208+
expect(
209+
getParentPaths(
210+
['address', 'country', 'city'],
211+
['address', 'address.country']
212+
)
213+
).to.deep.equal(['address.country.city']);
214+
expect(
215+
getParentPaths(['address', 'country', 'city'], ['address.country'])
216+
).to.deep.equal(['address', 'address.country.city']);
217+
});
218+
});
219+
220+
describe('isOptionDisabled', function () {
221+
const options = [
222+
'_id',
223+
'address',
224+
'address.city',
225+
'address.state',
226+
'address.street',
227+
'address.zipcode',
228+
'address.nested',
229+
'address.nested.cityname',
230+
'address.nested.countryname',
231+
'cusine',
232+
'name',
233+
'stars',
234+
];
235+
236+
it('should return false for options when there is nothing in projectedfields', function () {
237+
const isOptionDisabled = makeIsOptionDisabled([]);
238+
options.forEach((option) => {
239+
expect(isOptionDisabled(option)).to.be.false;
240+
});
241+
});
242+
243+
it('should return false when projected fields do not include any nested field', function () {
244+
const isOptionDisabled = makeIsOptionDisabled([
245+
'_id',
246+
'name',
247+
'stars',
248+
'cusine',
249+
]);
250+
options.forEach((option) => {
251+
expect(isOptionDisabled(option)).to.be.false;
252+
});
253+
});
254+
255+
context('when there is a nested children in projected field', function () {
256+
it('should return true for its parent and the children of the projected nested children and false for rest', function () {
257+
// Check with a nested-nested property
258+
let isOptionDisabled = makeIsOptionDisabled(['address.nested']);
259+
expect(isOptionDisabled('_id')).to.be.false;
260+
// Since a children is already projected the parent cannot be
261+
expect(isOptionDisabled('address')).to.be.true;
262+
expect(isOptionDisabled('address.city')).to.be.false;
263+
expect(isOptionDisabled('address.state')).to.be.false;
264+
expect(isOptionDisabled('address.street')).to.be.false;
265+
expect(isOptionDisabled('address.zipcode')).to.be.false;
266+
expect(isOptionDisabled('address.nested')).to.be.false;
267+
// Since the parent of the following paths is already projected hence children cannot be
268+
expect(isOptionDisabled('address.nested.cityname')).to.be.true;
269+
expect(isOptionDisabled('address.nested.countryname')).to.be.true;
270+
expect(isOptionDisabled('cusine')).to.be.false;
271+
expect(isOptionDisabled('name')).to.be.false;
272+
expect(isOptionDisabled('stars')).to.be.false;
273+
274+
// This time check with a simple nested property
275+
isOptionDisabled = makeIsOptionDisabled(['address.city']);
276+
expect(isOptionDisabled('_id')).to.be.false;
277+
// Since a children is already projected the parent cannot be
278+
expect(isOptionDisabled('address')).to.be.true;
279+
expect(isOptionDisabled('address.city')).to.be.false;
280+
expect(isOptionDisabled('address.state')).to.be.false;
281+
expect(isOptionDisabled('address.street')).to.be.false;
282+
expect(isOptionDisabled('address.zipcode')).to.be.false;
283+
expect(isOptionDisabled('address.nested')).to.be.false;
284+
expect(isOptionDisabled('address.nested.cityname')).to.be.false;
285+
expect(isOptionDisabled('address.nested.countryname')).to.be.false;
286+
expect(isOptionDisabled('cusine')).to.be.false;
287+
expect(isOptionDisabled('name')).to.be.false;
288+
expect(isOptionDisabled('stars')).to.be.false;
289+
});
290+
});
291+
});
292+
});

0 commit comments

Comments
 (0)