blob: c8bd80ab9512f327fdf62567d6b8516b1698e10e [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {combineReducers} from 'redux';
import {createSelector} from 'reselect';
import {createReducer, createRequestReducer} from './redux-helpers.js';
import * as permissions from 'reducers/permissions.js';
import {fieldTypes, SITEWIDE_DEFAULT_COLUMNS, defaultIssueFieldMap,
parseColSpec, stringValuesForIssueField} from 'shared/issue-fields.js';
import {hasPrefix, removePrefix} from 'shared/helpers.js';
import {fieldNameToLabelPrefix,
labelNameToLabelPrefixes, labelNameToLabelValue, fieldDefToName,
restrictionLabelsForPermissions} from 'shared/convertersV0.js';
import {prpcClient} from 'prpc-client-instance.js';
import 'shared/typedef.js';
/** @typedef {import('redux').AnyAction} AnyAction */
// Actions
export const SELECT = 'projectV0/SELECT';
const FETCH_CONFIG_START = 'projectV0/FETCH_CONFIG_START';
export const FETCH_CONFIG_SUCCESS = 'projectV0/FETCH_CONFIG_SUCCESS';
const FETCH_CONFIG_FAILURE = 'projectV0/FETCH_CONFIG_FAILURE';
export const FETCH_PRESENTATION_CONFIG_START =
'projectV0/FETCH_PRESENTATION_CONFIG_START';
export const FETCH_PRESENTATION_CONFIG_SUCCESS =
'projectV0/FETCH_PRESENTATION_CONFIG_SUCCESS';
export const FETCH_PRESENTATION_CONFIG_FAILURE =
'projectV0/FETCH_PRESENTATION_CONFIG_FAILURE';
export const FETCH_CUSTOM_PERMISSIONS_START =
'projectV0/FETCH_CUSTOM_PERMISSIONS_START';
export const FETCH_CUSTOM_PERMISSIONS_SUCCESS =
'projectV0/FETCH_CUSTOM_PERMISSIONS_SUCCESS';
export const FETCH_CUSTOM_PERMISSIONS_FAILURE =
'projectV0/FETCH_CUSTOM_PERMISSIONS_FAILURE';
export const FETCH_VISIBLE_MEMBERS_START =
'projectV0/FETCH_VISIBLE_MEMBERS_START';
export const FETCH_VISIBLE_MEMBERS_SUCCESS =
'projectV0/FETCH_VISIBLE_MEMBERS_SUCCESS';
export const FETCH_VISIBLE_MEMBERS_FAILURE =
'projectV0/FETCH_VISIBLE_MEMBERS_FAILURE';
const FETCH_TEMPLATES_START = 'projectV0/FETCH_TEMPLATES_START';
export const FETCH_TEMPLATES_SUCCESS = 'projectV0/FETCH_TEMPLATES_SUCCESS';
const FETCH_TEMPLATES_FAILURE = 'projectV0/FETCH_TEMPLATES_FAILURE';
/* State Shape
{
name: string,
configs: Object<string, Config>,
presentationConfigs: Object<string, PresentationConfig>,
customPermissions: Object<string, Array<string>>,
visibleMembers:
Object<string, {userRefs: Array<UserRef>, groupRefs: Array<UserRef>}>,
templates: Object<string, Array<TemplateDef>>,
presentationConfigsLoaded: Object<string, boolean>,
requests: {
fetchConfig: ReduxRequestState,
fetchMembers: ReduxRequestState
fetchCustomPermissions: ReduxRequestState,
fetchPresentationConfig: ReduxRequestState,
fetchTemplates: ReduxRequestState,
},
}
*/
// Reducers
export const nameReducer = createReducer(null, {
[SELECT]: (_state, {projectName}) => projectName,
});
export const configsReducer = createReducer({}, {
[FETCH_CONFIG_SUCCESS]: (state, {projectName, config}) => ({
...state,
[projectName]: config,
}),
});
export const presentationConfigsReducer = createReducer({}, {
[FETCH_PRESENTATION_CONFIG_SUCCESS]:
(state, {projectName, presentationConfig}) => ({
...state,
[projectName]: presentationConfig,
}),
});
/**
* Adds custom permissions to Redux in a normalized state.
* @param {Object<string, Array<String>>} state Redux state.
* @param {AnyAction} Action
* @return {Object<string, Array<String>>}
*/
export const customPermissionsReducer = createReducer({}, {
[FETCH_CUSTOM_PERMISSIONS_SUCCESS]:
(state, {projectName, permissions}) => ({
...state,
[projectName]: permissions,
}),
});
export const visibleMembersReducer = createReducer({}, {
[FETCH_VISIBLE_MEMBERS_SUCCESS]: (state, {projectName, visibleMembers}) => ({
...state,
[projectName]: visibleMembers,
}),
});
export const templatesReducer = createReducer({}, {
[FETCH_TEMPLATES_SUCCESS]: (state, {projectName, templates}) => ({
...state,
[projectName]: templates,
}),
});
const requestsReducer = combineReducers({
fetchConfig: createRequestReducer(
FETCH_CONFIG_START, FETCH_CONFIG_SUCCESS, FETCH_CONFIG_FAILURE),
fetchMembers: createRequestReducer(
FETCH_VISIBLE_MEMBERS_START,
FETCH_VISIBLE_MEMBERS_SUCCESS,
FETCH_VISIBLE_MEMBERS_FAILURE),
fetchCustomPermissions: createRequestReducer(
FETCH_CUSTOM_PERMISSIONS_START,
FETCH_CUSTOM_PERMISSIONS_SUCCESS,
FETCH_CUSTOM_PERMISSIONS_FAILURE),
fetchPresentationConfig: createRequestReducer(
FETCH_PRESENTATION_CONFIG_START,
FETCH_PRESENTATION_CONFIG_SUCCESS,
FETCH_PRESENTATION_CONFIG_FAILURE),
fetchTemplates: createRequestReducer(
FETCH_TEMPLATES_START, FETCH_TEMPLATES_SUCCESS, FETCH_TEMPLATES_FAILURE),
});
export const reducer = combineReducers({
name: nameReducer,
configs: configsReducer,
customPermissions: customPermissionsReducer,
presentationConfigs: presentationConfigsReducer,
visibleMembers: visibleMembersReducer,
templates: templatesReducer,
requests: requestsReducer,
});
// Selectors
export const project = (state) => state.projectV0 || {};
export const viewedProjectName =
createSelector(project, (project) => project.name || null);
export const configs =
createSelector(project, (project) => project.configs || {});
export const presentationConfigs =
createSelector(project, (project) => project.presentationConfigs || {});
export const customPermissions =
createSelector(project, (project) => project.customPermissions || {});
export const visibleMembers =
createSelector(project, (project) => project.visibleMembers || {});
export const templates =
createSelector(project, (project) => project.templates || {});
export const viewedConfig = createSelector(
[viewedProjectName, configs],
(projectName, configs) => configs[projectName] || {});
export const viewedPresentationConfig = createSelector(
[viewedProjectName, presentationConfigs],
(projectName, configs) => configs[projectName] || {});
// TODO(crbug.com/monorail/7080): Come up with a more clear and
// consistent pattern for determining when data is loaded.
export const viewedPresentationConfigLoaded = createSelector(
[viewedProjectName, presentationConfigs],
(projectName, configs) => !!configs[projectName]);
export const viewedCustomPermissions = createSelector(
[viewedProjectName, customPermissions],
(projectName, permissions) => permissions[projectName] || []);
export const viewedVisibleMembers = createSelector(
[viewedProjectName, visibleMembers],
(projectName, visibleMembers) => visibleMembers[projectName] || {});
export const viewedTemplates = createSelector(
[viewedProjectName, templates],
(projectName, templates) => templates[projectName] || []);
/**
* Get the default columns for the currently viewed project.
*/
export const defaultColumns = createSelector(viewedPresentationConfig,
({defaultColSpec}) =>{
if (defaultColSpec) {
return parseColSpec(defaultColSpec);
}
return SITEWIDE_DEFAULT_COLUMNS;
});
/**
* Get the default query for the currently viewed project.
*/
export const defaultQuery = createSelector(viewedPresentationConfig,
(config) => config.defaultQuery || '');
// Look up components by path.
export const componentsMap = createSelector(
viewedConfig,
(config) => {
if (!config || !config.componentDefs) return new Map();
const acc = new Map();
for (const v of config.componentDefs) {
acc.set(v.path, v);
}
return acc;
},
);
export const fieldDefs = createSelector(
viewedConfig, (config) => ((config && config.fieldDefs) || []),
);
export const fieldDefMap = createSelector(
fieldDefs, (fieldDefs) => {
const map = new Map();
fieldDefs.forEach((fd) => {
map.set(fd.fieldRef.fieldName.toLowerCase(), fd);
});
return map;
},
);
export const labelDefs = createSelector(
[viewedConfig, viewedCustomPermissions],
(config, permissions) => [
...((config && config.labelDefs) || []),
...restrictionLabelsForPermissions(permissions),
],
);
// labelDefs stored in an easily findable format with label names as keys.
export const labelDefMap = createSelector(
labelDefs, (labelDefs) => {
const map = new Map();
labelDefs.forEach((ld) => {
map.set(ld.label.toLowerCase(), ld);
});
return map;
},
);
/**
* A selector that builds a map where keys are label prefixes
* and values equal to sets of possible values corresponding to the prefix
* @param {Object} state Current Redux state.
* @return {Map}
*/
export const labelPrefixValueMap = createSelector(labelDefs, (labelDefs) => {
const prefixMap = new Map();
labelDefs.forEach((ld) => {
const prefixes = labelNameToLabelPrefixes(ld.label);
prefixes.forEach((prefix) => {
if (prefixMap.has(prefix)) {
prefixMap.get(prefix).add(labelNameToLabelValue(ld.label, prefix));
} else {
prefixMap.set(prefix, new Set(
[labelNameToLabelValue(ld.label, prefix)]));
}
});
});
return prefixMap;
});
/**
* A selector that builds an array of label prefixes, keeping casing intact
* Some labels are implicitly used as custom fields in the grid and list view.
* Only labels with more than one option are included, to reduce noise.
* @param {Object} state Current Redux state.
* @return {Array}
*/
export const labelPrefixFields = createSelector(
labelPrefixValueMap, (map) => {
const prefixes = [];
map.forEach((options, prefix) => {
// Ignore label prefixes with only one value.
if (options.size > 1) {
prefixes.push(prefix);
}
});
return prefixes;
},
);
/**
* A selector that wraps labelPrefixFields arrays as set for fast lookup.
* @param {Object} state Current Redux state.
* @return {Set}
*/
export const labelPrefixSet = createSelector(
labelPrefixFields, (fields) => new Set(fields.map(
(field) => field.toLowerCase())),
);
export const enumFieldDefs = createSelector(
fieldDefs,
(fieldDefs) => {
return fieldDefs.filter(
(fd) => fd.fieldRef.type === fieldTypes.ENUM_TYPE);
},
);
/**
* A selector that builds a function that's used to compute the value of
* a given field name on a given issue. This function abstracts the difference
* between custom fields, built-in fields, and implicit fields created
* from labels and considers these values in the context of the current
* project configuration.
* @param {Object} state Current Redux state.
* @return {function(Issue, string): Array<string>} A function that processes a
* given issue and field name to find the string value for that field, in
* the issue.
*/
export const extractFieldValuesFromIssue = createSelector(
viewedProjectName,
(projectName) => (issue, fieldName) =>
stringValuesForIssueField(issue, fieldName, projectName),
);
/**
* A selector that builds a function that's used to compute the type of a given
* field name.
* @param {Object} state Current Redux state.
* @return {function(string): string}
*/
export const extractTypeForFieldName = createSelector(fieldDefMap,
(fieldDefMap) => {
return (fieldName) => {
const key = fieldName.toLowerCase();
// If the field is a built in field. Default fields have precedence
// over custom fields.
if (defaultIssueFieldMap.hasOwnProperty(key)) {
return defaultIssueFieldMap[key].type;
}
// If the field is a custom field. Custom fields have precedence
// over label prefixes.
if (fieldDefMap.has(key)) {
return fieldDefMap.get(key).fieldRef.type;
}
// Default to STR_TYPE, including for label fields.
return fieldTypes.STR_TYPE;
};
},
);
export const optionsPerEnumField = createSelector(
enumFieldDefs,
labelDefs,
(fieldDefs, labelDefs) => {
const map = new Map(fieldDefs.map(
(fd) => [fd.fieldRef.fieldName.toLowerCase(), []]));
labelDefs.forEach((ld) => {
const labelName = ld.label;
const fd = fieldDefs.find((fd) => hasPrefix(
labelName, fieldNameToLabelPrefix(fd.fieldRef.fieldName)));
if (fd) {
const key = fd.fieldRef.fieldName.toLowerCase();
map.get(key).push({
...ld,
optionName: removePrefix(labelName,
fieldNameToLabelPrefix(fd.fieldRef.fieldName)),
});
}
});
return map;
},
);
export const fieldDefsForPhases = createSelector(
fieldDefs,
(fieldDefs) => {
if (!fieldDefs) return [];
return fieldDefs.filter((f) => f.isPhaseField);
},
);
export const fieldDefsByApprovalName = createSelector(
fieldDefs,
(fieldDefs) => {
if (!fieldDefs) return new Map();
const acc = new Map();
for (const fd of fieldDefs) {
if (fd.fieldRef && fd.fieldRef.approvalName) {
if (acc.has(fd.fieldRef.approvalName)) {
acc.get(fd.fieldRef.approvalName).push(fd);
} else {
acc.set(fd.fieldRef.approvalName, [fd]);
}
}
}
return acc;
},
);
export const fetchingConfig = (state) => {
return state.projectV0.requests.fetchConfig.requesting;
};
/**
* Shorthand method for detecting whether we are currently
* fetching presentationConcifg
* @param {Object} state Current Redux state.
* @return {boolean}
*/
export const fetchingPresentationConfig = (state) => {
return state.projectV0.requests.fetchPresentationConfig.requesting;
};
// Action Creators
/**
* Action creator to set the currently viewed Project.
* @param {string} projectName The name of the Project to select.
* @return {function(function): Promise<void>}
*/
export const select = (projectName) => {
return (dispatch) => dispatch({type: SELECT, projectName});
};
/**
* Fetches data required to view project.
* @param {string} projectName
* @return {function(function): Promise<void>}
*/
export const fetch = (projectName) => async (dispatch) => {
const configPromise = dispatch(fetchConfig(projectName));
const visibleMembersPromise = dispatch(fetchVisibleMembers(projectName));
dispatch(fetchPresentationConfig(projectName));
dispatch(fetchTemplates(projectName));
const customPermissionsPromise = dispatch(
fetchCustomPermissions(projectName));
// TODO(crbug.com/monorail/5828): Remove window.TKR_populateAutocomplete once
// the old autocomplete code is deprecated.
const [config, visibleMembers, customPermissions] = await Promise.all([
configPromise,
visibleMembersPromise,
customPermissionsPromise]);
config.labelDefs = [...config.labelDefs,
...restrictionLabelsForPermissions(customPermissions)];
dispatch(fetchFieldPerms(config.projectName, config.fieldDefs));
// eslint-disable-next-line new-cap
window.TKR_populateAutocomplete(config, visibleMembers, customPermissions);
};
/**
* Fetches project configuration including things like the custom fields in a
* project, the statuses, etc.
* @param {string} projectName
* @return {function(function): Promise<Config>}
*/
const fetchConfig = (projectName) => async (dispatch) => {
dispatch({type: FETCH_CONFIG_START});
const getConfig = prpcClient.call(
'monorail.Projects', 'GetConfig', {projectName});
try {
const config = await getConfig;
dispatch({type: FETCH_CONFIG_SUCCESS, projectName, config});
return config;
} catch (error) {
dispatch({type: FETCH_CONFIG_FAILURE, error});
}
};
export const fetchPresentationConfig = (projectName) => async (dispatch) => {
dispatch({type: FETCH_PRESENTATION_CONFIG_START});
try {
const presentationConfig = await prpcClient.call(
'monorail.Projects', 'GetPresentationConfig', {projectName});
dispatch({
type: FETCH_PRESENTATION_CONFIG_SUCCESS,
projectName,
presentationConfig,
});
} catch (error) {
dispatch({type: FETCH_PRESENTATION_CONFIG_FAILURE, error});
}
};
/**
* Fetches custom permissions defined for a project.
* @param {string} projectName
* @return {function(function): Promise<Array<string>>}
*/
export const fetchCustomPermissions = (projectName) => async (dispatch) => {
dispatch({type: FETCH_CUSTOM_PERMISSIONS_START});
try {
const {permissions} = await prpcClient.call(
'monorail.Projects', 'GetCustomPermissions', {projectName});
dispatch({
type: FETCH_CUSTOM_PERMISSIONS_SUCCESS,
projectName,
permissions,
});
return permissions;
} catch (error) {
dispatch({type: FETCH_CUSTOM_PERMISSIONS_FAILURE, error});
}
};
/**
* Fetches the project members that the user is able to view.
* @param {string} projectName
* @return {function(function): Promise<GetVisibleMembersResponse>}
*/
export const fetchVisibleMembers = (projectName) => async (dispatch) => {
dispatch({type: FETCH_VISIBLE_MEMBERS_START});
try {
const visibleMembers = await prpcClient.call(
'monorail.Projects', 'GetVisibleMembers', {projectName});
dispatch({
type: FETCH_VISIBLE_MEMBERS_SUCCESS,
projectName,
visibleMembers,
});
return visibleMembers;
} catch (error) {
dispatch({type: FETCH_VISIBLE_MEMBERS_FAILURE, error});
}
};
const fetchTemplates = (projectName) => async (dispatch) => {
dispatch({type: FETCH_TEMPLATES_START});
const listTemplates = prpcClient.call(
'monorail.Projects', 'ListProjectTemplates', {projectName});
// TODO(zhangtiff): Remove (see above TODO).
if (!listTemplates) return;
try {
const resp = await listTemplates;
dispatch({
type: FETCH_TEMPLATES_SUCCESS,
projectName,
templates: resp.templates,
});
} catch (error) {
dispatch({type: FETCH_TEMPLATES_FAILURE, error});
}
};
// Helpers
/**
* Helper to fetch field permissions.
* @param {string} projectName The name of the project where the fields are.
* @param {Array<FieldDef>} fieldDefs
* @return {function(function): Promise<void>}
*/
export const fetchFieldPerms = (projectName, fieldDefs) => async (dispatch) => {
const fieldDefsNames = [];
if (fieldDefs) {
fieldDefs.forEach((fd) => {
const fieldDefName = fieldDefToName(projectName, fd);
fieldDefsNames.push(fieldDefName);
});
}
await dispatch(permissions.batchGet(fieldDefsNames));
};