blob: 5101ff84304eada81c71ee357cb03ed714c93423 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {combineReducers} from 'redux';
6import {createSelector} from 'reselect';
7import {createReducer, createRequestReducer} from './redux-helpers.js';
8import * as permissions from 'reducers/permissions.js';
9import {fieldTypes, SITEWIDE_DEFAULT_COLUMNS, defaultIssueFieldMap,
10 parseColSpec, stringValuesForIssueField} from 'shared/issue-fields.js';
11import {hasPrefix, removePrefix} from 'shared/helpers.js';
12import {fieldNameToLabelPrefix,
13 labelNameToLabelPrefixes, labelNameToLabelValue, fieldDefToName,
14 restrictionLabelsForPermissions} from 'shared/convertersV0.js';
15import {prpcClient} from 'prpc-client-instance.js';
16import 'shared/typedef.js';
17
18/** @typedef {import('redux').AnyAction} AnyAction */
19
20// Actions
21export const SELECT = 'projectV0/SELECT';
22
23const FETCH_CONFIG_START = 'projectV0/FETCH_CONFIG_START';
24export const FETCH_CONFIG_SUCCESS = 'projectV0/FETCH_CONFIG_SUCCESS';
25const FETCH_CONFIG_FAILURE = 'projectV0/FETCH_CONFIG_FAILURE';
26
27export const FETCH_PRESENTATION_CONFIG_START =
28 'projectV0/FETCH_PRESENTATION_CONFIG_START';
29export const FETCH_PRESENTATION_CONFIG_SUCCESS =
30 'projectV0/FETCH_PRESENTATION_CONFIG_SUCCESS';
31export const FETCH_PRESENTATION_CONFIG_FAILURE =
32 'projectV0/FETCH_PRESENTATION_CONFIG_FAILURE';
33
34export const FETCH_CUSTOM_PERMISSIONS_START =
35 'projectV0/FETCH_CUSTOM_PERMISSIONS_START';
36export const FETCH_CUSTOM_PERMISSIONS_SUCCESS =
37 'projectV0/FETCH_CUSTOM_PERMISSIONS_SUCCESS';
38export const FETCH_CUSTOM_PERMISSIONS_FAILURE =
39 'projectV0/FETCH_CUSTOM_PERMISSIONS_FAILURE';
40
41
42export const FETCH_VISIBLE_MEMBERS_START =
43 'projectV0/FETCH_VISIBLE_MEMBERS_START';
44export const FETCH_VISIBLE_MEMBERS_SUCCESS =
45 'projectV0/FETCH_VISIBLE_MEMBERS_SUCCESS';
46export const FETCH_VISIBLE_MEMBERS_FAILURE =
47 'projectV0/FETCH_VISIBLE_MEMBERS_FAILURE';
48
49const FETCH_TEMPLATES_START = 'projectV0/FETCH_TEMPLATES_START';
50export const FETCH_TEMPLATES_SUCCESS = 'projectV0/FETCH_TEMPLATES_SUCCESS';
51const FETCH_TEMPLATES_FAILURE = 'projectV0/FETCH_TEMPLATES_FAILURE';
52
53/* State Shape
54{
55 name: string,
56
57 configs: Object<string, Config>,
58 presentationConfigs: Object<string, PresentationConfig>,
59 customPermissions: Object<string, Array<string>>,
60 visibleMembers:
61 Object<string, {userRefs: Array<UserRef>, groupRefs: Array<UserRef>}>,
62 templates: Object<string, Array<TemplateDef>>,
63 presentationConfigsLoaded: Object<string, boolean>,
64
65 requests: {
66 fetchConfig: ReduxRequestState,
67 fetchMembers: ReduxRequestState
68 fetchCustomPermissions: ReduxRequestState,
69 fetchPresentationConfig: ReduxRequestState,
70 fetchTemplates: ReduxRequestState,
71 },
72}
73*/
74
75// Reducers
76export const nameReducer = createReducer(null, {
77 [SELECT]: (_state, {projectName}) => projectName,
78});
79
80export const configsReducer = createReducer({}, {
81 [FETCH_CONFIG_SUCCESS]: (state, {projectName, config}) => ({
82 ...state,
83 [projectName]: config,
84 }),
85});
86
87export const presentationConfigsReducer = createReducer({}, {
88 [FETCH_PRESENTATION_CONFIG_SUCCESS]:
89 (state, {projectName, presentationConfig}) => ({
90 ...state,
91 [projectName]: presentationConfig,
92 }),
93});
94
95/**
96 * Adds custom permissions to Redux in a normalized state.
97 * @param {Object<string, Array<String>>} state Redux state.
98 * @param {AnyAction} Action
99 * @return {Object<string, Array<String>>}
100 */
101export const customPermissionsReducer = createReducer({}, {
102 [FETCH_CUSTOM_PERMISSIONS_SUCCESS]:
103 (state, {projectName, permissions}) => ({
104 ...state,
105 [projectName]: permissions,
106 }),
107});
108
109export const visibleMembersReducer = createReducer({}, {
110 [FETCH_VISIBLE_MEMBERS_SUCCESS]: (state, {projectName, visibleMembers}) => ({
111 ...state,
112 [projectName]: visibleMembers,
113 }),
114});
115
116export const templatesReducer = createReducer({}, {
117 [FETCH_TEMPLATES_SUCCESS]: (state, {projectName, templates}) => ({
118 ...state,
119 [projectName]: templates,
120 }),
121});
122
123const requestsReducer = combineReducers({
124 fetchConfig: createRequestReducer(
125 FETCH_CONFIG_START, FETCH_CONFIG_SUCCESS, FETCH_CONFIG_FAILURE),
126 fetchMembers: createRequestReducer(
127 FETCH_VISIBLE_MEMBERS_START,
128 FETCH_VISIBLE_MEMBERS_SUCCESS,
129 FETCH_VISIBLE_MEMBERS_FAILURE),
130 fetchCustomPermissions: createRequestReducer(
131 FETCH_CUSTOM_PERMISSIONS_START,
132 FETCH_CUSTOM_PERMISSIONS_SUCCESS,
133 FETCH_CUSTOM_PERMISSIONS_FAILURE),
134 fetchPresentationConfig: createRequestReducer(
135 FETCH_PRESENTATION_CONFIG_START,
136 FETCH_PRESENTATION_CONFIG_SUCCESS,
137 FETCH_PRESENTATION_CONFIG_FAILURE),
138 fetchTemplates: createRequestReducer(
139 FETCH_TEMPLATES_START, FETCH_TEMPLATES_SUCCESS, FETCH_TEMPLATES_FAILURE),
140});
141
142export const reducer = combineReducers({
143 name: nameReducer,
144 configs: configsReducer,
145 customPermissions: customPermissionsReducer,
146 presentationConfigs: presentationConfigsReducer,
147 visibleMembers: visibleMembersReducer,
148 templates: templatesReducer,
149 requests: requestsReducer,
150});
151
152// Selectors
153export const project = (state) => state.projectV0 || {};
154
155export const viewedProjectName =
156 createSelector(project, (project) => project.name || null);
157
158export const configs =
159 createSelector(project, (project) => project.configs || {});
160export const presentationConfigs =
161 createSelector(project, (project) => project.presentationConfigs || {});
162export const customPermissions =
163 createSelector(project, (project) => project.customPermissions || {});
164export const visibleMembers =
165 createSelector(project, (project) => project.visibleMembers || {});
166export const templates =
167 createSelector(project, (project) => project.templates || {});
168
169export const viewedConfig = createSelector(
170 [viewedProjectName, configs],
171 (projectName, configs) => configs[projectName] || {});
172export const viewedPresentationConfig = createSelector(
173 [viewedProjectName, presentationConfigs],
174 (projectName, configs) => configs[projectName] || {});
175
176// TODO(crbug.com/monorail/7080): Come up with a more clear and
177// consistent pattern for determining when data is loaded.
178export const viewedPresentationConfigLoaded = createSelector(
179 [viewedProjectName, presentationConfigs],
180 (projectName, configs) => !!configs[projectName]);
181export const viewedCustomPermissions = createSelector(
182 [viewedProjectName, customPermissions],
183 (projectName, permissions) => permissions[projectName] || []);
184export const viewedVisibleMembers = createSelector(
185 [viewedProjectName, visibleMembers],
186 (projectName, visibleMembers) => visibleMembers[projectName] || {});
187export const viewedTemplates = createSelector(
188 [viewedProjectName, templates],
189 (projectName, templates) => templates[projectName] || []);
190
191/**
192 * Get the default columns for the currently viewed project.
193 */
194export const defaultColumns = createSelector(viewedPresentationConfig,
195 ({defaultColSpec}) =>{
196 if (defaultColSpec) {
197 return parseColSpec(defaultColSpec);
198 }
199 return SITEWIDE_DEFAULT_COLUMNS;
200 });
201
202
203/**
204 * Get the default query for the currently viewed project.
205 */
206export const defaultQuery = createSelector(viewedPresentationConfig,
207 (config) => config.defaultQuery || '');
208
209// Look up components by path.
210export const componentsMap = createSelector(
211 viewedConfig,
212 (config) => {
213 if (!config || !config.componentDefs) return new Map();
214 const acc = new Map();
215 for (const v of config.componentDefs) {
216 acc.set(v.path, v);
217 }
218 return acc;
219 },
220);
221
222export const fieldDefs = createSelector(
223 viewedConfig, (config) => ((config && config.fieldDefs) || []),
224);
225
226export const fieldDefMap = createSelector(
227 fieldDefs, (fieldDefs) => {
228 const map = new Map();
229 fieldDefs.forEach((fd) => {
230 map.set(fd.fieldRef.fieldName.toLowerCase(), fd);
231 });
232 return map;
233 },
234);
235
236export const labelDefs = createSelector(
237 [viewedConfig, viewedCustomPermissions],
238 (config, permissions) => [
239 ...((config && config.labelDefs) || []),
240 ...restrictionLabelsForPermissions(permissions),
241 ],
242);
243
244// labelDefs stored in an easily findable format with label names as keys.
245export const labelDefMap = createSelector(
246 labelDefs, (labelDefs) => {
247 const map = new Map();
248 labelDefs.forEach((ld) => {
249 map.set(ld.label.toLowerCase(), ld);
250 });
251 return map;
252 },
253);
254
255/**
256 * A selector that builds a map where keys are label prefixes
257 * and values equal to sets of possible values corresponding to the prefix
258 * @param {Object} state Current Redux state.
259 * @return {Map}
260 */
261export const labelPrefixValueMap = createSelector(labelDefs, (labelDefs) => {
262 const prefixMap = new Map();
263 labelDefs.forEach((ld) => {
264 const prefixes = labelNameToLabelPrefixes(ld.label);
265
266 prefixes.forEach((prefix) => {
267 if (prefixMap.has(prefix)) {
268 prefixMap.get(prefix).add(labelNameToLabelValue(ld.label, prefix));
269 } else {
270 prefixMap.set(prefix, new Set(
271 [labelNameToLabelValue(ld.label, prefix)]));
272 }
273 });
274 });
275
276 return prefixMap;
277});
278
279/**
280 * A selector that builds an array of label prefixes, keeping casing intact
281 * Some labels are implicitly used as custom fields in the grid and list view.
282 * Only labels with more than one option are included, to reduce noise.
283 * @param {Object} state Current Redux state.
284 * @return {Array}
285 */
286export const labelPrefixFields = createSelector(
287 labelPrefixValueMap, (map) => {
288 const prefixes = [];
289
290 map.forEach((options, prefix) => {
291 // Ignore label prefixes with only one value.
292 if (options.size > 1) {
293 prefixes.push(prefix);
294 }
295 });
296
297 return prefixes;
298 },
299);
300
301/**
302 * A selector that wraps labelPrefixFields arrays as set for fast lookup.
303 * @param {Object} state Current Redux state.
304 * @return {Set}
305 */
306export const labelPrefixSet = createSelector(
307 labelPrefixFields, (fields) => new Set(fields.map(
308 (field) => field.toLowerCase())),
309);
310
311export const enumFieldDefs = createSelector(
312 fieldDefs,
313 (fieldDefs) => {
314 return fieldDefs.filter(
315 (fd) => fd.fieldRef.type === fieldTypes.ENUM_TYPE);
316 },
317);
318
319/**
320 * A selector that builds a function that's used to compute the value of
321 * a given field name on a given issue. This function abstracts the difference
322 * between custom fields, built-in fields, and implicit fields created
323 * from labels and considers these values in the context of the current
324 * project configuration.
325 * @param {Object} state Current Redux state.
326 * @return {function(Issue, string): Array<string>} A function that processes a
327 * given issue and field name to find the string value for that field, in
328 * the issue.
329 */
330export const extractFieldValuesFromIssue = createSelector(
331 viewedProjectName,
332 (projectName) => (issue, fieldName) =>
333 stringValuesForIssueField(issue, fieldName, projectName),
334);
335
336/**
337 * A selector that builds a function that's used to compute the type of a given
338 * field name.
339 * @param {Object} state Current Redux state.
340 * @return {function(string): string}
341 */
342export const extractTypeForFieldName = createSelector(fieldDefMap,
343 (fieldDefMap) => {
344 return (fieldName) => {
345 const key = fieldName.toLowerCase();
346
347 // If the field is a built in field. Default fields have precedence
348 // over custom fields.
349 if (defaultIssueFieldMap.hasOwnProperty(key)) {
350 return defaultIssueFieldMap[key].type;
351 }
352
353 // If the field is a custom field. Custom fields have precedence
354 // over label prefixes.
355 if (fieldDefMap.has(key)) {
356 return fieldDefMap.get(key).fieldRef.type;
357 }
358
359 // Default to STR_TYPE, including for label fields.
360 return fieldTypes.STR_TYPE;
361 };
362 },
363);
364
365export const optionsPerEnumField = createSelector(
366 enumFieldDefs,
367 labelDefs,
368 (fieldDefs, labelDefs) => {
369 const map = new Map(fieldDefs.map(
370 (fd) => [fd.fieldRef.fieldName.toLowerCase(), []]));
371 labelDefs.forEach((ld) => {
372 const labelName = ld.label;
373
374 const fd = fieldDefs.find((fd) => hasPrefix(
375 labelName, fieldNameToLabelPrefix(fd.fieldRef.fieldName)));
376 if (fd) {
377 const key = fd.fieldRef.fieldName.toLowerCase();
378 map.get(key).push({
379 ...ld,
380 optionName: removePrefix(labelName,
381 fieldNameToLabelPrefix(fd.fieldRef.fieldName)),
382 });
383 }
384 });
385 return map;
386 },
387);
388
389export const fieldDefsForPhases = createSelector(
390 fieldDefs,
391 (fieldDefs) => {
392 if (!fieldDefs) return [];
393 return fieldDefs.filter((f) => f.isPhaseField);
394 },
395);
396
397export const fieldDefsByApprovalName = createSelector(
398 fieldDefs,
399 (fieldDefs) => {
400 if (!fieldDefs) return new Map();
401 const acc = new Map();
402 for (const fd of fieldDefs) {
403 if (fd.fieldRef && fd.fieldRef.approvalName) {
404 if (acc.has(fd.fieldRef.approvalName)) {
405 acc.get(fd.fieldRef.approvalName).push(fd);
406 } else {
407 acc.set(fd.fieldRef.approvalName, [fd]);
408 }
409 }
410 }
411 return acc;
412 },
413);
414
415export const fetchingConfig = (state) => {
416 return state.projectV0.requests.fetchConfig.requesting;
417};
418
419/**
420 * Shorthand method for detecting whether we are currently
421 * fetching presentationConcifg
422 * @param {Object} state Current Redux state.
423 * @return {boolean}
424 */
425export const fetchingPresentationConfig = (state) => {
426 return state.projectV0.requests.fetchPresentationConfig.requesting;
427};
428
429// Action Creators
430/**
431 * Action creator to set the currently viewed Project.
432 * @param {string} projectName The name of the Project to select.
433 * @return {function(function): Promise<void>}
434 */
435export const select = (projectName) => {
436 return (dispatch) => dispatch({type: SELECT, projectName});
437};
438
439/**
440 * Fetches data required to view project.
441 * @param {string} projectName
442 * @return {function(function): Promise<void>}
443 */
444export const fetch = (projectName) => async (dispatch) => {
445 const configPromise = dispatch(fetchConfig(projectName));
446 const visibleMembersPromise = dispatch(fetchVisibleMembers(projectName));
447
448 dispatch(fetchPresentationConfig(projectName));
449 dispatch(fetchTemplates(projectName));
450
451 const customPermissionsPromise = dispatch(
452 fetchCustomPermissions(projectName));
453
454 // TODO(crbug.com/monorail/5828): Remove window.TKR_populateAutocomplete once
455 // the old autocomplete code is deprecated.
456 const [config, visibleMembers, customPermissions] = await Promise.all([
457 configPromise,
458 visibleMembersPromise,
459 customPermissionsPromise]);
460 config.labelDefs = [...config.labelDefs,
461 ...restrictionLabelsForPermissions(customPermissions)];
462 dispatch(fetchFieldPerms(config.projectName, config.fieldDefs));
463 // eslint-disable-next-line new-cap
464 window.TKR_populateAutocomplete(config, visibleMembers, customPermissions);
465};
466
467/**
468 * Fetches project configuration including things like the custom fields in a
469 * project, the statuses, etc.
470 * @param {string} projectName
471 * @return {function(function): Promise<Config>}
472 */
473const fetchConfig = (projectName) => async (dispatch) => {
474 dispatch({type: FETCH_CONFIG_START});
475
476 const getConfig = prpcClient.call(
477 'monorail.Projects', 'GetConfig', {projectName});
478
479 try {
480 const config = await getConfig;
481 dispatch({type: FETCH_CONFIG_SUCCESS, projectName, config});
482 return config;
483 } catch (error) {
484 dispatch({type: FETCH_CONFIG_FAILURE, error});
485 }
486};
487
488export const fetchPresentationConfig = (projectName) => async (dispatch) => {
489 dispatch({type: FETCH_PRESENTATION_CONFIG_START});
490
491 try {
492 const presentationConfig = await prpcClient.call(
493 'monorail.Projects', 'GetPresentationConfig', {projectName});
494 dispatch({
495 type: FETCH_PRESENTATION_CONFIG_SUCCESS,
496 projectName,
497 presentationConfig,
498 });
499 } catch (error) {
500 dispatch({type: FETCH_PRESENTATION_CONFIG_FAILURE, error});
501 }
502};
503
504/**
505 * Fetches custom permissions defined for a project.
506 * @param {string} projectName
507 * @return {function(function): Promise<Array<string>>}
508 */
509export const fetchCustomPermissions = (projectName) => async (dispatch) => {
510 dispatch({type: FETCH_CUSTOM_PERMISSIONS_START});
511
512 try {
513 const {permissions} = await prpcClient.call(
514 'monorail.Projects', 'GetCustomPermissions', {projectName});
515 dispatch({
516 type: FETCH_CUSTOM_PERMISSIONS_SUCCESS,
517 projectName,
518 permissions,
519 });
520 return permissions;
521 } catch (error) {
522 dispatch({type: FETCH_CUSTOM_PERMISSIONS_FAILURE, error});
523 }
524};
525
526/**
527 * Fetches the project members that the user is able to view.
528 * @param {string} projectName
529 * @return {function(function): Promise<GetVisibleMembersResponse>}
530 */
531export const fetchVisibleMembers = (projectName) => async (dispatch) => {
532 dispatch({type: FETCH_VISIBLE_MEMBERS_START});
533
534 try {
535 const visibleMembers = await prpcClient.call(
536 'monorail.Projects', 'GetVisibleMembers', {projectName});
537 dispatch({
538 type: FETCH_VISIBLE_MEMBERS_SUCCESS,
539 projectName,
540 visibleMembers,
541 });
542 return visibleMembers;
543 } catch (error) {
544 dispatch({type: FETCH_VISIBLE_MEMBERS_FAILURE, error});
545 }
546};
547
548const fetchTemplates = (projectName) => async (dispatch) => {
549 dispatch({type: FETCH_TEMPLATES_START});
550
551 const listTemplates = prpcClient.call(
552 'monorail.Projects', 'ListProjectTemplates', {projectName});
553
554 // TODO(zhangtiff): Remove (see above TODO).
555 if (!listTemplates) return;
556
557 try {
558 const resp = await listTemplates;
559 dispatch({
560 type: FETCH_TEMPLATES_SUCCESS,
561 projectName,
562 templates: resp.templates,
563 });
564 } catch (error) {
565 dispatch({type: FETCH_TEMPLATES_FAILURE, error});
566 }
567};
568
569// Helpers
570
571/**
572 * Helper to fetch field permissions.
573 * @param {string} projectName The name of the project where the fields are.
574 * @param {Array<FieldDef>} fieldDefs
575 * @return {function(function): Promise<void>}
576 */
577export const fetchFieldPerms = (projectName, fieldDefs) => async (dispatch) => {
578 const fieldDefsNames = [];
579 if (fieldDefs) {
580 fieldDefs.forEach((fd) => {
581 const fieldDefName = fieldDefToName(projectName, fd);
582 fieldDefsNames.push(fieldDefName);
583 });
584 }
585 await dispatch(permissions.batchGet(fieldDefsNames));
586};