21842: cached initial suggestions to preven re-loading
[arvados.git] / services / workbench2 / src / views-components / sharing-dialog / participant-select.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from 'react';
6 import { Autocomplete, AutocompleteCat } from 'components/autocomplete/autocomplete';
7 import { connect, DispatchProp } from 'react-redux';
8 import { ServiceRepository } from 'services/services';
9 import { FilterBuilder } from '../../services/api/filter-builder';
10 import { debounce } from 'debounce';
11 import { ListItemText, Typography } from '@mui/material';
12 import { noop } from 'lodash/fp';
13 import { GroupClass, GroupResource } from 'models/group';
14 import { getUserDetailsString, getUserDisplayName, UserResource } from 'models/user';
15 import { Resource, ResourceKind } from 'models/resource';
16 import { ListResults } from 'services/common-service/common-service';
17
18 export interface Participant {
19     name: string;
20     tooltip: string;
21     uuid: string;
22 }
23
24 type ParticipantResource = GroupResource | UserResource;
25
26 interface ParticipantSelectProps {
27     items: Participant[];
28     excludedParticipants?: string[];
29     label?: string;
30     autofocus?: boolean;
31     onlyPeople?: boolean;
32     onlyActive?: boolean;
33     disabled?: boolean;
34     category?: AutocompleteCat;
35
36     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
37     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
38     onCreate?: (person: Participant) => void;
39     onDelete?: (index: number) => void;
40     onSelect?: (person: Participant) => void;
41 }
42
43 interface ParticipantSelectState {
44     isWorking: boolean;
45     value: string;
46     suggestions: ParticipantResource[];
47     cachedSuggestions: ParticipantResource[];
48 }
49
50 const getDisplayName = (item: GroupResource | UserResource, detailed: boolean) => {
51     switch (item.kind) {
52         case ResourceKind.USER:
53             return getUserDisplayName(item, detailed, detailed);
54         case ResourceKind.GROUP:
55             return item.name + `(${`(${(item as Resource).uuid})`})`;
56         default:
57             return (item as Resource).uuid;
58     }
59 };
60
61 const getDisplayTooltip = (item: GroupResource | UserResource) => {
62     switch (item.kind) {
63         case ResourceKind.USER:
64             return getUserDetailsString(item);
65         case ResourceKind.GROUP:
66             return item.name + `(${`(${(item as Resource).uuid})`})`;
67         default:
68             return (item as Resource).uuid;
69     }
70 };
71
72 export const ParticipantSelect = connect()(
73     class ParticipantSelect extends React.Component<ParticipantSelectProps & DispatchProp, ParticipantSelectState> {
74         state: ParticipantSelectState = {
75             isWorking: false,
76             value: '',
77             suggestions: [],
78             cachedSuggestions: [],
79         };
80
81         componentDidUpdate(prevProps: ParticipantSelectProps & DispatchProp, prevState: ParticipantSelectState) {
82             if (prevState.suggestions.length === 0 && this.state.suggestions.length > 0 && this.state.value.length === 0) {
83                 this.setState({ cachedSuggestions: this.state.suggestions });
84             }
85         }
86
87         render() {
88             const { label = 'Add people and groups' } = this.props;
89
90             return (
91                 <Autocomplete
92                     label={label}
93                     value={this.state.value}
94                     items={this.props.items}
95                     suggestions={this.state.suggestions}
96                     autofocus={this.props.autofocus}
97                     onChange={this.handleChange}
98                     onCreate={this.handleCreate}
99                     onSelect={this.handleSelect}
100                     onDelete={this.props.onDelete && !this.props.disabled ? this.handleDelete : undefined}
101                     onFocus={this.props.onFocus || this.onFocus}
102                     onBlur={this.onBlur}
103                     renderChipValue={this.renderChipValue}
104                     renderChipTooltip={this.renderChipTooltip}
105                     renderSuggestion={this.renderSuggestion}
106                     category={this.props.category}
107                     isWorking={this.state.isWorking}
108                     maxLength={this.props.category === AutocompleteCat.SHARING ? 10 : undefined}
109                     disabled={this.props.disabled} />
110             );
111         }
112
113         onFocus = (e) => {
114             this.setState({ isWorking: true });
115             this.getSuggestions();
116         }
117
118         onBlur = (e) => {
119             if (this.props.onBlur) {
120                 this.props.onBlur(e);
121             }
122             setTimeout(() => this.setState({ value: '', suggestions: [] }), 200);
123         }
124
125         renderChipValue(chipValue: Participant) {
126             const { name, uuid } = chipValue;
127             return name || uuid;
128         }
129
130         renderChipTooltip(item: Participant) {
131             return item.tooltip;
132         }
133
134         renderSuggestion(item: ParticipantResource) {
135             return (
136                 <ListItemText>
137                     <Typography noWrap>{getDisplayName(item, true)}</Typography>
138                 </ListItemText>
139             );
140         }
141
142         handleDelete = (_: Participant, index: number) => {
143             const { onDelete = noop } = this.props;
144             onDelete(index);
145         }
146
147         handleCreate = () => {
148             const { onCreate } = this.props;
149             if (onCreate) {
150                 this.setState({ value: '', suggestions: [] });
151                 onCreate({
152                     name: '',
153                     tooltip: '',
154                     uuid: this.state.value,
155                 });
156             }
157         }
158
159         handleSelect = (selection: ParticipantResource) => {
160             if (!selection) return;
161             const { uuid } = selection;
162             const { onSelect = noop } = this.props;
163             this.setState({ value: '', suggestions: this.state.cachedSuggestions });
164             onSelect({
165                 name: getDisplayName(selection, false),
166                 tooltip: getDisplayTooltip(selection),
167                 uuid,
168             });
169         }
170
171         handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
172             this.setState({ value: event.target.value }, this.getSuggestions);
173         }
174
175         getSuggestions = debounce(() => this.props.dispatch<any>(this.requestSuggestions), 500);
176
177         requestSuggestions = async (_: void, __: void, { userService, groupsService }: ServiceRepository) => {
178             this.setState({ isWorking: true });
179             const { value } = this.state;
180             // +1 to see if there are more than 10 results
181             const limit = 11;
182
183             const filterUsers = new FilterBuilder()
184                 .addILike('any', value)
185                 .addEqual('is_active', this.props.onlyActive || undefined)
186                 .addNotIn('uuid', this.props.excludedParticipants)
187                 .getFilters();
188             const userItems: ListResults<any> = await userService.list({ filters: filterUsers, limit, count: "none" });
189
190             const filterGroups = new FilterBuilder()
191                 .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
192                 .addNotIn('uuid', this.props.excludedParticipants)
193                 .addILike('name', value)
194                 .getFilters();
195
196             const groupItems: ListResults<any> = await groupsService.list({ filters: filterGroups, limit, count: "none" });
197             this.setState({
198                 suggestions: this.props.onlyPeople
199                     ? userItems.items
200                     : userItems.items.concat(groupItems.items),
201                 isWorking: false,
202             });
203         }
204     });