Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/react/IssueWizard.css b/static_src/react/IssueWizard.css
new file mode 100644
index 0000000..46b1ff2
--- /dev/null
+++ b/static_src/react/IssueWizard.css
@@ -0,0 +1,18 @@
+.container {
+ margin-left: 50px;
+ max-width: 70vw;
+ width: 100%;
+ font-family: 'Poppins', serif;
+}
+
+.yellowBox {
+ height: 10vh;
+ border-style: solid;
+ border-color: #ea8600;
+ border-radius: 8px;
+ background: #fef7e0;
+}
+
+.poppins {
+ font-family: 'Poppins', serif;
+}
\ No newline at end of file
diff --git a/static_src/react/IssueWizard.test.tsx b/static_src/react/IssueWizard.test.tsx
new file mode 100644
index 0000000..07016ce
--- /dev/null
+++ b/static_src/react/IssueWizard.test.tsx
@@ -0,0 +1,19 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {assert} from 'chai';
+import {render} from '@testing-library/react';
+
+import {IssueWizard} from './IssueWizard.tsx';
+
+describe('IssueWizard', () => {
+ it('renders', async () => {
+ render(<IssueWizard />);
+
+ const stepper = document.getElementById("mobile-stepper")
+
+ assert.isNotNull(stepper);
+ });
+});
diff --git a/static_src/react/IssueWizard.tsx b/static_src/react/IssueWizard.tsx
new file mode 100644
index 0000000..de5e8fb
--- /dev/null
+++ b/static_src/react/IssueWizard.tsx
@@ -0,0 +1,67 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {ReactElement} from 'react';
+import * as React from 'react'
+import ReactDOM from 'react-dom';
+import styles from './IssueWizard.css';
+import DotMobileStepper from './issue-wizard/DotMobileStepper.tsx';
+import LandingStep from './issue-wizard/LandingStep.tsx';
+import DetailsStep from './issue-wizard/DetailsStep.tsx'
+
+/**
+ * Base component for the issue filing wizard, wrapper for other components.
+ * @return Issue wizard JSX.
+ */
+export function IssueWizard(): ReactElement {
+ const [checkExisting, setCheckExisting] = React.useState(false);
+ const [userType, setUserType] = React.useState('End User');
+ const [activeStep, setActiveStep] = React.useState(0);
+ const [category, setCategory] = React.useState('');
+ const [textValues, setTextValues] = React.useState(
+ {
+ oneLineSummary: '',
+ stepsToReproduce: '',
+ describeProblem: '',
+ additionalComments: ''
+ });
+
+ let nextEnabled;
+ let page;
+ if (activeStep === 0){
+ page = <LandingStep
+ checkExisting={checkExisting}
+ setCheckExisting={setCheckExisting}
+ userType={userType}
+ setUserType={setUserType}
+ category={category}
+ setCategory={setCategory}
+ />;
+ nextEnabled = checkExisting && userType && (category != '');
+ } else if (activeStep === 1){
+ page = <DetailsStep textValues={textValues} setTextValues={setTextValues} category={category}/>;
+ nextEnabled = (textValues.oneLineSummary.trim() !== '') &&
+ (textValues.stepsToReproduce.trim() !== '') &&
+ (textValues.describeProblem.trim() !== '');
+ }
+
+ return (
+ <>
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Poppins"></link>
+ <div className={styles.container}>
+ {page}
+ <DotMobileStepper nextEnabled={nextEnabled} activeStep={activeStep} setActiveStep={setActiveStep}/>
+ </div>
+ </>
+ );
+}
+
+/**
+ * Renders the issue filing wizard page.
+ * @param mount HTMLElement that the React component should be
+ * added to.
+ */
+export function renderWizard(mount: HTMLElement): void {
+ ReactDOM.render(<IssueWizard />, mount);
+}
diff --git a/static_src/react/ReactAutocomplete.test.tsx b/static_src/react/ReactAutocomplete.test.tsx
new file mode 100644
index 0000000..a1e7c62
--- /dev/null
+++ b/static_src/react/ReactAutocomplete.test.tsx
@@ -0,0 +1,311 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import {assert} from 'chai';
+import React from 'react';
+import sinon from 'sinon';
+import {fireEvent, render} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import {ReactAutocomplete, MAX_AUTOCOMPLETE_OPTIONS}
+ from './ReactAutocomplete.tsx';
+
+/**
+ * Cleans autocomplete dropdown from the DOM for the next test.
+ * @param input The autocomplete element to remove the dropdown for.
+ */
+ const cleanAutocomplete = (input: ReactAutocomplete) => {
+ fireEvent.change(input, {target: {value: ''}});
+ fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+};
+
+xdescribe('ReactAutocomplete', () => {
+ it('renders', async () => {
+ const {container} = render(<ReactAutocomplete label="cool" options={[]} />);
+
+ assert.isNotNull(container.querySelector('input'));
+ });
+
+ it('placeholder renders', async () => {
+ const {container} = render(<ReactAutocomplete
+ placeholder="penguins"
+ options={['']}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ assert.strictEqual(input?.placeholder, 'penguins');
+ });
+
+ it('filterOptions empty input value', async () => {
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={['option 1 label']}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ assert.strictEqual(input?.value, '');
+
+ fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+ assert.strictEqual(input?.value, '');
+ });
+
+ it('filterOptions truncates values', async () => {
+ const options = [];
+
+ // a0@test.com, a1@test.com, a2@test.com, ...
+ for (let i = 0; i <= MAX_AUTOCOMPLETE_OPTIONS; i++) {
+ options.push(`a${i}@test.com`);
+ }
+
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={options}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ userEvent.type(input, 'a');
+
+ const results = document.querySelectorAll('.autocomplete-option');
+
+ assert.equal(results.length, MAX_AUTOCOMPLETE_OPTIONS);
+
+ // Clean up autocomplete dropdown from the DOM for the next test.
+ cleanAutocomplete(input);
+ });
+
+ it('filterOptions label matching', async () => {
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={['option 1 label']}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ assert.strictEqual(input?.value, '');
+
+ userEvent.type(input, 'lab');
+ assert.strictEqual(input?.value, 'lab');
+
+ fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+
+ assert.strictEqual(input?.value, 'option 1 label');
+ });
+
+ it('filterOptions description matching', async () => {
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ getOptionDescription={() => 'penguin apples'}
+ options={['lol']}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ assert.strictEqual(input?.value, '');
+
+ userEvent.type(input, 'app');
+ assert.strictEqual(input?.value, 'app');
+
+ fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+ assert.strictEqual(input?.value, 'lol');
+ });
+
+ it('filterOptions no match', async () => {
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={[]}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ assert.strictEqual(input?.value, '');
+
+ userEvent.type(input, 'foobar');
+ assert.strictEqual(input?.value, 'foobar');
+
+ fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+ assert.strictEqual(input?.value, 'foobar');
+ });
+
+ it('onChange callback is called', async () => {
+ const onChangeStub = sinon.stub();
+
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={[]}
+ onChange={onChangeStub}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ sinon.assert.notCalled(onChangeStub);
+
+ userEvent.type(input, 'foobar');
+ sinon.assert.notCalled(onChangeStub);
+
+ fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+ sinon.assert.calledOnce(onChangeStub);
+
+ assert.equal(onChangeStub.getCall(0).args[1], 'foobar');
+ });
+
+ it('onChange excludes fixed values', async () => {
+ const onChangeStub = sinon.stub();
+
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={['cute owl']}
+ multiple={true}
+ fixedValues={['immortal penguin']}
+ onChange={onChangeStub}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+ fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+
+ sinon.assert.calledWith(onChangeStub, sinon.match.any, []);
+ });
+
+ it('pressing space creates new chips', async () => {
+ const onChangeStub = sinon.stub();
+
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={['cute owl']}
+ multiple={true}
+ onChange={onChangeStub}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ sinon.assert.notCalled(onChangeStub);
+
+ userEvent.type(input, 'foobar');
+ sinon.assert.notCalled(onChangeStub);
+
+ fireEvent.keyDown(input, {key: ' ', code: 'Space'});
+ sinon.assert.calledOnce(onChangeStub);
+
+ assert.deepEqual(onChangeStub.getCall(0).args[1], ['foobar']);
+ });
+
+ it('_renderOption shows user input', async () => {
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={['cute@owl.com']}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ userEvent.type(input, 'ow');
+
+ const options = document.querySelectorAll('.autocomplete-option');
+
+ // Options: cute@owl.com
+ assert.deepEqual(options.length, 1);
+ assert.equal(options[0].textContent, 'cute@owl.com');
+
+ cleanAutocomplete(input);
+ });
+
+ it('_renderOption hides duplicate user input', async () => {
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={['cute@owl.com']}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ userEvent.type(input, 'cute@owl.com');
+
+ const options = document.querySelectorAll('.autocomplete-option');
+
+ // Options: cute@owl.com
+ assert.equal(options.length, 1);
+
+ assert.equal(options[0].textContent, 'cute@owl.com');
+
+ cleanAutocomplete(input);
+ });
+
+ it('_renderOption highlights matching text', async () => {
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ options={['cute@owl.com']}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ userEvent.type(input, 'ow');
+
+ const option = document.querySelector('.autocomplete-option');
+ const match = option?.querySelector('strong');
+
+ assert.isNotNull(match);
+ assert.equal(match?.innerText, 'ow');
+
+ // Description is not rendered.
+ assert.equal(option?.querySelectorAll('span').length, 1);
+ assert.equal(option?.querySelectorAll('strong').length, 1);
+
+ cleanAutocomplete(input);
+ });
+
+ it('_renderOption highlights matching description', async () => {
+ const {container} = render(<ReactAutocomplete
+ label="cool"
+ getOptionDescription={() => 'penguin of-doom'}
+ options={['cute owl']}
+ />);
+
+ const input = container.querySelector('input');
+ assert.isNotNull(input);
+ if (!input) return;
+
+ userEvent.type(input, 'do');
+
+ const option = document.querySelector('.autocomplete-option');
+ const match = option?.querySelector('strong');
+
+ assert.isNotNull(match);
+ assert.equal(match?.innerText, 'do');
+
+ assert.equal(option?.querySelectorAll('span').length, 2);
+ assert.equal(option?.querySelectorAll('strong').length, 1);
+
+ cleanAutocomplete(input);
+ });
+
+ it('_renderTags disables fixedValues', async () => {
+ // TODO(crbug.com/monorail/9393): Add this test once we have a way to stub
+ // out dependent components.
+ });
+});
diff --git a/static_src/react/ReactAutocomplete.tsx b/static_src/react/ReactAutocomplete.tsx
new file mode 100644
index 0000000..27fdc32
--- /dev/null
+++ b/static_src/react/ReactAutocomplete.tsx
@@ -0,0 +1,276 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+
+import {FilterOptionsState} from '@material-ui/core';
+import Autocomplete, {
+ AutocompleteChangeDetails, AutocompleteChangeReason,
+ AutocompleteRenderGetTagProps, AutocompleteRenderInputParams,
+ AutocompleteRenderOptionState,
+} from '@material-ui/core/Autocomplete';
+import Chip, {ChipProps} from '@material-ui/core/Chip';
+import TextField from '@material-ui/core/TextField';
+import {Value} from '@material-ui/core/useAutocomplete';
+
+export const MAX_AUTOCOMPLETE_OPTIONS = 100;
+
+interface AutocompleteProps<T> {
+ label: string;
+ options: T[];
+ value?: Value<T, boolean, false, true>;
+ fixedValues?: T[];
+ inputType?: React.InputHTMLAttributes<unknown>['type'];
+ multiple?: boolean;
+ placeholder?: string;
+ onChange?: (
+ event: React.SyntheticEvent,
+ value: Value<T, boolean, false, true>,
+ reason: AutocompleteChangeReason,
+ details?: AutocompleteChangeDetails<T>
+ ) => void;
+ getOptionDescription?: (option: T) => string;
+ getOptionLabel?: (option: T) => string;
+}
+
+/**
+ * A wrapper around Material UI Autocomplete that customizes and extends it for
+ * Monorail's theme and options. Adds support for:
+ * - Fixed values that render as disabled chips.
+ * - Option descriptions that render alongside the option labels.
+ * - Matching on word boundaries in both the labels and descriptions.
+ * - Highlighting of the matching substrings.
+ * @return Autocomplete instance with Monorail-specific properties set.
+ */
+export function ReactAutocomplete<T>(
+ {
+ label, options, value = undefined, fixedValues = [], inputType = 'text',
+ multiple = false, placeholder = '', onChange = () => {},
+ getOptionDescription = () => '', getOptionLabel = (o) => String(o)
+ }: AutocompleteProps<T>
+): React.ReactNode {
+ value = value || (multiple ? [] : '');
+
+ return <Autocomplete
+ id={label}
+ autoHighlight
+ autoSelect
+ filterOptions={_filterOptions(getOptionDescription)}
+ filterSelectedOptions={multiple}
+ freeSolo
+ getOptionLabel={getOptionLabel}
+ multiple={multiple}
+ onChange={_onChange(fixedValues, multiple, onChange)}
+ onKeyDown={_onKeyDown}
+ options={options}
+ renderInput={_renderInput(inputType, placeholder)}
+ renderOption={_renderOption(getOptionDescription, getOptionLabel)}
+ renderTags={_renderTags(fixedValues, getOptionLabel)}
+ style={{width: 'var(--mr-edit-field-width)'}}
+ value={multiple ? [...fixedValues, ...value] : value}
+ />;
+}
+
+/**
+ * Modifies the default option matching behavior to match on all Regex word
+ * boundaries and to match on both label and description.
+ * @param getOptionDescription Function to get the description for an option.
+ * @return The text for a given option.
+ */
+function _filterOptions<T>(getOptionDescription: (option: T) => string) {
+ return (
+ options: T[],
+ {inputValue, getOptionLabel}: FilterOptionsState<T>
+ ): T[] => {
+ if (!inputValue.length) {
+ return [];
+ }
+ const regex = _matchRegex(inputValue);
+ const predicate = (option: T) => {
+ return getOptionLabel(option).match(regex) ||
+ getOptionDescription(option).match(regex);
+ }
+ return options.filter(predicate).slice(0, MAX_AUTOCOMPLETE_OPTIONS);
+ }
+}
+
+/**
+ * Computes an onChange handler for Autocomplete. Adds logic to make sure
+ * fixedValues are preserved and wraps whatever onChange handler the parent
+ * passed in.
+ * @param fixedValues Values that display in the edit field but can't be
+ * edited by the user. Usually set by filter rules in Monorail.
+ * @param multiple Whether this input takes multiple values or not.
+ * @param onChange onChange property passed in by parent, used to sync value
+ * changes to parent.
+ * @return Function that's run on Autocomplete changes.
+ */
+function _onChange<T, Multiple, DisableClearable, FreeSolo>(
+ fixedValues: T[],
+ multiple: Multiple,
+ onChange: (
+ event: React.SyntheticEvent,
+ value: Value<T, Multiple, DisableClearable, FreeSolo>,
+ reason: AutocompleteChangeReason,
+ details?: AutocompleteChangeDetails<T>
+ ) => void,
+) {
+ return (
+ event: React.SyntheticEvent,
+ newValue: Value<T, Multiple, DisableClearable, FreeSolo>,
+ reason: AutocompleteChangeReason,
+ details?: AutocompleteChangeDetails<T>
+ ): void => {
+ // Ensure that fixed values can't be removed.
+ if (multiple) {
+ newValue = newValue.filter((option: T) => !fixedValues.includes(option));
+ }
+
+ // Propagate onChange callback.
+ onChange(event, newValue, reason, details);
+ }
+}
+
+/**
+ * Custom keydown handler.
+ * @param e Keyboard event.
+ */
+function _onKeyDown(e: React.KeyboardEvent) {
+ // Convert spaces to Enter events to allow users to type space to create new
+ // chips.
+ if (e.key === ' ') {
+ e.key = 'Enter';
+ }
+}
+
+/**
+ * @param inputType A valid HTML 5 input type for the `input` element.
+ * @param placeholder Placeholder text for the input.
+ * @return A function that renders the input element used by
+ * ReactAutocomplete.
+ */
+function _renderInput(inputType = 'text', placeholder = ''):
+ (params: AutocompleteRenderInputParams) => React.ReactNode {
+ return (params: AutocompleteRenderInputParams): React.ReactNode =>
+ <TextField
+ {...params} variant="standard" size="small"
+ type={inputType} placeholder={placeholder}
+ />;
+}
+
+/**
+ * Renders a single instance of an option for Autocomplete.
+ * @param getOptionDescription Function to get the description text shown.
+ * @param getOptionLabel Function to get the name of the option shown to the
+ * user.
+ * @return ReactNode containing the JSX to be rendered.
+ */
+function _renderOption<T>(
+ getOptionDescription: (option: T) => string,
+ getOptionLabel: (option: T) => string
+): React.ReactNode {
+ return (
+ props: React.HTMLAttributes<HTMLLIElement>,
+ option: T,
+ {inputValue}: AutocompleteRenderOptionState
+ ): React.ReactNode => {
+ // Render the option label.
+ const label = getOptionLabel(option);
+ const matchValue = label.match(_matchRegex(inputValue));
+ let optionTemplate = <>{label}</>;
+ if (matchValue) {
+ // Highlight the matching text.
+ optionTemplate = <>
+ {matchValue[1]}
+ <strong>{matchValue[2]}</strong>
+ {matchValue[3]}
+ </>;
+ }
+
+ // Render the option description.
+ const description = getOptionDescription(option);
+ const matchDescription =
+ description && description.match(_matchRegex(inputValue));
+ let descriptionTemplate = <>{description}</>;
+ if (matchDescription) {
+ // Highlight the matching text.
+ descriptionTemplate = <>
+ {matchDescription[1]}
+ <strong>{matchDescription[2]}</strong>
+ {matchDescription[3]}
+ </>;
+ }
+
+ // Put the label and description together into one <li>.
+ return <li
+ {...props}
+ className={`${props.className} autocomplete-option`}
+ style={{display: 'flex', flexDirection: 'row', wordWrap: 'break-word'}}
+ >
+ <span style={{display: 'block', width: (description ? '40%' : '100%')}}>
+ {optionTemplate}
+ </span>
+ {description &&
+ <span style={{display: 'block', boxSizing: 'border-box',
+ paddingLeft: '8px', width: '60%'}}>
+ {descriptionTemplate}
+ </span>
+ }
+ </li>;
+ };
+}
+
+/**
+ * Helper to render the Chips elements used by Autocomplete. Ensures that
+ * fixedValues are disabled.
+ * @param fixedValues Undeleteable values in an issue usually set by filter
+ * rules.
+ * @param getOptionLabel Function to compute text for the option.
+ * @return Function to render the ReactNode for all the chips.
+ */
+function _renderTags<T>(
+ fixedValues: T[], getOptionLabel: (option: T) => string
+) {
+ return (
+ value: T[],
+ getTagProps: AutocompleteRenderGetTagProps
+ ): React.ReactNode => {
+ return value.map((option, index) => {
+ const props: ChipProps = {...getTagProps({index})};
+ const disabled = fixedValues.includes(option);
+ if (disabled) {
+ delete props.onDelete;
+ }
+
+ const label = getOptionLabel(option);
+ return <Chip
+ {...props}
+ key={label}
+ label={label}
+ disabled={disabled}
+ size="small"
+ />;
+ });
+ }
+}
+
+/**
+ * Generates a RegExp to match autocomplete values.
+ * @param needle The string the user is searching for.
+ * @return A RegExp to find matching values.
+ */
+function _matchRegex(needle: string): RegExp {
+ // This code copied from ac.js.
+ // Since we use needle to build a regular expression, we need to escape RE
+ // characters. We match '-', '{', '$' and others in the needle and convert
+ // them into "\-", "\{", "\$".
+ const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
+ const modifiedPrefix = needle.replace(regexForRegexCharacters, '\\$1');
+
+ // Match the modifiedPrefix anywhere as long as it is either at the very
+ // beginning "Th" -> "The Hobbit", or comes immediately after a word separator
+ // such as "Ga" -> "The-Great-Gatsby".
+ const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
+ return new RegExp(patternRegex, 'i' /* ignore case */);
+}
diff --git a/static_src/react/issue-wizard/DetailsStep.test.tsx b/static_src/react/issue-wizard/DetailsStep.test.tsx
new file mode 100644
index 0000000..eaef0e7
--- /dev/null
+++ b/static_src/react/issue-wizard/DetailsStep.test.tsx
@@ -0,0 +1,34 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {render, cleanup} from '@testing-library/react';
+import {assert} from 'chai';
+
+import DetailsStep from './DetailsStep.tsx';
+
+describe('DetailsStep', () => {
+ afterEach(cleanup);
+
+ it('renders', async () => {
+ const {container} = render(<DetailsStep />);
+
+ // this is checking for the first question
+ const input = container.querySelector('input');
+ assert.isNotNull(input)
+
+ // this is checking for the rest
+ const count = document.querySelectorAll('textarea').length;
+ assert.equal(count, 3)
+ });
+
+ it('renders category in title', async () => {
+ const {container} = render(<DetailsStep category='UI'/>);
+
+ // this is checking the title contains our category
+ const title = container.querySelector('h2');
+ assert.include(title?.innerText, 'Details for problems with UI');
+ });
+
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/DetailsStep.tsx b/static_src/react/issue-wizard/DetailsStep.tsx
new file mode 100644
index 0000000..1a69cc1
--- /dev/null
+++ b/static_src/react/issue-wizard/DetailsStep.tsx
@@ -0,0 +1,65 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {createStyles, createTheme} from '@material-ui/core/styles';
+import {makeStyles} from '@material-ui/styles';
+import TextField from '@material-ui/core/TextField';
+import {red, grey} from '@material-ui/core/colors';
+
+/**
+ * The detail step is the second step on the dot
+ * stepper. This react component provides the users with
+ * specific questions about their bug to be filled out.
+ */
+const theme: Theme = createTheme();
+
+const useStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ root: {
+ '& > *': {
+ margin: theme.spacing(1),
+ width: '100%',
+ },
+ },
+ head: {
+ marginTop: '25px',
+ },
+ red: {
+ color: red[600],
+ },
+ grey: {
+ color: grey[600],
+ },
+ }), {defaultTheme: theme}
+);
+
+export default function DetailsStep({textValues, setTextValues, category}:
+ {textValues: Object, setTextValues: Function, category: string}): React.ReactElement {
+ const classes = useStyles();
+
+ const handleChange = (valueName: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
+ const textInput = e.target.value;
+ setTextValues({...textValues, [valueName]: textInput});
+ };
+
+ return (
+ <>
+ <h2 className={classes.grey}>Details for problems with {category}</h2>
+ <form className={classes.root} noValidate autoComplete="off">
+ <h3 className={classes.head}>Please enter a one line summary <span className={classes.red}>*</span></h3>
+ <TextField id="outlined-basic-1" variant="outlined" onChange={handleChange('oneLineSummary')}/>
+
+ <h3 className={classes.head}>Steps to reproduce problem <span className={classes.red}>*</span></h3>
+ <TextField multiline rows={4} id="outlined-basic-2" variant="outlined" onChange={handleChange('stepsToReproduce')}/>
+
+ <h3 className={classes.head}>Please describe the problem <span className={classes.red}>*</span></h3>
+ <TextField multiline rows={3} id="outlined-basic-3" variant="outlined" onChange={handleChange('describeProblem')}/>
+
+ <h3 className={classes.head}>Additional Comments</h3>
+ <TextField multiline rows={3} id="outlined-basic-4" variant="outlined" onChange={handleChange('additionalComments')}/>
+ </form>
+ </>
+ );
+}
diff --git a/static_src/react/issue-wizard/DotMobileStepper.test.tsx b/static_src/react/issue-wizard/DotMobileStepper.test.tsx
new file mode 100644
index 0000000..5203110
--- /dev/null
+++ b/static_src/react/issue-wizard/DotMobileStepper.test.tsx
@@ -0,0 +1,59 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {render, screen, cleanup} from '@testing-library/react';
+import {assert} from 'chai';
+
+import DotMobileStepper from './DotMobileStepper.tsx';
+
+describe('DotMobileStepper', () => {
+ let container: HTMLElement;
+
+ afterEach(cleanup);
+
+ it('renders', () => {
+ container = render(<DotMobileStepper activeStep={0} nextEnabled={true}/>).container;
+
+ // this is checking the buttons for the stepper rendered
+ const count = document.querySelectorAll('button').length;
+ assert.equal(count, 2)
+ });
+
+ it('back button disabled on first step', () => {
+ render(<DotMobileStepper activeStep={0} nextEnabled={true}/>).container;
+
+ // Finds a button on the page with "back" as text using React testing library.
+ const backButton = screen.getByRole('button', {name: /backButton/i}) as HTMLButtonElement;
+
+ // Back button is disabled on the first step.
+ assert.isTrue(backButton.disabled);
+ });
+
+ it('both buttons enabled on second step', () => {
+ render(<DotMobileStepper activeStep={1} nextEnabled={true}/>).container;
+
+ // Finds a button on the page with "back" as text using React testing library.
+ const backButton = screen.getByRole('button', {name: /backButton/i}) as HTMLButtonElement;
+
+ // Finds a button on the page with "next" as text using React testing library.
+ const nextButton = screen.getByRole('button', {name: /nextButton/i}) as HTMLButtonElement;
+
+ // Back button is not disabled on the second step.
+ assert.isFalse(backButton.disabled);
+
+ // Next button is not disabled on the second step.
+ assert.isFalse(nextButton.disabled);
+ });
+
+ it('next button disabled on last step', () => {
+ render(<DotMobileStepper activeStep={2}/>).container;
+
+ // Finds a button on the page with "next" as text using React testing library.
+ const nextButton = screen.getByRole('button', {name: /nextButton/i}) as HTMLButtonElement;
+
+ // Next button is disabled on the second step.
+ assert.isTrue(nextButton.disabled);
+ });
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/DotMobileStepper.tsx b/static_src/react/issue-wizard/DotMobileStepper.tsx
new file mode 100644
index 0000000..9870f03
--- /dev/null
+++ b/static_src/react/issue-wizard/DotMobileStepper.tsx
@@ -0,0 +1,72 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {createTheme} from '@material-ui/core/styles';
+import {makeStyles} from '@material-ui/styles';
+import MobileStepper from '@material-ui/core/MobileStepper';
+import Button from '@material-ui/core/Button';
+import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
+import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
+
+const theme: Theme = createTheme();
+
+const useStyles = makeStyles({
+ root: {
+ width: '100%',
+ flexGrow: 1,
+ },
+}, {defaultTheme: theme});
+
+/**
+ * `<DotMobileStepper />`
+ *
+ * React component for rendering the linear dot stepper of the issue wizard.
+ *
+ * @return ReactElement.
+ */
+export default function DotsMobileStepper({nextEnabled, activeStep, setActiveStep} : {nextEnabled: boolean, activeStep: number, setActiveStep: Function}) : React.ReactElement {
+ const classes = useStyles();
+
+ const handleNext = () => {
+ setActiveStep((prevActiveStep: number) => prevActiveStep + 1);
+ };
+
+ const handleBack = () => {
+ setActiveStep((prevActiveStep: number) => prevActiveStep - 1);
+ };
+
+ let label;
+ let icon;
+
+ if (activeStep === 2){
+ label = 'Submit';
+ icon = '';
+ } else {
+ label = 'Next';
+ icon = <KeyboardArrowRight />;
+ }
+ return (
+ <MobileStepper
+ id="mobile-stepper"
+ variant="dots"
+ steps={3}
+ position="static"
+ activeStep={activeStep}
+ className={classes.root}
+ nextButton={
+ <Button aria-label="nextButton" size="medium" onClick={handleNext} disabled={activeStep === 2 || !nextEnabled}>
+ {label}
+ {icon}
+ </Button>
+ }
+ backButton={
+ <Button aria-label="backButton" size="medium" onClick={handleBack} disabled={activeStep === 0}>
+ <KeyboardArrowLeft />
+ Back
+ </Button>
+ }
+ />
+ );
+}
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/LandingStep.tsx b/static_src/react/issue-wizard/LandingStep.tsx
new file mode 100644
index 0000000..efe6491
--- /dev/null
+++ b/static_src/react/issue-wizard/LandingStep.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import {makeStyles, withStyles} from '@material-ui/styles';
+import {blue, yellow, red, grey} from '@material-ui/core/colors';
+import FormControlLabel from '@material-ui/core/FormControlLabel';
+import Checkbox, {CheckboxProps} from '@material-ui/core/Checkbox';
+import SelectMenu from './SelectMenu.tsx';
+import RadioDescription from './RadioDescription.tsx';
+
+const CustomCheckbox = withStyles({
+ root: {
+ color: blue[400],
+ '&$checked': {
+ color: blue[600],
+ },
+ },
+ checked: {},
+})((props: CheckboxProps) => <Checkbox color="default" {...props} />);
+
+const useStyles = makeStyles({
+ pad: {
+ margin: '10px, 20px',
+ display: 'inline-block',
+ },
+ flex: {
+ display: 'flex',
+ },
+ inlineBlock: {
+ display: 'inline-block',
+ },
+ warningBox: {
+ minHeight: '10vh',
+ borderStyle: 'solid',
+ borderWidth: '2px',
+ borderColor: yellow[800],
+ borderRadius: '8px',
+ background: yellow[50],
+ padding: '0px 20px 1em',
+ margin: '30px 0px'
+ },
+ warningHeader: {
+ color: yellow[800],
+ fontSize: '16px',
+ fontWeight: '500',
+ },
+ star:{
+ color: red[700],
+ marginRight: '8px',
+ fontSize: '16px',
+ display: 'inline-block',
+ },
+ header: {
+ color: grey[900],
+ fontSize: '28px',
+ marginTop: '6vh',
+ },
+ subheader: {
+ color: grey[700],
+ fontSize: '18px',
+ lineHeight: '32px',
+ },
+ red: {
+ color: red[600],
+ },
+});
+
+export default function LandingStep({checkExisting, setCheckExisting, userType, setUserType, category, setCategory}:
+ {checkExisting: boolean, setCheckExisting: Function, userType: string, setUserType: Function, category: string, setCategory: Function}) {
+ const classes = useStyles();
+
+ const handleCheckChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ setCheckExisting(event.target.checked);
+ };
+
+ return (
+ <>
+ <p className={classes.header}>Report an issue with Chromium</p>
+ <p className={classes.subheader}>
+ We want you to enter the best possible issue report so that the project team members
+ can act on it effectively. The following steps will help route your issue to the correct
+ people.
+ </p>
+ <p className={classes.subheader}>
+ Please select your following role: <span className={classes.red}>*</span>
+ </p>
+ <RadioDescription value={userType} setValue={setUserType}/>
+ <div className={classes.subheader}>
+ Which of the following best describes the issue that you are reporting? <span className={classes.red}>*</span>
+ </div>
+ <SelectMenu option={category} setOption={setCategory}/>
+ <div className={classes.warningBox}>
+ <p className={classes.warningHeader}> Avoid duplicate issue reports:</p>
+ <div>
+ <div className={classes.star}>*</div>
+ <FormControlLabel className={classes.pad}
+ control={
+ <CustomCheckbox
+ checked={checkExisting}
+ onChange={handleCheckChange}
+ name="warningCheck"
+ />
+ }
+ label="By checking this box, I'm acknowledging that I have searched for existing issues that already report this problem."
+ />
+ </div>
+ </div>
+ </>
+ );
+}
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/RadioDescription.test.tsx b/static_src/react/issue-wizard/RadioDescription.test.tsx
new file mode 100644
index 0000000..ff65eae
--- /dev/null
+++ b/static_src/react/issue-wizard/RadioDescription.test.tsx
@@ -0,0 +1,54 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {render, screen, cleanup} from '@testing-library/react';
+import userEvent from '@testing-library/user-event'
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import RadioDescription from './RadioDescription.tsx';
+
+describe('RadioDescription', () => {
+ afterEach(cleanup);
+
+ it('renders', () => {
+ render(<RadioDescription />);
+ // look for blue radios
+ const radioOne = screen.getByRole('radio', {name: /Web Developer/i});
+ assert.isNotNull(radioOne)
+
+ const radioTwo = screen.getByRole('radio', {name: /End User/i});
+ assert.isNotNull(radioTwo)
+
+ const radioThree = screen.getByRole('radio', {name: /Chromium Contributor/i});
+ assert.isNotNull(radioThree)
+ });
+
+ it('checks selected radio value', () => {
+ // We're passing in the "Web Developer" value here manually
+ // to tell our code that that radio button is selected.
+ render(<RadioDescription value={'Web Developer'} />);
+
+ const checkedRadio = screen.getByRole('radio', {name: /Web Developer/i});
+ assert.isTrue(checkedRadio.checked);
+
+ // Extra check to make sure we haven't checked every single radio button.
+ const uncheckedRadio = screen.getByRole('radio', {name: /End User/i});
+ assert.isFalse(uncheckedRadio.checked);
+ });
+
+ it('sets radio value when clicked', () => {
+ // Using the sinon.js testing library to create a function for testing.
+ const setValue = sinon.stub();
+
+ render(<RadioDescription setValue={setValue} />);
+
+ const radio = screen.getByRole('radio', {name: /Web Developer/i});
+ userEvent.click(radio);
+
+ // Asserts that "Web Developer" was passed into our "setValue" function.
+ sinon.assert.calledWith(setValue, 'Web Developer');
+ });
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/RadioDescription.tsx b/static_src/react/issue-wizard/RadioDescription.tsx
new file mode 100644
index 0000000..ad78c78
--- /dev/null
+++ b/static_src/react/issue-wizard/RadioDescription.tsx
@@ -0,0 +1,117 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {makeStyles, withStyles} from '@material-ui/styles';
+import {blue, grey} from '@material-ui/core/colors';
+import Radio, {RadioProps} from '@material-ui/core/Radio';
+
+const userGroups = Object.freeze({
+ END_USER: 'End User',
+ WEB_DEVELOPER: 'Web Developer',
+ CONTRIBUTOR: 'Chromium Contributor',
+});
+
+const BlueRadio = withStyles({
+ root: {
+ color: blue[400],
+ '&$checked': {
+ color: blue[600],
+ },
+ },
+ checked: {},
+})((props: RadioProps) => <Radio color="default" {...props} />);
+
+const useStyles = makeStyles({
+ flex: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ },
+ container: {
+ width: '320px',
+ height: '150px',
+ position: 'relative',
+ display: 'inline-block',
+ },
+ text: {
+ position: 'absolute',
+ display: 'inline-block',
+ left: '55px',
+ },
+ title: {
+ marginTop: '7px',
+ fontSize: '20px',
+ color: grey[900],
+ },
+ subheader: {
+ fontSize: '16px',
+ color: grey[800],
+ },
+ line: {
+ position: 'absolute',
+ bottom: 0,
+ width: '300px',
+ left: '20px',
+ }
+});
+
+/**
+ * `<RadioDescription />`
+ *
+ * React component for radio buttons and their descriptions
+ * on the landing step of the Issue Wizard.
+ *
+ * @return ReactElement.
+ */
+export default function RadioDescription({value, setValue} : {value: string, setValue: Function}): React.ReactElement {
+ const classes = useStyles();
+
+ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ setValue(event.target.value);
+ };
+
+ return (
+ <div className={classes.flex}>
+ <div className={classes.container}>
+ <BlueRadio
+ checked={value === userGroups.END_USER}
+ onChange={handleChange}
+ value={userGroups.END_USER}
+ inputProps={{ 'aria-label': userGroups.END_USER}}
+ />
+ <div className={classes.text}>
+ <p className={classes.title}>{userGroups.END_USER}</p>
+ <p className={classes.subheader}>I am a user trying to do something on a website.</p>
+ </div>
+ <hr color={grey[200]} className={classes.line}/>
+ </div>
+ <div className={classes.container}>
+ <BlueRadio
+ checked={value === userGroups.WEB_DEVELOPER}
+ onChange={handleChange}
+ value={userGroups.WEB_DEVELOPER}
+ inputProps={{ 'aria-label': userGroups.WEB_DEVELOPER }}
+ />
+ <div className={classes.text}>
+ <p className={classes.title}>{userGroups.WEB_DEVELOPER}</p>
+ <p className={classes.subheader}>I am a web developer trying to build something.</p>
+ </div>
+ <hr color={grey[200]} className={classes.line}/>
+ </div>
+ <div className={classes.container}>
+ <BlueRadio
+ checked={value === userGroups.CONTRIBUTOR}
+ onChange={handleChange}
+ value={userGroups.CONTRIBUTOR}
+ inputProps={{ 'aria-label': userGroups.CONTRIBUTOR }}
+ />
+ <div className={classes.text}>
+ <p className={classes.title}>{userGroups.CONTRIBUTOR}</p>
+ <p className={classes.subheader}>I know about a problem in specific tests or code.</p>
+ </div>
+ <hr color={grey[200]} className={classes.line}/>
+ </div>
+ </div>
+ );
+ }
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/SelectMenu.test.tsx b/static_src/react/issue-wizard/SelectMenu.test.tsx
new file mode 100644
index 0000000..13efef6
--- /dev/null
+++ b/static_src/react/issue-wizard/SelectMenu.test.tsx
@@ -0,0 +1,38 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {render} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {screen} from '@testing-library/dom';
+import {assert} from 'chai';
+
+import SelectMenu from './SelectMenu.tsx';
+
+describe('SelectMenu', () => {
+ let container: React.RenderResult;
+
+ beforeEach(() => {
+ container = render(<SelectMenu />).container;
+ });
+
+ it('renders', () => {
+ const form = container.querySelector('form');
+ assert.isNotNull(form)
+ });
+
+ it('renders options on click', () => {
+ const input = document.getElementById('outlined-select-category');
+ if (!input) {
+ throw new Error('Input is undefined');
+ }
+
+ userEvent.click(input)
+
+ // 14 is the current number of options in the select menu
+ const count = screen.getAllByTestId('select-menu-item').length;
+
+ assert.equal(count, 14);
+ });
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/SelectMenu.tsx b/static_src/react/issue-wizard/SelectMenu.tsx
new file mode 100644
index 0000000..3b0b96d
--- /dev/null
+++ b/static_src/react/issue-wizard/SelectMenu.tsx
@@ -0,0 +1,133 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {createTheme, Theme} from '@material-ui/core/styles';
+import {makeStyles} from '@material-ui/styles';
+import MenuItem from '@material-ui/core/MenuItem';
+import TextField from '@material-ui/core/TextField';
+
+const CATEGORIES = [
+ {
+ value: 'UI',
+ label: 'UI',
+ },
+ {
+ value: 'Accessibility',
+ label: 'Accessibility',
+ },
+ {
+ value: 'Network/Downloading',
+ label: 'Network/Downloading',
+ },
+ {
+ value: 'Audio/Video',
+ label: 'Audio/Video',
+ },
+ {
+ value: 'Content',
+ label: 'Content',
+ },
+ {
+ value: 'Apps',
+ label: 'Apps',
+ },
+ {
+ value: 'Extensions/Themes',
+ label: 'Extensions/Themes',
+ },
+ {
+ value: 'Webstore',
+ label: 'Webstore',
+ },
+ {
+ value: 'Sync',
+ label: 'Sync',
+ },
+ {
+ value: 'Enterprise',
+ label: 'Enterprise',
+ },
+ {
+ value: 'Installation',
+ label: 'Installation',
+ },
+ {
+ value: 'Crashes',
+ label: 'Crashes',
+ },
+ {
+ value: 'Security',
+ label: 'Security',
+ },
+ {
+ value: 'Other',
+ label: 'Other',
+ },
+];
+
+const theme: Theme = createTheme();
+
+const useStyles = makeStyles((theme: Theme) => ({
+ container: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ maxWidth: '65%',
+ },
+ textField: {
+ marginLeft: theme.spacing(1),
+ marginRight: theme.spacing(1),
+ },
+ menu: {
+ width: '100%',
+ minWidth: '300px',
+ },
+}), {defaultTheme: theme});
+
+/**
+ * Select menu component that is located on the landing step if the
+ * Issue Wizard. The menu is used for the user to indicate the category
+ * of their bug when filing an issue.
+ *
+ * @return ReactElement.
+ */
+export default function SelectMenu({option, setOption}: {option: string, setOption: Function}) {
+ const classes = useStyles();
+ const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
+ setOption(event.target.value as string);
+ };
+
+ return (
+ <form className={classes.container} noValidate autoComplete="off">
+ <TextField
+ id="outlined-select-category"
+ select
+ label=''
+ className={classes.textField}
+ value={option}
+ onChange={handleChange}
+ InputLabelProps={{shrink: false}}
+ SelectProps={{
+ MenuProps: {
+ className: classes.menu,
+ },
+ }}
+ margin="normal"
+ variant="outlined"
+ fullWidth={true}
+ >
+ {CATEGORIES.map(option => (
+ <MenuItem
+ className={classes.menu}
+ key={option.value}
+ value={option.value}
+ data-testid="select-menu-item"
+ >
+ {option.label}
+ </MenuItem>
+ ))}
+ </TextField>
+ </form>
+ );
+}
\ No newline at end of file
diff --git a/static_src/react/mr-react-autocomplete.test.ts b/static_src/react/mr-react-autocomplete.test.ts
new file mode 100644
index 0000000..8553c36
--- /dev/null
+++ b/static_src/react/mr-react-autocomplete.test.ts
@@ -0,0 +1,158 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import {MrReactAutocomplete} from './mr-react-autocomplete.tsx';
+
+let element: MrReactAutocomplete;
+
+describe('mr-react-autocomplete', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-react-autocomplete');
+ element.vocabularyName = 'member';
+ document.body.appendChild(element);
+
+ sinon.stub(element, 'stateChanged');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrReactAutocomplete);
+ });
+
+ it('ReactDOM renders on update', async () => {
+ element.value = 'Penguin Island';
+
+ await element.updateComplete;
+
+ const input = element.querySelector('input');
+
+ assert.equal(input?.value, 'Penguin Island');
+ });
+
+ it('does not update on new copies of the same values', async () => {
+ element.fixedValues = ['test'];
+ element.value = ['hah'];
+
+ sinon.spy(element, 'updated');
+
+ await element.updateComplete;
+ sinon.assert.calledOnce(element.updated);
+
+ element.fixedValues = ['test'];
+ element.value = ['hah'];
+
+ await element.updateComplete;
+ sinon.assert.calledOnce(element.updated);
+ });
+
+ it('_getOptionDescription with component vocabulary gets docstring', () => {
+ element.vocabularyName = 'component';
+ element._components = new Map([['Infra>UI', {docstring: 'Test docs'}]]);
+ element._labels = new Map([['M-84', {docstring: 'Test label docs'}]]);
+
+ assert.equal(element._getOptionDescription('Infra>UI'), 'Test docs');
+ assert.equal(element._getOptionDescription('M-84'), '');
+ assert.equal(element._getOptionDescription('NoMatch'), '');
+ });
+
+ it('_getOptionDescription with label vocabulary gets docstring', () => {
+ element.vocabularyName = 'label';
+ element._components = new Map([['Infra>UI', {docstring: 'Test docs'}]]);
+ element._labels = new Map([['m-84', {docstring: 'Test label docs'}]]);
+
+ assert.equal(element._getOptionDescription('Infra>UI'), '');
+ assert.equal(element._getOptionDescription('M-84'), 'Test label docs');
+ assert.equal(element._getOptionDescription('NoMatch'), '');
+ });
+
+ it('_getOptionDescription with other vocabulary gets empty docstring', () => {
+ element.vocabularyName = 'owner';
+ element._components = new Map([['Infra>UI', {docstring: 'Test docs'}]]);
+ element._labels = new Map([['M-84', {docstring: 'Test label docs'}]]);
+
+ assert.equal(element._getOptionDescription('Infra>UI'), '');
+ assert.equal(element._getOptionDescription('M-84'), '');
+ assert.equal(element._getOptionDescription('NoMatch'), '');
+ });
+
+ it('_options gets component names', () => {
+ element.vocabularyName = 'component';
+ element._components = new Map([
+ ['Infra>UI', {docstring: 'Test docs'}],
+ ['Bird>Penguin', {docstring: 'Test docs'}],
+ ]);
+
+ assert.deepEqual(element._options(), ['Infra>UI', 'Bird>Penguin']);
+ });
+
+ it('_options gets label names', () => {
+ element.vocabularyName = 'label';
+ element._labels = new Map([
+ ['M-84', {label: 'm-84', docstring: 'Test docs'}],
+ ['Restrict-View-Bagel', {label: 'restrict-VieW-bAgEl', docstring: 'T'}],
+ ]);
+
+ assert.deepEqual(element._options(), ['m-84', 'restrict-VieW-bAgEl']);
+ });
+
+ it('_options gets member names with groups', () => {
+ element.vocabularyName = 'member';
+ element._members = {
+ userRefs: [
+ {displayName: 'penguin@island.com'},
+ {displayName: 'google@monorail.com'},
+ {displayName: 'group@birds.com'},
+ ],
+ groupRefs: [{displayName: 'group@birds.com'}],
+ };
+
+ assert.deepEqual(element._options(),
+ ['penguin@island.com', 'google@monorail.com', 'group@birds.com']);
+ });
+
+ it('_options gets owner names without groups', () => {
+ element.vocabularyName = 'owner';
+ element._members = {
+ userRefs: [
+ {displayName: 'penguin@island.com'},
+ {displayName: 'google@monorail.com'},
+ {displayName: 'group@birds.com'},
+ ],
+ groupRefs: [{displayName: 'group@birds.com'}],
+ };
+
+ assert.deepEqual(element._options(),
+ ['penguin@island.com', 'google@monorail.com']);
+ });
+
+ it('_options gets owner names without groups', () => {
+ element.vocabularyName = 'project';
+ element._projects = {
+ ownerOf: ['penguins'],
+ memberOf: ['birds'],
+ contributorTo: ['canary', 'owl-island'],
+ };
+
+ assert.deepEqual(element._options(),
+ ['penguins', 'birds', 'canary', 'owl-island']);
+ });
+
+ it('_options gives empty array for empty vocabulary name', () => {
+ element.vocabularyName = '';
+ assert.deepEqual(element._options(), []);
+ });
+
+ it('_options throws error on unknown vocabulary', () => {
+ element.vocabularyName = 'whatever';
+
+ assert.throws(element._options.bind(element),
+ 'Unknown vocabulary name: whatever');
+ });
+});
diff --git a/static_src/react/mr-react-autocomplete.tsx b/static_src/react/mr-react-autocomplete.tsx
new file mode 100644
index 0000000..8cc5f84
--- /dev/null
+++ b/static_src/react/mr-react-autocomplete.tsx
@@ -0,0 +1,176 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, property, internalProperty} from 'lit-element';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import deepEqual from 'deep-equal';
+
+import {AutocompleteChangeDetails, AutocompleteChangeReason}
+ from '@material-ui/core/Autocomplete';
+import {ThemeProvider, createTheme} from '@material-ui/core/styles';
+
+import {connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {userRefsToDisplayNames} from 'shared/convertersV0.js';
+import {arrayDifference} from 'shared/helpers.js';
+
+import {ReactAutocomplete} from 'react/ReactAutocomplete.tsx';
+
+type Vocabulary = 'component' | 'label' | 'member' | 'owner' | 'project' | '';
+
+
+/**
+ * A normal text input enhanced by a panel of suggested options.
+ * `<mr-react-autocomplete>` wraps a React implementation of autocomplete
+ * in a web component, suitable for embedding in a LitElement component
+ * hierarchy. All parents must not use Shadow DOM. The supported autocomplete
+ * option types are defined in type Vocabulary.
+ */
+export class MrReactAutocomplete extends connectStore(LitElement) {
+ // Required properties passed in from the parent element.
+ /** The `<input id>` attribute. Called "label" to avoid name conflicts. */
+ @property() label: string = '';
+ /** The autocomplete option type. See type Vocabulary for the full list. */
+ @property() vocabularyName: Vocabulary = '';
+
+ // Optional properties passed in from the parent element.
+ /** The value (or values, if `multiple === true`). */
+ @property({
+ hasChanged: (oldVal, newVal) => !deepEqual(oldVal, newVal),
+ }) value?: string | string[] = undefined;
+ /** Values that show up as disabled chips. */
+ @property({
+ hasChanged: (oldVal, newVal) => !deepEqual(oldVal, newVal),
+ }) fixedValues: string[] = [];
+ /** A valid HTML 5 input type for the `input` element. */
+ @property() inputType: string = 'text';
+ /** True for chip input that takes multiple values, false for single input. */
+ @property() multiple: boolean = false;
+ /** Placeholder for the form input. */
+ @property() placeholder?: string = '';
+ /** Callback for input value changes. */
+ @property() onChange: (
+ event: React.SyntheticEvent,
+ newValue: string | string[] | null,
+ reason: AutocompleteChangeReason,
+ details?: AutocompleteChangeDetails
+ ) => void = () => {};
+
+ // Internal state properties from the Redux store.
+ @internalProperty() protected _components:
+ Map<string, ComponentDef> = new Map();
+ @internalProperty() protected _labels: Map<string, LabelDef> = new Map();
+ @internalProperty() protected _members:
+ {userRefs?: UserRef[], groupRefs?: UserRef[]} = {};
+ @internalProperty() protected _projects:
+ {contributorTo?: string[], memberOf?: string[], ownerOf?: string[]} = {};
+
+ /** @override */
+ createRenderRoot(): LitElement {
+ return this;
+ }
+
+ /** @override */
+ updated(changedProperties: Map<string | number | symbol, unknown>): void {
+ super.updated(changedProperties);
+
+ const theme = createTheme({
+ components: {
+ MuiChip: {
+ styleOverrides: {
+ root: {fontSize: 13},
+ },
+ },
+ },
+ palette: {
+ action: {disabledOpacity: 0.6},
+ primary: {
+ // Same as var(--chops-primary-accent-color).
+ main: '#1976d2',
+ },
+ },
+ typography: {fontSize: 11.375},
+ });
+ const element = <ThemeProvider theme={theme}>
+ <ReactAutocomplete
+ label={this.label}
+ options={this._options()}
+ value={this.value}
+ fixedValues={this.fixedValues}
+ inputType={this.inputType}
+ multiple={this.multiple}
+ placeholder={this.placeholder}
+ onChange={this.onChange}
+ getOptionDescription={this._getOptionDescription.bind(this)}
+ getOptionLabel={(option: string) => option}
+ />
+ </ThemeProvider>;
+ ReactDOM.render(element, this);
+ }
+
+ /** @override */
+ stateChanged(state: any): void {
+ super.stateChanged(state);
+
+ this._components = projectV0.componentsMap(state);
+ this._labels = projectV0.labelDefMap(state);
+ this._members = projectV0.viewedVisibleMembers(state);
+ this._projects = userV0.projects(state);
+ }
+
+ /**
+ * Computes which description belongs to given autocomplete option.
+ * Different data is shown depending on the autocomplete vocabulary.
+ * @param option The option to find a description for.
+ * @return The description for the option.
+ */
+ _getOptionDescription(option: string): string {
+ switch (this.vocabularyName) {
+ case 'component': {
+ const component = this._components.get(option);
+ return component && component.docstring || '';
+ } case 'label': {
+ const label = this._labels.get(option.toLowerCase());
+ return label && label.docstring || '';
+ } default: {
+ return '';
+ }
+ }
+ }
+
+ /**
+ * Computes the set of options used by the autocomplete instance.
+ * @return Array of strings that the user can try to match.
+ */
+ _options(): string[] {
+ switch (this.vocabularyName) {
+ case 'component': {
+ return [...this._components.keys()];
+ } case 'label': {
+ // The label map keys are lowercase. Use the LabelDef label name instead.
+ return [...this._labels.values()].map((labelDef: LabelDef) => labelDef.label);
+ } case 'member': {
+ const {userRefs = []} = this._members;
+ const users = userRefsToDisplayNames(userRefs);
+ return users;
+ } case 'owner': {
+ const {userRefs = [], groupRefs = []} = this._members;
+ const users = userRefsToDisplayNames(userRefs);
+ const groups = userRefsToDisplayNames(groupRefs);
+ // Remove groups from the list of all members.
+ return arrayDifference(users, groups);
+ } case 'project': {
+ const {ownerOf = [], memberOf = [], contributorTo = []} = this._projects;
+ return [...ownerOf, ...memberOf, ...contributorTo];
+ } case '': {
+ return [];
+ } default: {
+ throw new Error(`Unknown vocabulary name: ${this.vocabularyName}`);
+ }
+ }
+ }
+}
+customElements.define('mr-react-autocomplete', MrReactAutocomplete);