diff --git a/README.md b/README.md index 7cee578..81ae464 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **Maintainer:** [@marianunez](https://github.com/marianunez) **Co-Maintainer:** [@larkox](https://github.com/larkox) -This plugin adds custom attributes to users in your Mattermost instance. You can specify an Attribute, and then specify specific users or groups which will display that attribute on their public profile - so other users can identify them easily. This can be useful when there are Team Leads, Timezones, etc. and makes it easy to show who is on a particular team or Project. +This plugin adds custom attributes to users in your Mattermost instance. You can specify an Attribute, and then specify specific users, teams or groups which will display that attribute on their public profile - so other users can identify them easily. This can be useful when there are Team Leads, Timezones, etc. and makes it easy to show who is on a particular team or Project. Currently the plugin only exposes the specified attributes in the user profile popover, but this plugin could be extended to allow displaying attributes elsewhere in the user interface, such as badges next to usernames. @@ -31,12 +31,13 @@ Install via Plugin Marketplace (Recommended) ## Configuration -Before you start, Identify the attributes you want to display on a user's profile popover. These can contain emojis. Some examples could be "Timezone:PST", "Development Team", "Executive Team Member", "Mentor", etc. then Identify the groups or particular usernames that should display those atrributes. A spreadsheet can help to organize things. +Before you start, Identify the attributes you want to display on a user's profile popover. These can contain emojis. Some examples could be "Timezone:PST", "Development Team", "Executive Team Member", "Mentor", etc. then Identify the groups, teams or particular usernames that should display those atrributes. A spreadsheet can help to organize things. 1. Click "Add Custom Attribute" button, a text box will appear. Add the text that would appear in the user's profile popover. The text supports markdown and could include emojis and/or links, i.e. "[Integrations Team](https://developers.mattermost.com/internal/rd-teams/#integrations-team)" ![2020-04-14_12-12-46](https://user-images.githubusercontent.com/915956/79266979-3e3d2e80-7e4d-11ea-8a4d-80f78bd81d79.png) -2. Specify which users should have that attribute displayed on their profile. You can specify individual users or a Mattermost group ID (this ID needs to be copy/pasted from the group). The Mattermost group could be synched with an LDAP group to dynamically display attributes to user profiles, based on which LDAP group they currently belong to (this requires an E20 licence to enable AD/LDAP Groups). -![image](https://user-images.githubusercontent.com/915956/79267902-c07a2280-7e4e-11ea-8eed-96bc2fc9bde9.png) +2. Specify which users should have that attribute displayed on their profile. You can specify individual users, a Mattermost team name, or a Mattermost group ID (this ID needs to be copy/pasted from the group). The Mattermost group could be synched with an LDAP group to dynamically display attributes to user profiles, based on which LDAP group they currently belong to (this requires an E20 licence to enable AD/LDAP Groups). +![2020-06-21_22-51-13](https://user-images.githubusercontent.com/45119518/85234976-a726c100-b411-11ea-9477-7133c6a6d45b.png) + 3. Click "Save" @@ -46,7 +47,7 @@ Here are some example rules for two users: ![2020-04-14_12-18-50](https://user-images.githubusercontent.com/915956/79267023-4eeda480-7e4d-11ea-9279-e77c97d737be.png) -Their respective profile popvers display their information: +Their respective profile popovers display their information: ![2020-04-14_12-19-24](https://user-images.githubusercontent.com/915956/79267480-169a9600-7e4e-11ea-8c04-4775a395ff5b.png) @@ -66,8 +67,11 @@ To add a custom attribute, edit your `config.json` file and add a "CustomAttribu An attribute should have a `Name` field for what is displayed in the user interface as the attribute and an array of `UserIDs` for the users this attribute should apply to. The `Name` field can include Markdown, emojis and links. +You can fill an array of Mattermost team ID's to the `TeamIDs` parameter, and the `Name` will then be displayed +for all members of these teams. + You can also add an array of Mattermost group ID's to the `GroupIDs` parameter. The `Name` will then be displayed -for all memebers who are apart of that group. +for all members who are apart of that group. Below is an example: @@ -81,11 +85,13 @@ Below is an example: { "Name": ":mattermost: [Core Committer](https://developers.mattermost.com/contribute/getting-started/core-committers/)", "UserIDs": ["someuserID1", "someuserID2"], + "TeamIDs": ["someteamID1", "someteamID2"], "GroupIDs":["somegroupID1","somegroupID2"] }, { "Name": ":mattermost: Staff", "UserIDs": ["someuserID3", "someuserID4"], + "TeamIDs": ["someteamID3", "someteamID4"], "GroupIDs":["somegroupID3","somegroupID4"] } ] diff --git a/server/configuration.go b/server/configuration.go index c6a5ecd..f361c85 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -24,6 +24,7 @@ type configuration struct { type CustomAttribute struct { Name string UserIDs []string + TeamIDs []string GroupIDs []string } @@ -40,6 +41,15 @@ func (c *configuration) Clone() *configuration { for i2, id := range ca.UserIDs { caClone.UserIDs[i2] = id } + caClone.TeamIDs = make([]string, len(ca.TeamIDs)) + for i2, id := range ca.TeamIDs { + caClone.TeamIDs[i2] = id + } + caClone.GroupIDs = make([]string, len(ca.GroupIDs)) + for i2, id := range ca.GroupIDs { + caClone.GroupIDs[i2] = id + } + clone.CustomAttributes[i1] = caClone } diff --git a/server/plugin.go b/server/plugin.go index 7df2f8b..dd07c8f 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -56,12 +56,13 @@ func (p *Plugin) handleGetAttributes(w http.ResponseWriter, r *http.Request) { } attributes := []string{} + usersTeams, _ := p.API.GetTeamsForUser(userID) usersGroups, _ := p.API.GetGroupsForUser(userID) for _, ca := range config.CustomAttributes { - if ca.UserIDs == nil && ca.GroupIDs == nil { + if ca.UserIDs == nil && ca.TeamIDs == nil && ca.GroupIDs == nil { continue } - if sliceContainsString(ca.UserIDs, userID) || sliceContainsUserGroup(ca.GroupIDs, usersGroups) { + if sliceContainsString(ca.UserIDs, userID) || sliceContainsUserTeam(ca.TeamIDs, usersTeams) || sliceContainsUserGroup(ca.GroupIDs, usersGroups) { attributes = append(attributes, ca.Name) } } @@ -85,6 +86,17 @@ func sliceContainsString(arr []string, str string) bool { return false } +func sliceContainsUserTeam(arr []string, userTeams []*model.Team) bool { + for _, a := range arr { + for _, userTeam := range userTeams { + if a == userTeam.Id { + return true + } + } + } + return false +} + func sliceContainsUserGroup(arr []string, userGroups []*model.Group) bool { for _, a := range arr { for _, userGroup := range userGroups { diff --git a/webapp/src/components/admin_settings/add_attribute.jsx b/webapp/src/components/admin_settings/add_attribute.jsx index d59da6c..e2dba39 100644 --- a/webapp/src/components/admin_settings/add_attribute.jsx +++ b/webapp/src/components/admin_settings/add_attribute.jsx @@ -10,6 +10,7 @@ export default class AddAttribute extends React.Component { id: PropTypes.string, name: PropTypes.string, users: PropTypes.array, + teams: PropTypes.array, groups: PropTypes.array, onChange: PropTypes.func.isRequired, } @@ -21,6 +22,7 @@ export default class AddAttribute extends React.Component { collapsed: true, name: this.props.name, users: this.props.users, + teams: this.props.teams, groups: this.props.groups, error: false, }; @@ -31,29 +33,32 @@ export default class AddAttribute extends React.Component { collapsed: true, name: null, users: null, + teams: null, groups: null, error: false, }); } - onInput = ({name, users, groups}) => { - this.setState({name, users, groups, error: false}); + onInput = ({name, users, teams, groups}) => { + this.setState({name, users, teams, groups, error: false}); } handleSave = () => { const usersEmpty = !this.state.users || !this.state.users.length; + const teamsEmpty = !this.state.teams || !this.state.teams.length; const groupsEmpty = !this.state.groups || this.state.groups.trim() === ''; - if (!this.state.name || this.state.name.trim() === '' || (usersEmpty && groupsEmpty)) { + if (!this.state.name || this.state.name.trim() === '' || (usersEmpty && teamsEmpty && groupsEmpty)) { this.setState({error: true}); return; } - this.props.onChange({id: this.props.id, name: this.state.name, users: this.state.users, groups: this.state.groups}); + this.props.onChange({id: this.props.id, name: this.state.name, users: this.state.users, teams: this.state.teams, groups: this.state.groups}); this.setState({ collapsed: true, name: null, users: null, + teams: null, groups: null, }); } @@ -76,7 +81,7 @@ export default class AddAttribute extends React.Component { if (this.state.error) { errorBanner = (
-

{'You must provide a value for name and users or group.'} +

{'You must provide a value for name and users, teams or group.'}

); @@ -87,6 +92,7 @@ export default class AddAttribute extends React.Component { !res.error).map((res) => res.data); + + this.setState({teams}); + } + handleNameInput = (e) => { if (!e.target.value || e.target.value.trim() === '') { this.setState({error: 'Attribute name cannot be empty.'}); @@ -66,35 +83,52 @@ export default class CustomAttribute extends React.Component { } this.setState({name: e.target.value}); - this.props.onChange({id: this.props.id, name: e.target.value, users: this.state.users, groups: this.state.groups}); + this.props.onChange({id: this.props.id, name: e.target.value, users: this.state.users, teams: this.state.teams, groups: this.state.groups}); } handleUsersInput = (userIds) => { const usersEmpty = !userIds || !userIds.length; + const teamsEmpty = !this.state.teams || !this.state.teams.length; const groupsEmpty = !this.state.groups || this.state.groups.trim() === ''; - if (usersEmpty && groupsEmpty) { - this.setState({error: 'Attribute must include at least one user or group.'}); + if (usersEmpty && teamsEmpty && groupsEmpty) { + this.setState({error: 'Attribute must include at least one user, team or group.'}); } else if (this.state.name) { this.setState({error: null}); } this.setState({users: userIds}); - this.props.onChange({id: this.props.id, name: this.state.name, users: userIds, groups: this.state.groups}); + this.props.onChange({id: this.props.id, name: this.state.name, users: userIds, teams: this.state.teams, groups: this.state.groups}); } - handleGroupsInput = (e) => { - const usersEmpty = !e.target.value || e.target.value.trim() === ''; + handleTeamsInput = (teamsIds) => { + const usersEmpty = !this.state.users || !this.state.users.length; + const teamsEmpty = !teamsIds || !teamsIds.length; const groupsEmpty = !this.state.groups || this.state.groups.trim() === ''; - if (usersEmpty && groupsEmpty) { - this.setState({error: 'Attribute must include at least one user or group.'}); + if (usersEmpty && teamsEmpty && groupsEmpty) { + this.setState({error: 'Attribute must include at least one user, team or group.'}); + } else if (this.state.name) { + this.setState({error: null}); + } + + this.setState({teams: teamsIds}); + this.props.onChange({id: this.props.id, name: this.state.name, users: this.state.users, teams: teamsIds, groups: this.state.groups}); + } + + handleGroupsInput = (e) => { + const usersEmpty = !this.state.users || !this.state.users.length; + const teamsEmpty = !this.state.teams || !this.state.teams.length; + const groupsEmpty = !e.target.value || e.target.value.trim() === ''; + + if (usersEmpty && teamsEmpty && groupsEmpty) { + this.setState({error: 'Attribute must include at least one user, team or group.'}); } else if (this.state.name) { this.setState({error: null}); } this.setState({groups: e.target.value}); - this.props.onChange({id: this.props.id, name: this.state.name, users: this.state.users, groups: e.target.value}); + this.props.onChange({id: this.props.id, name: this.state.name, users: this.state.users, teams: this.state.teams, groups: e.target.value}); } handleDelete = () => { @@ -154,14 +188,21 @@ export default class CustomAttribute extends React.Component { onChange={this.handleNameInput} /> -
+
-
+
+ +
+
{ + handleChange = ({id, name, users, teams, groups}) => { let userIds = []; if (users) { userIds = users.map((v) => { @@ -84,9 +85,21 @@ export default class CustomAttributesSettings extends React.Component { return v; }); } + + let teamIds = []; + if (teams) { + teamIds = teams.map((team) => { + if (team.id) { + return team.id; + } + return team; + }); + } + this.state.attributes.set(id, { Name: name, UserIDs: userIds, + TeamIDs: teamIds, GroupIDs: groups ? groups.split(' ') : '', }); diff --git a/webapp/src/components/admin_settings/teams_input/index.js b/webapp/src/components/admin_settings/teams_input/index.js new file mode 100644 index 0000000..5d0356b --- /dev/null +++ b/webapp/src/components/admin_settings/teams_input/index.js @@ -0,0 +1,23 @@ +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; + +import {getTeams, searchTeams as reduxSearchTeams} from 'mattermost-redux/actions/teams'; + +import TeamsInput from './teams_input.jsx'; + +const searchTeams = (term, options = {}) => { + if (!term) { + return getTeams(0, 20, options); + } + return reduxSearchTeams(term, options); +}; + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + searchTeams, + }, dispatch), + }; +} + +export default connect(null, mapDispatchToProps)(TeamsInput); diff --git a/webapp/src/components/admin_settings/teams_input/teams_input.jsx b/webapp/src/components/admin_settings/teams_input/teams_input.jsx new file mode 100644 index 0000000..131cf58 --- /dev/null +++ b/webapp/src/components/admin_settings/teams_input/teams_input.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import debounce from 'lodash/debounce'; +import AsyncSelect from 'react-select/async'; + +// TeamsInput searches and selects teams displayed by display_name. +// Teams prop can handle the team object or strings directly if the team object is not available. +// Returns the selected team ids in the `OnChange` value parameter. +export default class TeamsInput extends React.PureComponent { + static propTypes = { + placeholder: PropTypes.string, + teams: PropTypes.array, + onChange: PropTypes.func, + actions: PropTypes.shape({ + searchTeams: PropTypes.func.isRequired, + }).isRequired, + }; + + onChange = (value) => { + if (this.props.onChange) { + this.props.onChange(value); + } + } + + getOptionValue = (team) => { + if (team.id) { + return team.id; + } + + return team; + }; + + formatOptionLabel = (option) => { + if (option.display_name) { + return ( + + { `${option.display_name}`} + + ); + } + + return option; + } + + searchTeams = debounce((term, callback) => { + this.props.actions.searchTeams(term).then(({data, error}) => { + if (error) { + // eslint-disable-next-line no-console + console.error('Error searching team in custom attribute settings dropdown. ' + error.message); + callback([]); + return; + } + + callback(data); + }); + }, 150); + + render() { + return ( + null, IndicatorSeparator: () => null}} + styles={customStyles} + /> + ); + } +} + +const customStyles = { + control: (provided) => ({ + ...provided, + minHeight: 34, + }), +};