blob: 24eebd70c8558fdd37318f7c1b867659ec675291 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001// Copyright 2021 The Chromium Authors
Copybara854996b2021-09-07 19:36:02 +00002// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, property, internalProperty} from 'lit-element';
6import React from 'react';
7import ReactDOM from 'react-dom';
8import deepEqual from 'deep-equal';
9
10import {AutocompleteChangeDetails, AutocompleteChangeReason}
11 from '@material-ui/core/Autocomplete';
12import {ThemeProvider, createTheme} from '@material-ui/core/styles';
13
14import {connectStore} from 'reducers/base.js';
15import * as projectV0 from 'reducers/projectV0.js';
16import * as userV0 from 'reducers/userV0.js';
17import {userRefsToDisplayNames} from 'shared/convertersV0.js';
18import {arrayDifference} from 'shared/helpers.js';
19
20import {ReactAutocomplete} from 'react/ReactAutocomplete.tsx';
21
22type Vocabulary = 'component' | 'label' | 'member' | 'owner' | 'project' | '';
23
24
25/**
26 * A normal text input enhanced by a panel of suggested options.
27 * `<mr-react-autocomplete>` wraps a React implementation of autocomplete
28 * in a web component, suitable for embedding in a LitElement component
29 * hierarchy. All parents must not use Shadow DOM. The supported autocomplete
30 * option types are defined in type Vocabulary.
31 */
32export class MrReactAutocomplete extends connectStore(LitElement) {
33 // Required properties passed in from the parent element.
34 /** The `<input id>` attribute. Called "label" to avoid name conflicts. */
35 @property() label: string = '';
36 /** The autocomplete option type. See type Vocabulary for the full list. */
37 @property() vocabularyName: Vocabulary = '';
38
39 // Optional properties passed in from the parent element.
40 /** The value (or values, if `multiple === true`). */
41 @property({
42 hasChanged: (oldVal, newVal) => !deepEqual(oldVal, newVal),
43 }) value?: string | string[] = undefined;
44 /** Values that show up as disabled chips. */
45 @property({
46 hasChanged: (oldVal, newVal) => !deepEqual(oldVal, newVal),
47 }) fixedValues: string[] = [];
48 /** A valid HTML 5 input type for the `input` element. */
49 @property() inputType: string = 'text';
50 /** True for chip input that takes multiple values, false for single input. */
51 @property() multiple: boolean = false;
52 /** Placeholder for the form input. */
53 @property() placeholder?: string = '';
54 /** Callback for input value changes. */
55 @property() onChange: (
56 event: React.SyntheticEvent,
57 newValue: string | string[] | null,
58 reason: AutocompleteChangeReason,
59 details?: AutocompleteChangeDetails
60 ) => void = () => {};
61
62 // Internal state properties from the Redux store.
63 @internalProperty() protected _components:
64 Map<string, ComponentDef> = new Map();
65 @internalProperty() protected _labels: Map<string, LabelDef> = new Map();
66 @internalProperty() protected _members:
67 {userRefs?: UserRef[], groupRefs?: UserRef[]} = {};
68 @internalProperty() protected _projects:
69 {contributorTo?: string[], memberOf?: string[], ownerOf?: string[]} = {};
70
71 /** @override */
72 createRenderRoot(): LitElement {
73 return this;
74 }
75
76 /** @override */
77 updated(changedProperties: Map<string | number | symbol, unknown>): void {
78 super.updated(changedProperties);
79
Adrià Vilanova Martínez535e7312021-10-17 00:48:12 +020080 const maxChipLabelWidth = '290px';
Copybara854996b2021-09-07 19:36:02 +000081 const theme = createTheme({
82 components: {
83 MuiChip: {
84 styleOverrides: {
Adrià Vilanova Martínez535e7312021-10-17 00:48:12 +020085 root: { fontSize: 13 },
86 label: {
87 textOverflow: 'ellipsis',
88 maxWidth: maxChipLabelWidth
89 }
Copybara854996b2021-09-07 19:36:02 +000090 },
91 },
92 },
93 palette: {
94 action: {disabledOpacity: 0.6},
95 primary: {
96 // Same as var(--chops-primary-accent-color).
97 main: '#1976d2',
98 },
99 },
100 typography: {fontSize: 11.375},
101 });
102 const element = <ThemeProvider theme={theme}>
103 <ReactAutocomplete
104 label={this.label}
105 options={this._options()}
106 value={this.value}
107 fixedValues={this.fixedValues}
108 inputType={this.inputType}
109 multiple={this.multiple}
110 placeholder={this.placeholder}
111 onChange={this.onChange}
112 getOptionDescription={this._getOptionDescription.bind(this)}
113 getOptionLabel={(option: string) => option}
114 />
115 </ThemeProvider>;
116 ReactDOM.render(element, this);
117 }
118
119 /** @override */
120 stateChanged(state: any): void {
121 super.stateChanged(state);
122
123 this._components = projectV0.componentsMap(state);
124 this._labels = projectV0.labelDefMap(state);
125 this._members = projectV0.viewedVisibleMembers(state);
126 this._projects = userV0.projects(state);
127 }
128
129 /**
130 * Computes which description belongs to given autocomplete option.
131 * Different data is shown depending on the autocomplete vocabulary.
132 * @param option The option to find a description for.
133 * @return The description for the option.
134 */
135 _getOptionDescription(option: string): string {
136 switch (this.vocabularyName) {
137 case 'component': {
138 const component = this._components.get(option);
139 return component && component.docstring || '';
140 } case 'label': {
141 const label = this._labels.get(option.toLowerCase());
142 return label && label.docstring || '';
143 } default: {
144 return '';
145 }
146 }
147 }
148
149 /**
150 * Computes the set of options used by the autocomplete instance.
151 * @return Array of strings that the user can try to match.
152 */
153 _options(): string[] {
154 switch (this.vocabularyName) {
155 case 'component': {
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200156 return [...this._components.values()].filter((c) => !c.deprecated).map((c) => c.path);
Copybara854996b2021-09-07 19:36:02 +0000157 } case 'label': {
158 // The label map keys are lowercase. Use the LabelDef label name instead.
159 return [...this._labels.values()].map((labelDef: LabelDef) => labelDef.label);
160 } case 'member': {
161 const {userRefs = []} = this._members;
162 const users = userRefsToDisplayNames(userRefs);
163 return users;
164 } case 'owner': {
165 const {userRefs = [], groupRefs = []} = this._members;
166 const users = userRefsToDisplayNames(userRefs);
167 const groups = userRefsToDisplayNames(groupRefs);
168 // Remove groups from the list of all members.
169 return arrayDifference(users, groups);
170 } case 'project': {
171 const {ownerOf = [], memberOf = [], contributorTo = []} = this._projects;
172 return [...ownerOf, ...memberOf, ...contributorTo];
173 } case '': {
174 return [];
175 } default: {
176 throw new Error(`Unknown vocabulary name: ${this.vocabularyName}`);
177 }
178 }
179 }
180}
181customElements.define('mr-react-autocomplete', MrReactAutocomplete);