Skip to content

Commit 684e4f4

Browse files
authored
chore(stage-wizard): refactor fields combobox COMPASS-6769 (#4357)
1 parent 188f348 commit 684e4f4

25 files changed

+494
-374
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import React from 'react';
2+
import type { ComponentProps } from 'react';
3+
import {
4+
FieldCombobox,
5+
SINGLE_SELECT_LABEL,
6+
getParentPaths,
7+
isOptionDisabled,
8+
} from './field-combobox';
9+
import { render, screen } from '@testing-library/react';
10+
import { expect } from 'chai';
11+
import { setInputElementValue } from '../../../../test/form-helper';
12+
import type { StageWizardFields } from '.';
13+
14+
const SAMPLE_FIELDS: StageWizardFields = [
15+
{
16+
name: '_id',
17+
type: 'ObjectId',
18+
},
19+
{
20+
name: 'address',
21+
type: 'String',
22+
},
23+
{
24+
name: 'address.city',
25+
type: 'String',
26+
},
27+
{
28+
name: 'address.zip',
29+
type: 'Int32',
30+
},
31+
];
32+
33+
const renderFieldCombobox = (
34+
props: Partial<ComponentProps<typeof FieldCombobox>> = {}
35+
) => {
36+
render(<FieldCombobox fields={SAMPLE_FIELDS} {...props} />);
37+
};
38+
describe('field-combobox', function () {
39+
context('component', function () {
40+
it('does not render custom field label for available options', function () {
41+
renderFieldCombobox();
42+
setInputElementValue(new RegExp(SINGLE_SELECT_LABEL, 'i'), '_id');
43+
const option = screen.getByRole('option', { name: '_id' });
44+
expect(option).to.exist;
45+
});
46+
it('renders custom field label for unavailable options', function () {
47+
renderFieldCombobox();
48+
setInputElementValue(new RegExp(SINGLE_SELECT_LABEL, 'i'), 'email');
49+
const option = screen.getByRole('option', { name: /field: "email"/i });
50+
expect(option).to.exist;
51+
});
52+
});
53+
context('helpers', function () {
54+
describe('getParentPaths', function () {
55+
it('should return possible parent paths for provided list of paths', function () {
56+
expect(getParentPaths([])).to.deep.equal([]);
57+
expect(getParentPaths(['address'])).to.deep.equal(['address']);
58+
expect(getParentPaths(['address'], ['address'])).to.deep.equal([]);
59+
60+
expect(getParentPaths(['address', 'city'])).to.deep.equal([
61+
'address',
62+
'address.city',
63+
]);
64+
expect(getParentPaths(['address', 'city'], ['address'])).to.deep.equal([
65+
'address.city',
66+
]);
67+
68+
expect(getParentPaths(['address', 'country', 'city'])).to.deep.equal([
69+
'address',
70+
'address.country',
71+
'address.country.city',
72+
]);
73+
expect(
74+
getParentPaths(
75+
['address', 'country', 'city'],
76+
['address', 'address.country']
77+
)
78+
).to.deep.equal(['address.country.city']);
79+
expect(
80+
getParentPaths(['address', 'country', 'city'], ['address.country'])
81+
).to.deep.equal(['address', 'address.country.city']);
82+
});
83+
});
84+
85+
describe('isOptionDisabled', function () {
86+
const options = [
87+
'_id',
88+
'address',
89+
'address.city',
90+
'address.state',
91+
'address.street',
92+
'address.zipcode',
93+
'address.nested',
94+
'address.nested.cityname',
95+
'address.nested.countryname',
96+
'cusine',
97+
'name',
98+
'stars',
99+
];
100+
101+
it('should return false for options when there is nothing in projectedfields', function () {
102+
options.forEach((option) => {
103+
expect(isOptionDisabled([], option)).to.be.false;
104+
});
105+
});
106+
107+
it('should return false when projected fields do not include any nested field', function () {
108+
options.forEach((option) => {
109+
expect(isOptionDisabled(['_id', 'name', 'stars', 'cusine'], option))
110+
.to.be.false;
111+
});
112+
});
113+
114+
context(
115+
'when there is a nested children in projected field',
116+
function () {
117+
it('should return true for its parent and the children of the projected nested children and false for rest', function () {
118+
// Check with a nested-nested property
119+
expect(isOptionDisabled(['address.nested'], '_id')).to.be.false;
120+
// Since a children is already projected the parent cannot be
121+
expect(isOptionDisabled(['address.nested'], 'address')).to.be.true;
122+
expect(isOptionDisabled(['address.nested'], 'address.city')).to.be
123+
.false;
124+
expect(isOptionDisabled(['address.nested'], 'address.state')).to.be
125+
.false;
126+
expect(isOptionDisabled(['address.nested'], 'address.street')).to.be
127+
.false;
128+
expect(isOptionDisabled(['address.nested'], 'address.zipcode')).to
129+
.be.false;
130+
expect(isOptionDisabled(['address.nested'], 'address.nested')).to.be
131+
.false;
132+
// Since the parent of the following paths is already projected hence children cannot be
133+
expect(
134+
isOptionDisabled(['address.nested'], 'address.nested.cityname')
135+
).to.be.true;
136+
expect(
137+
isOptionDisabled(['address.nested'], 'address.nested.countryname')
138+
).to.be.true;
139+
expect(isOptionDisabled(['address.nested'], 'cusine')).to.be.false;
140+
expect(isOptionDisabled(['address.nested'], 'name')).to.be.false;
141+
expect(isOptionDisabled(['address.nested'], 'stars')).to.be.false;
142+
143+
// This time check with a simple nested property
144+
expect(isOptionDisabled(['address.city'], '_id')).to.be.false;
145+
// Since a children is already projected the parent cannot be
146+
expect(isOptionDisabled(['address.city'], 'address')).to.be.true;
147+
expect(isOptionDisabled(['address.city'], 'address.city')).to.be
148+
.false;
149+
expect(isOptionDisabled(['address.city'], 'address.state')).to.be
150+
.false;
151+
expect(isOptionDisabled(['address.city'], 'address.street')).to.be
152+
.false;
153+
expect(isOptionDisabled(['address.city'], 'address.zipcode')).to.be
154+
.false;
155+
expect(isOptionDisabled(['address.city'], 'address.nested')).to.be
156+
.false;
157+
expect(
158+
isOptionDisabled(['address.city'], 'address.nested.cityname')
159+
).to.be.false;
160+
expect(
161+
isOptionDisabled(['address.city'], 'address.nested.countryname')
162+
).to.be.false;
163+
expect(isOptionDisabled(['address.city'], 'cusine')).to.be.false;
164+
expect(isOptionDisabled(['address.city'], 'name')).to.be.false;
165+
expect(isOptionDisabled(['address.city'], 'stars')).to.be.false;
166+
});
167+
}
168+
);
169+
});
170+
});
171+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {
2+
ComboboxWithCustomOption,
3+
ComboboxOption,
4+
} from '@mongodb-js/compass-components';
5+
import React, { useMemo } from 'react';
6+
import type { ComponentProps } from 'react';
7+
import type { WizardComponentProps } from '.';
8+
9+
export const SINGLE_SELECT_LABEL = 'Select a field';
10+
export const MULTI_SELECT_LABEL = 'Select fields';
11+
12+
type CustomComboboxProps = ComponentProps<typeof ComboboxWithCustomOption>;
13+
14+
// Generates parent paths for a list of paths
15+
// by joining paths one at a time.
16+
// ['a','b','c'] => ['a', 'a.b', 'a.b.c']
17+
export const getParentPaths = (paths: string[], excluding: string[] = []) => {
18+
const parentPaths = paths.reduce<string[]>((parents, path) => {
19+
const parentPath = !parents.length
20+
? path
21+
: parents[parents.length - 1] + '.' + path;
22+
23+
return [...parents, parentPath];
24+
}, []);
25+
26+
return parentPaths.filter((path) => !excluding.includes(path));
27+
};
28+
29+
export const isOptionDisabled = (selectedOptions: string[], option: string) => {
30+
const paths = option.split('.');
31+
// If option is nested property then we might need to disable
32+
// it if one of its possible children or one of its parent is
33+
// already selected
34+
if (paths.length > 1) {
35+
const parentPaths = getParentPaths(paths, [option]);
36+
const parentHasOption = parentPaths.some((path) =>
37+
selectedOptions.includes(path)
38+
);
39+
const childHasOption = selectedOptions.some((field) =>
40+
field.startsWith(`${option}.`)
41+
);
42+
return parentHasOption || childHasOption;
43+
}
44+
45+
// If option is a path at first level then we disable it only
46+
// when any of its children are already selected.
47+
return selectedOptions.some((field) => field.startsWith(`${option}.`));
48+
};
49+
50+
export const FieldCombobox = ({
51+
fields: schemaFields,
52+
multiselect,
53+
'aria-label': ariaLabel,
54+
placeholder,
55+
isRelatedFieldDisabled = false,
56+
value,
57+
...props
58+
}: Partial<CustomComboboxProps> & {
59+
fields: WizardComponentProps['fields'];
60+
// When selecting a field, if its nested or parent field should be disabled.
61+
// Only applicable when its a multiselect combobox.
62+
isRelatedFieldDisabled?: boolean;
63+
}) => {
64+
const fields = useMemo(
65+
() => schemaFields.map(({ name, type }) => ({ value: name, type })),
66+
[schemaFields]
67+
);
68+
69+
const label = useMemo(
70+
() => (multiselect ? MULTI_SELECT_LABEL : SINGLE_SELECT_LABEL),
71+
[multiselect]
72+
);
73+
74+
return (
75+
<ComboboxWithCustomOption
76+
aria-label={ariaLabel ?? label}
77+
placeholder={placeholder ?? label}
78+
multiselect={multiselect}
79+
size="default"
80+
clearable={false}
81+
overflow="scroll-x"
82+
{...props}
83+
options={fields}
84+
value={value}
85+
renderOption={(option, index, isCustom) => {
86+
return (
87+
<ComboboxOption
88+
key={`field-option-${index}`}
89+
value={option.value}
90+
displayName={isCustom ? `Field: "${option.value}"` : option.value}
91+
description={option.type ?? 'Unknown'}
92+
disabled={
93+
isRelatedFieldDisabled && multiselect
94+
? isOptionDisabled((value ?? []) as string[], option.value)
95+
: false
96+
}
97+
/>
98+
);
99+
}}
100+
/>
101+
);
102+
};

packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/group/basic-group.spec.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { expect } from 'chai';
44
import { BasicGroup } from './basic-group';
55
import sinon from 'sinon';
66
import { setMultiSelectComboboxValues } from '../../../../../test/form-helper';
7+
import { MULTI_SELECT_LABEL } from '../field-combobox';
78

89
describe('basic group', function () {
910
context('renders a group form', function () {
@@ -17,7 +18,7 @@ describe('basic group', function () {
1718
expect(screen.getByRole('combobox')).to.exist;
1819
expect(
1920
screen.getByRole('textbox', {
20-
name: /select field names/i,
21+
name: new RegExp(MULTI_SELECT_LABEL, 'i'),
2122
})
2223
).to.exist;
2324
});
@@ -36,7 +37,11 @@ describe('basic group', function () {
3637
/>
3738
);
3839

39-
setMultiSelectComboboxValues(/select field names/i, ['a', 'b', 'c']);
40+
setMultiSelectComboboxValues(new RegExp(MULTI_SELECT_LABEL, 'i'), [
41+
'a',
42+
'b',
43+
'c',
44+
]);
4045

4146
expect(onChange.lastCall.args[0]).to.equal(
4247
JSON.stringify({
@@ -50,7 +55,7 @@ describe('basic group', function () {
5055
expect(onChange.lastCall.args[1]).to.be.null;
5156

5257
// deselect a
53-
setMultiSelectComboboxValues(/select field names/i, ['a']);
58+
setMultiSelectComboboxValues(new RegExp(MULTI_SELECT_LABEL, 'i'), ['a']);
5459
expect(onChange.lastCall.args[0]).to.equal(
5560
JSON.stringify({
5661
_id: {
@@ -62,7 +67,7 @@ describe('basic group', function () {
6267
expect(onChange.lastCall.args[1]).to.be.null;
6368

6469
// deselect b
65-
setMultiSelectComboboxValues(/select field names/i, ['b']);
70+
setMultiSelectComboboxValues(new RegExp(MULTI_SELECT_LABEL, 'i'), ['b']);
6671
expect(onChange.lastCall.args[0]).to.equal(
6772
JSON.stringify({
6873
_id: '$c',
@@ -71,7 +76,7 @@ describe('basic group', function () {
7176
expect(onChange.lastCall.args[1]).to.be.null;
7277

7378
// deselect c
74-
setMultiSelectComboboxValues(/select field names/i, ['c']);
79+
setMultiSelectComboboxValues(new RegExp(MULTI_SELECT_LABEL, 'i'), ['c']);
7580
expect(onChange.lastCall.args[0]).to.equal(JSON.stringify({ _id: null }));
7681
expect(onChange.lastCall.args[1]).to.be.an.instanceOf(Error);
7782
});

packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/group/basic-group.tsx

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
import {
2-
Body,
3-
spacing,
4-
css,
5-
ComboboxWithCustomOption,
6-
} from '@mongodb-js/compass-components';
7-
import React, { useState, useMemo } from 'react';
1+
import { Body, spacing, css } from '@mongodb-js/compass-components';
2+
import React, { useState } from 'react';
83
import { mapFieldsToAccumulatorValue } from '../utils';
94
import type { WizardComponentProps } from '..';
5+
import { FieldCombobox } from '../field-combobox';
106

117
const containerStyles = css({
128
display: 'flex',
@@ -23,7 +19,6 @@ const mapGroupFormStateToStageValue = (formState: string[]) => {
2319
};
2420

2521
export const BasicGroup = ({ fields, onChange }: WizardComponentProps) => {
26-
const fieldNames = useMemo(() => fields.map(({ name }) => name), [fields]);
2722
const [groupFields, setGroupFields] = useState<string[]>([]);
2823

2924
const onChangeFields = (data: string[]) => {
@@ -38,18 +33,12 @@ export const BasicGroup = ({ fields, onChange }: WizardComponentProps) => {
3833
return (
3934
<div className={containerStyles}>
4035
<Body>Group documents based on</Body>
41-
<ComboboxWithCustomOption<true>
42-
placeholder={'Select field names'}
36+
<FieldCombobox
4337
className={comboboxStyles}
44-
aria-label={'Select field names'}
45-
size="default"
46-
clearable={true}
4738
multiselect={true}
4839
value={groupFields}
4940
onChange={onChangeFields}
50-
options={fieldNames}
51-
optionLabel="Field:"
52-
overflow="scroll-x"
41+
fields={fields}
5342
/>
5443
</div>
5544
);

0 commit comments

Comments
 (0)