blob: c9a17ff3d74dd5f52a8a724b522593a38b9d404c [file] [log] [blame]
// 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 maxChipLabelWidth = '290px';
const theme = createTheme({
components: {
MuiChip: {
styleOverrides: {
root: { fontSize: 13 },
label: {
textOverflow: 'ellipsis',
maxWidth: maxChipLabelWidth
}
},
},
},
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.values()].filter((c) => !c.deprecated).map((c) => c.path);
} 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);