diff --git a/src/data/generated.ts b/src/data/generated.ts index e148dea20c..412a95e3ee 100644 --- a/src/data/generated.ts +++ b/src/data/generated.ts @@ -250,6 +250,7 @@ export type Query = { colonyMembersWithReputation?: Maybe>; colonyName: Scalars['String']; colonyReputation?: Maybe; + contributorsAndWatchers?: Maybe; currentPeriodTokens: CurrentPeriodTokens; domain: SubgraphUnusedDomain; domainBalance: Scalars['String']; @@ -438,6 +439,13 @@ export type QueryColonyReputationArgs = { }; +export type QueryContributorsAndWatchersArgs = { + colonyAddress: Scalars['String']; + colonyName: Scalars['String']; + domainId?: Maybe; +}; + + export type QueryCurrentPeriodTokensArgs = { colonyAddress: Scalars['String']; }; @@ -1115,6 +1123,28 @@ export type UserDomainReputation = { reputationPercentage: Scalars['String']; }; +export type ColonyContributor = { + id: Scalars['String']; + directRoles: Array; + roles: Array; + banned: Scalars['Boolean']; + profile: UserProfile; + isWhitelisted: Scalars['Boolean']; + userReputation: Scalars['String']; +}; + +export type ColonyWatcher = { + id: Scalars['String']; + profile: UserProfile; + banned: Scalars['Boolean']; + isWhitelisted: Scalars['Boolean']; +}; + +export type ContributorsAndWatchers = { + contributors: Array; + watchers: Array; +}; + export type ByColonyFilter = { colonyAddress: Scalars['String']; domainChainId?: Maybe; @@ -2032,6 +2062,21 @@ export type ColonyMembersWithReputationQueryVariables = Exact<{ export type ColonyMembersWithReputationQuery = Pick; +export type ContributorsAndWatchersQueryVariables = Exact<{ + colonyAddress: Scalars['String']; + colonyName: Scalars['String']; + domainId?: Maybe; +}>; + + +export type ContributorsAndWatchersQuery = { contributorsAndWatchers?: Maybe<{ contributors: Array<( + Pick + & { profile: Pick } + )>, watchers: Array<( + Pick + & { profile: Pick } + )> }> }; + export type ColonyReputationQueryVariables = Exact<{ address: Scalars['String']; domainId?: Maybe; @@ -5121,6 +5166,65 @@ export function useColonyMembersWithReputationLazyQuery(baseOptions?: Apollo.Laz export type ColonyMembersWithReputationQueryHookResult = ReturnType; export type ColonyMembersWithReputationLazyQueryHookResult = ReturnType; export type ColonyMembersWithReputationQueryResult = Apollo.QueryResult; +export const ContributorsAndWatchersDocument = gql` + query ContributorsAndWatchers($colonyAddress: String!, $colonyName: String!, $domainId: Int) { + contributorsAndWatchers(colonyAddress: $colonyAddress, colonyName: $colonyName, domainId: $domainId) @client { + contributors { + id + directRoles + roles + banned + isWhitelisted + profile { + avatarHash + displayName + username + walletAddress + } + userReputation + } + watchers { + id + banned + isWhitelisted + profile { + avatarHash + displayName + username + walletAddress + } + } + } +} + `; + +/** + * __useContributorsAndWatchersQuery__ + * + * To run a query within a React component, call `useContributorsAndWatchersQuery` and pass it any options that fit your needs. + * When your component renders, `useContributorsAndWatchersQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useContributorsAndWatchersQuery({ + * variables: { + * colonyAddress: // value for 'colonyAddress' + * colonyName: // value for 'colonyName' + * domainId: // value for 'domainId' + * }, + * }); + */ +export function useContributorsAndWatchersQuery(baseOptions?: Apollo.QueryHookOptions) { + return Apollo.useQuery(ContributorsAndWatchersDocument, baseOptions); + } +export function useContributorsAndWatchersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + return Apollo.useLazyQuery(ContributorsAndWatchersDocument, baseOptions); + } +export type ContributorsAndWatchersQueryHookResult = ReturnType; +export type ContributorsAndWatchersLazyQueryHookResult = ReturnType; +export type ContributorsAndWatchersQueryResult = Apollo.QueryResult; export const ColonyReputationDocument = gql` query ColonyReputation($address: String!, $domainId: Int) { colonyReputation(address: $address, domainId: $domainId) @client diff --git a/src/data/graphql/queries.graphql b/src/data/graphql/queries.graphql index dee30b93a3..67aa05038f 100644 --- a/src/data/graphql/queries.graphql +++ b/src/data/graphql/queries.graphql @@ -468,6 +468,36 @@ query ColonyMembersWithReputation($colonyAddress: String!, $domainId: Int) { colonyMembersWithReputation(colonyAddress: $colonyAddress, domainId: $domainId) @client } +query ContributorsAndWatchers($colonyAddress: String!, $colonyName: String!, $domainId: Int) { + contributorsAndWatchers(colonyAddress: $colonyAddress, colonyName: $colonyName, domainId: $domainId) @client { + contributors { + id + directRoles + roles + banned + isWhitelisted + profile { + avatarHash + displayName + username + walletAddress + } + userReputation + } + watchers { + id + banned + isWhitelisted + profile { + avatarHash + displayName + username + walletAddress + } + } + } +} + query ColonyReputation($address: String!, $domainId: Int) { colonyReputation(address: $address, domainId: $domainId) @client } diff --git a/src/data/graphql/typeDefs.ts b/src/data/graphql/typeDefs.ts index 03a5635020..6a0c549afe 100644 --- a/src/data/graphql/typeDefs.ts +++ b/src/data/graphql/typeDefs.ts @@ -250,11 +250,38 @@ export default gql` reputationPercentage: String! } + type ColonyContributor { + id: String! + directRoles: [Int!]! + roles: [Int!]! + banned: Boolean! + profile: UserProfile! + isWhitelisted: Boolean! + userReputation: String! + } + + type ColonyWatcher { + id: String! + profile: UserProfile! + banned: Boolean! + isWhitelisted: Boolean! + } + + type ContributorsAndWatchers { + contributors: [ColonyContributor!]! + watchers: [ColonyWatcher!]! + } + extend type Query { loggedInUser: LoggedInUser! colonyAddress(name: String!): String! colonyName(address: String!): String! colonyReputation(address: String!, domainId: Int): String + contributorsAndWatchers( + colonyAddress: String! + colonyName: String! + domainId: Int + ): ContributorsAndWatchers colonyMembersWithReputation( colonyAddress: String! domainId: Int diff --git a/src/data/resolvers/colony.ts b/src/data/resolvers/colony.ts index 54c6884f12..527ac27c78 100644 --- a/src/data/resolvers/colony.ts +++ b/src/data/resolvers/colony.ts @@ -11,6 +11,7 @@ import { ROOT_DOMAIN_ID, getHistoricColonyRoles, formatColonyRoles, + ColonyRole, } from '@colony/colony-js'; import { Color } from '~core/ColorTag'; @@ -37,6 +38,18 @@ import { UserQuery, UserQueryVariables, UserDocument, + ColonyMembersWithReputationQuery, + ColonyMembersWithReputationQueryVariables, + ColonyMembersWithReputationDocument, + ColonyFromNameQuery, + ColonyFromNameQueryVariables, + ColonyFromNameDocument, + ColonyMembersQuery, + ColonyMembersQueryVariables, + ColonyMembersDocument, + BannedUsersQuery, + BannedUsersQueryVariables, + BannedUsersDocument, } from '~data/index'; import { createAddress } from '~utils/web3'; @@ -45,6 +58,7 @@ import { parseSubgraphEvent } from '~utils/events'; import { Address } from '~types/index'; import { COLONY_TOTAL_BALANCE_DOMAIN_ID } from '~constants'; +import { getAllUserRolesForDomain } from '~modules/transformers'; import { getToken } from './token'; import { @@ -52,6 +66,7 @@ import { getPayoutClaimedTransfers, getColonyUnclaimedTransfers, } from './transactions'; +import { getUserReputation } from './user'; export const getLatestSubgraphBlock = async ( apolloClient: ApolloClient, @@ -318,7 +333,7 @@ export const colonyResolvers = ({ _, { colonyAddress, - domainId = COLONY_TOTAL_BALANCE_DOMAIN_ID, + domainId = ROOT_DOMAIN_ID, }: { colonyAddress: Address; domainId: number }, ) { const colonyClient = await colonyManager.getClient( @@ -329,6 +344,205 @@ export const colonyResolvers = ({ const { addresses } = await colonyClient.getMembersReputation(skillId); return addresses || []; }, + async contributorsAndWatchers( + _, + { + colonyAddress, + colonyName, + domainId = COLONY_TOTAL_BALANCE_DOMAIN_ID, + }: { colonyAddress: Address; colonyName: string; domainId: number }, + ) { + const { data: colony } = await apolloClient.query< + ColonyFromNameQuery, + ColonyFromNameQueryVariables + >({ + query: ColonyFromNameDocument, + variables: { + name: colonyName, + address: colonyAddress.toLowerCase(), + }, + fetchPolicy: 'network-only', + }); + const verifiedAddresses = colony?.processedColony.whitelistedAddresses; + + const domainIdchecked = + domainId === COLONY_TOTAL_BALANCE_DOMAIN_ID ? ROOT_DOMAIN_ID : domainId; + + const domainRoles = getAllUserRolesForDomain( + colony?.processedColony, + domainIdchecked, + ); + const directDomainRoles = getAllUserRolesForDomain( + colony?.processedColony, + domainIdchecked, + true, + ); + + const inheritedDomainRoles = getAllUserRolesForDomain( + colony?.processedColony, + ROOT_DOMAIN_ID, + true, + ); + + const domainRolesArray = domainRoles + .sort(({ roles }) => (roles.includes(ColonyRole.Root) ? -1 : 1)) + .filter(({ roles }) => !!roles.length) + .map(({ address, roles }) => { + const directUserRoles = directDomainRoles.find( + ({ address: userAddress }) => userAddress === address, + ); + const rootRoles = inheritedDomainRoles.find( + ({ address: userAddress }) => userAddress === address, + ); + const allUserRoles = [ + ...new Set([ + ...(directUserRoles?.roles || []), + ...(rootRoles?.roles || []), + ]), + ]; + return { + userAddress: address, + roles, + directRoles: allUserRoles, + }; + }); + + const { data: membersWithReputationData } = await apolloClient.query< + ColonyMembersWithReputationQuery, + ColonyMembersWithReputationQueryVariables + >({ + query: ColonyMembersWithReputationDocument, + variables: { + colonyAddress: colonyAddress.toLowerCase(), + domainId: domainIdchecked, + }, + fetchPolicy: 'network-only', + }); + + const membersWithReputation = [ + ...(membersWithReputationData?.colonyMembersWithReputation || []), + ]; + + const { data: members } = await apolloClient.query< + ColonyMembersQuery, + ColonyMembersQueryVariables + >({ + query: ColonyMembersDocument, + variables: { + colonyAddress, + }, + fetchPolicy: 'network-only', + }); + + const { data: bannedUsers } = await apolloClient.query< + BannedUsersQuery, + BannedUsersQueryVariables + >({ + query: BannedUsersDocument, + variables: { + colonyAddress, + }, + fetchPolicy: 'network-only', + }); + + const contributors: any[] = []; + const watchers: any[] = []; + + members?.subscribedUsers.forEach((user) => { + const { + profile: { walletAddress }, + } = user; + const isWhitelisted = verifiedAddresses?.includes( + createAddress(walletAddress), + ); + + const isUserBanned = bannedUsers?.bannedUsers?.find( + ({ + id: bannedUserWalletAddress, + banned, + }: { + id: Address; + banned: boolean; + }) => + banned && + createAddress(bannedUserWalletAddress) === + createAddress(walletAddress), + ); + + const domainRole = domainRolesArray.find( + (rolesObject) => + createAddress(rolesObject.userAddress) === + createAddress(walletAddress), + ); + + const indexInReputationArr = membersWithReputation.indexOf( + walletAddress.toLowerCase(), + ); + if (indexInReputationArr > -1) { + membersWithReputation.splice(indexInReputationArr, 1); + } + + if (domainRole || indexInReputationArr > -1) { + contributors.push({ + ...user, + roles: domainRole ? domainRole.roles : [], + directRoles: domainRole ? domainRole.directRoles : [], + banned: !!isUserBanned, + isWhitelisted, + }); + } else { + watchers.push({ ...user, banned: !!isUserBanned, isWhitelisted }); + } + }); + + membersWithReputation.forEach((walletAddress) => { + const address = createAddress(walletAddress); + const isWhitelisted = verifiedAddresses?.includes(address); + + const isUserBanned = bannedUsers?.bannedUsers?.find( + ({ + id: bannedUserWalletAddress, + banned, + }: { + id: Address; + banned: boolean; + }) => banned && createAddress(bannedUserWalletAddress) === address, + ); + + contributors.push({ + __typename: 'User', + id: address, + profile: { + __typename: 'UserProfile', + walletAddress: address, + avatarHash: null, + displayName: null, + username: null, + }, + roles: [], + directRoles: [], + banned: !!isUserBanned, + isWhitelisted, + }); + }); + + const contributorsWithReputation = await Promise.all( + contributors.map(async (contributor) => { + const contributorReputation = await getUserReputation( + colonyManager, + contributor.profile.walletAddress, + colonyAddress, + domainId, + ); + return { + ...contributor, + userReputation: contributorReputation.toString(), + }; + }), + ); + + return { contributors: contributorsWithReputation, watchers }; + }, async colonyDomain(_, { colonyAddress, domainId }) { const { data } = await apolloClient.query< SubgraphSingleDomainQuery, diff --git a/src/data/resolvers/user.ts b/src/data/resolvers/user.ts index 007f575c5e..4ed30c1932 100644 --- a/src/data/resolvers/user.ts +++ b/src/data/resolvers/user.ts @@ -35,7 +35,7 @@ import { parseSubgraphEvent } from '~utils/events'; import { getToken } from './token'; import { getProcessedColony } from './colony'; -const getUserReputation = async ( +export const getUserReputation = async ( colonyManager: ColonyManager, address: Address, colonyAddress: Address, diff --git a/src/img/icons.json b/src/img/icons.json index a58ff7bc72..f78c2dc8be 100644 --- a/src/img/icons.json +++ b/src/img/icons.json @@ -8,11 +8,11 @@ "at-sign-circle", "attachment", "bell", - "caret-down-small", + "caret-down", "caret-left", "caret-right-small", "caret-right", - "caret-up-small", + "caret-up", "check-mark", "checklist", "circle-add", diff --git a/src/img/icons/caret-down-small.svg b/src/img/icons/caret-down-small.svg deleted file mode 100644 index 8fae5f7c5e..0000000000 --- a/src/img/icons/caret-down-small.svg +++ /dev/null @@ -1,719 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/img/icons/caret-down.svg b/src/img/icons/caret-down.svg new file mode 100644 index 0000000000..7b482b3740 --- /dev/null +++ b/src/img/icons/caret-down.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/img/icons/caret-up-small.svg b/src/img/icons/caret-up-small.svg deleted file mode 100644 index 24cedf2e7e..0000000000 --- a/src/img/icons/caret-up-small.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/img/icons/caret-up.svg b/src/img/icons/caret-up.svg new file mode 100644 index 0000000000..e81c2df22a --- /dev/null +++ b/src/img/icons/caret-up.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/img/icons/search.svg b/src/img/icons/search.svg index b100e03ce3..fb1a7b62b1 100644 --- a/src/img/icons/search.svg +++ b/src/img/icons/search.svg @@ -6,8 +6,6 @@ @@ -19,4 +17,4 @@ /> - \ No newline at end of file + diff --git a/src/modules/core/components/Button/Button.css b/src/modules/core/components/Button/Button.css index 3d09234a16..11cec22ebd 100644 --- a/src/modules/core/components/Button/Button.css +++ b/src/modules/core/components/Button/Button.css @@ -312,6 +312,18 @@ text-decoration: none; } +.themeBlueWithBackground { + composes: themeBlue; + padding: 4px 10px; + height: 24px; + border-radius: 4px; +} + +.themeBlueWithBackground:hover { + background-color: color-mod(var(--colony-blue) alpha(10%)); + text-decoration: none; +} + .themeDottedArea { display: block; padding: 31.5px 25px; diff --git a/src/modules/core/components/Button/Button.css.d.ts b/src/modules/core/components/Button/Button.css.d.ts index d1d8f4b563..752ddfae20 100644 --- a/src/modules/core/components/Button/Button.css.d.ts +++ b/src/modules/core/components/Button/Button.css.d.ts @@ -14,6 +14,7 @@ export const themeGhost: string; export const colorSchemaGrey: string; export const themeUnderlinedBold: string; export const themeBlue: string; +export const themeBlueWithBackground: string; export const themeDottedArea: string; export const themeWhite: string; export const themePink: string; diff --git a/src/modules/core/components/Button/Button.tsx b/src/modules/core/components/Button/Button.tsx index 21b3e7b831..7cfb1941b7 100644 --- a/src/modules/core/components/Button/Button.tsx +++ b/src/modules/core/components/Button/Button.tsx @@ -21,6 +21,7 @@ export interface Appearance { | 'ghost' | 'underlinedBold' | 'blue' + | 'blueWithBackground' | 'no-style' | 'white' | 'dottedArea'; diff --git a/src/modules/core/components/Comment/Dialogs/BanUser/BanUser.tsx b/src/modules/core/components/Comment/Dialogs/BanUser/BanUser.tsx index 7b10ee34bd..008ff70d40 100644 --- a/src/modules/core/components/Comment/Dialogs/BanUser/BanUser.tsx +++ b/src/modules/core/components/Comment/Dialogs/BanUser/BanUser.tsx @@ -71,6 +71,7 @@ const displayName = 'core.Comment.BanUser'; interface Props extends DialogProps { colonyAddress: Address; isBanning?: boolean; + addressToBan?: Address; } const UserAvatar = HookedUserAvatar({ fetchUser: false }); @@ -87,7 +88,13 @@ const validationSchema = yup.object().shape({ }), }); -const BanUser = ({ colonyAddress, cancel, close, isBanning = true }: Props) => { +const BanUser = ({ + colonyAddress, + cancel, + close, + isBanning = true, + addressToBan, +}: Props) => { const { data: colonyMembers } = useMembersSubscription({ variables: { colonyAddress }, }); @@ -145,12 +152,28 @@ const BanUser = ({ colonyAddress, cancel, close, isBanning = true }: Props) => { [close, colonyAddress, updateBanStatus], ); + const membersList = useMemo( + () => (isBanning ? membersNotBanned : membersBanned), + [isBanning, membersNotBanned, membersBanned], + ); + + const selectedUser = useMemo( + () => + (membersList as AnyUser[]).find( + (user) => user.profile?.walletAddress === addressToBan, + ), + [membersList, addressToBan], + ); + return (
{({ isValid, isSubmitting, submitForm }: FormikProps) => ( @@ -165,7 +188,7 @@ const BanUser = ({ colonyAddress, cancel, close, isBanning = true }: Props) => {
{ const handleSubmit = useCallback( (domainId: number) => { @@ -148,7 +153,7 @@ const DomainDropdown = ({ + )} + +
+ + ); + }} + + + ); +}; + +MembersFilter.displayName = displayName; + +export default MembersFilter; diff --git a/src/modules/dashboard/components/ColonyMembers/MembersFilter/index.ts b/src/modules/dashboard/components/ColonyMembers/MembersFilter/index.ts new file mode 100644 index 0000000000..81abffdb5a --- /dev/null +++ b/src/modules/dashboard/components/ColonyMembers/MembersFilter/index.ts @@ -0,0 +1,7 @@ +export { + default, + FormValues, + MemberType, + VerificationType, + BannedStatus, +} from './MembersFilter'; diff --git a/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.css b/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.css index 87e3cbd275..c7ef30969a 100644 --- a/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.css +++ b/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.css @@ -65,15 +65,8 @@ } .caretIcon { - display: block; - height: 24px; - width: 24px; -} - -.caretContainer { - display: inline-block; - margin: 0 0 0 3px; - vertical-align: middle; + margin-left: 10px; + width: 9px; } .tokenLockWrapper { diff --git a/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.css.d.ts b/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.css.d.ts index 6bad8d959c..9d624a305f 100644 --- a/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.css.d.ts +++ b/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.css.d.ts @@ -8,5 +8,4 @@ export const totalBalanceCopy: string; export const manageFundsLink: string; export const rightArrowDisplay: string; export const caretIcon: string; -export const caretContainer: string; export const tokenLockWrapper: string; diff --git a/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.tsx b/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.tsx index c469bb4da7..c2688e55c1 100644 --- a/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.tsx +++ b/src/modules/dashboard/components/ColonyTotalFunds/ColonyTotalFunds.tsx @@ -125,13 +125,11 @@ const ColonyTotalFunds = ({ appearance={{ size: 'large' }} /> )} - - - + diff --git a/src/modules/dashboard/components/Dialogs/EditDomainDialog/EditDomainDialogForm.tsx b/src/modules/dashboard/components/Dialogs/EditDomainDialog/EditDomainDialogForm.tsx index c10f99adf4..6ec099c924 100644 --- a/src/modules/dashboard/components/Dialogs/EditDomainDialog/EditDomainDialogForm.tsx +++ b/src/modules/dashboard/components/Dialogs/EditDomainDialog/EditDomainDialogForm.tsx @@ -35,9 +35,9 @@ const MSG = defineMessages({ id: 'dashboard.EditDomainDialog.EditDomainDialogForm.name', defaultMessage: 'Team name', }, - domain: { - id: 'dashboard.EditDomainDialog.EditDomainDialogForm.domain', - defaultMessage: 'Select domain', + team: { + id: 'dashboard.EditDomainDialog.EditDomainDialogForm.team', + defaultMessage: 'Select team', }, purpose: { id: 'dashboard.EditDomainDialog.EditDomainDialogForm.name', @@ -219,7 +219,7 @@ const EditDomainDialogForm = ({
- -
- {skelethonUsers.length ? ( - - colony={colony} - extraItemContent={({ roles, directRoles, banned }) => ( - - )} - domainId={currentDomainId} - users={members} - /> + + {!contributors?.length && !watchers?.length ? ( +
+ +
) : ( - - )} - {ITEMS_PER_PAGE * dataPage < skelethonUsers.length && ( - + membersContent )} ); diff --git a/src/modules/dashboard/components/Members/MembersSection.css b/src/modules/dashboard/components/Members/MembersSection.css new file mode 100644 index 0000000000..1d7e05d281 --- /dev/null +++ b/src/modules/dashboard/components/Members/MembersSection.css @@ -0,0 +1,38 @@ +.bar { + display: flex; + align-items: baseline; + margin-top: 25px; + margin-bottom: 13px; +} + +.title { + font-size: var(--size-smallish); + font-weight: var(--weight-bold); + color: var(--dark); +} + +.contributorsTitle { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.description { + margin-left: 9px; + font-size: var(--size-tiny); + font-weight: var(--weight-bold); + color: var(--temp-grey-blue-7); +} + +.noResults { + display: flex; + justify-content: center; + align-items: center; + margin-top: 36px; + margin-bottom: 48px; +} + +.membersList > ul { + box-shadow: none; +} diff --git a/src/modules/dashboard/components/Members/MembersSection.css.d.ts b/src/modules/dashboard/components/Members/MembersSection.css.d.ts new file mode 100644 index 0000000000..0a701c4669 --- /dev/null +++ b/src/modules/dashboard/components/Members/MembersSection.css.d.ts @@ -0,0 +1,6 @@ +export const bar: string; +export const title: string; +export const contributorsTitle: string; +export const description: string; +export const noResults: string; +export const membersList: string; diff --git a/src/modules/dashboard/components/Members/MembersSection.tsx b/src/modules/dashboard/components/Members/MembersSection.tsx new file mode 100644 index 0000000000..c67ec03fc9 --- /dev/null +++ b/src/modules/dashboard/components/Members/MembersSection.tsx @@ -0,0 +1,116 @@ +import React, { useState, useCallback, ReactNode } from 'react'; +import classnames from 'classnames'; +import { FormattedMessage, defineMessages } from 'react-intl'; + +import MembersList from '~core/MembersList'; +import LoadMoreButton from '~core/LoadMoreButton'; +import SortingRow from '~core/MembersList/SortingRow'; +import { Colony, ColonyWatcher, ColonyContributor } from '~data/index'; +import useColonyMembersSorting from '~modules/dashboard/hooks/useColonyMembersSorting'; + +import styles from './MembersSection.css'; + +const displayName = 'dashboard.MembersSection'; + +const MSG = defineMessages({ + contributorsTitle: { + id: 'dashboard.Members.MembersSection.contributorsTitle', + defaultMessage: 'Contributors', + }, + watchersTitle: { + id: 'dashboard.Members.MembersSection.watchersTitle', + defaultMessage: 'Watchers', + }, + watchersDescription: { + id: 'dashboard.Members.MembersSection.watchersDescription', + defaultMessage: "Members who don't currently have any reputation", + }, + noMemebersFound: { + id: 'dashboard.Members.MembersSection.noResultsFound', + defaultMessage: 'No members found', + }, +}); + +interface Props { + isContributorsSection: boolean; + colony: Colony; + currentDomainId: number; + members: ColonyWatcher[] | ColonyContributor[]; + canAdministerComments: boolean; + extraItemContent: (user: U) => ReactNode; +} + +const ITEMS_PER_SECTION = 10; +const MembersSection = ({ + colony, + currentDomainId, + members, + canAdministerComments, + extraItemContent, + isContributorsSection, +}: Props) => { + const [dataPage, setDataPage] = useState(1); + + const paginatedMembers = members.slice(0, ITEMS_PER_SECTION * dataPage); + const handleDataPagination = useCallback(() => { + setDataPage(dataPage + 1); + }, [dataPage]); + + const { + sortedMembers, + sortingMethod, + handleSortingMethodChange, + } = useColonyMembersSorting(paginatedMembers, isContributorsSection); + + return ( + <> +
+
+ + {isContributorsSection && handleSortingMethodChange && ( + + )} +
+ {!isContributorsSection && ( +
+ +
+ )} +
+ {sortedMembers.length ? ( +
+ +
+ ) : ( +
+ +
+ )} + {ITEMS_PER_SECTION * dataPage < members.length && ( + + )} + + ); +}; + +MembersSection.displayName = displayName; + +export default MembersSection; diff --git a/src/modules/dashboard/components/Members/MembersTitle.css b/src/modules/dashboard/components/Members/MembersTitle.css new file mode 100644 index 0000000000..db34bb2735 --- /dev/null +++ b/src/modules/dashboard/components/Members/MembersTitle.css @@ -0,0 +1,107 @@ +.titleContainer { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 35px; + min-width: 722px; + border-bottom: 1px solid var(--temp-grey-13); +} + +.titleContainer > h3 { + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.titleLeft { + display: flex; + align-items: center; +} + +.titleLeft > h3 { + margin-bottom: 0px; +} + +.titleLeft > form > div > div > div > button { + padding: 0; + border: 0; + font-size: var(--size-medium); + color: var(--dark); +} + +.titleSelect { + margin-left: 6px; +} + +.searchContainer { + margin-top: 15px; + + /* pull down to cover HR */ + margin-bottom: -1px; + padding-right: 42px; + width: 300px; + position: relative; + border-bottom: 1px solid var(--temp-grey-13); + + &:hover { + border-bottom: 1px solid var(--primary); + } + + &:hover > i > svg { + stroke: var(--colony-blue); + } + + &:focus-within > i > svg { + stroke: var(--colony-blue); + } +} + +.icon { + height: 20px; + width: 20px; + position: absolute; + top: 40%; + right: 2px; + z-index: 1; + transform: translateY(-50%); + + & svg { + stroke: color-mod(var(--temp-grey-blue-7) alpha(65%)); + + &:hover { + stroke: var(--colony-blue); + } + } +} + +.input { + padding: 4px; + height: 32px; + width: 100%; + position: relative; + z-index: 2; + border: 0; + background-color: transparent; + font-size: var(--size-normal); + line-height: 32px; + color: var(--text); + outline: none; + letter-spacing: var(--spacing-normal); + transition: border-color 0.1s ease-in; + + &::placeholder { + font-size: var(--size-normal); + color: var(--grey-4); + letter-spacing: var(--spacing-normal); + } +} + +.clearButton { + composes: button from '~styles/reset.css'; + position: absolute; + top: 50%; + right: 25px; + z-index: 2; + transform: translateY(-50%); +} diff --git a/src/modules/dashboard/components/Members/MembersTitle.css.d.ts b/src/modules/dashboard/components/Members/MembersTitle.css.d.ts new file mode 100644 index 0000000000..b967e608dc --- /dev/null +++ b/src/modules/dashboard/components/Members/MembersTitle.css.d.ts @@ -0,0 +1,7 @@ +export const titleContainer: string; +export const titleLeft: string; +export const titleSelect: string; +export const searchContainer: string; +export const icon: string; +export const input: string; +export const clearButton: string; diff --git a/src/modules/dashboard/components/Members/MembersTitle.tsx b/src/modules/dashboard/components/Members/MembersTitle.tsx new file mode 100644 index 0000000000..7d50c99d1b --- /dev/null +++ b/src/modules/dashboard/components/Members/MembersTitle.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, Dispatch, useRef, SetStateAction } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import Heading from '~core/Heading'; +import Icon from '~core/Icon'; +import { Select, Form } from '~core/Fields'; + +import styles from './MembersTitle.css'; + +const MSG = defineMessages({ + title: { + id: 'dashboard.Members.MembersTitle.title', + defaultMessage: 'Members: ', + }, + search: { + id: 'dashboard.Members.MembersTitle.search', + defaultMessage: 'Search', + }, + searchPlaceholder: { + id: 'dashboard.Members.MembersTitle.searchPlaceholder', + defaultMessage: 'Search...', + }, + labelFilter: { + id: 'dashboard.Members.MembersTitle.labelFilter', + defaultMessage: 'Filter', + }, +}); + +interface Props { + currentDomainId: number; + domainSelectOptions: { + value: string; + label: string; + }[]; + handleDomainChange: (domainId: number) => void; + searchValue: string; + setSearchValue: Dispatch>; + handleSearch: (e: any) => void; +} + +const displayName = 'dashboard.MembersTitle'; + +const MembersTitle = ({ + currentDomainId, + handleDomainChange, + domainSelectOptions, + searchValue, + handleSearch, +}: Props) => { + const { formatMessage } = useIntl(); + const searchInput = useRef(null); + const handleFocusRef = useCallback(() => { + searchInput?.current?.focus(); + }, [searchInput]); + + const handleMouseEnterRef = useCallback(() => { + if (searchInput.current !== null) { + searchInput.current.placeholder = formatMessage(MSG.searchPlaceholder); + } + }, [formatMessage]); + + const handleMouseLeaveRef = useCallback(() => { + if (searchInput.current !== null) { + searchInput.current.placeholder = ''; + } + }, []); + + const handleMouseEnter = useCallback( + (e) => { + (e.target as HTMLInputElement).placeholder = formatMessage( + MSG.searchPlaceholder, + ); + }, + [formatMessage], + ); + + const handleMouseLeave = useCallback((e) => { + (e.target as HTMLInputElement).placeholder = ''; + }, []); + + return ( +
+
+ +
{}} + > +
+ + {searchValue && ( + + )} + +
+
+ ); +}; +MembersTitle.displayName = displayName; + +export default MembersTitle; diff --git a/src/modules/dashboard/components/Members/filterMembers.ts b/src/modules/dashboard/components/Members/filterMembers.ts new file mode 100644 index 0000000000..95b85c56e0 --- /dev/null +++ b/src/modules/dashboard/components/Members/filterMembers.ts @@ -0,0 +1,95 @@ +import { + FormValues, + BannedStatus, + VerificationType, +} from '~dashboard/ColonyMembers/MembersFilter'; + +import { ColonyContributor, ColonyWatcher } from '~data/index'; + +export const filterMembers = ( + data: M[], + searchValue?: string, + filters?: FormValues, +): M[] => { + /* No filters */ + if ( + !searchValue && + filters?.bannedStatus === BannedStatus.ALL && + filters?.verificationType === VerificationType.ALL + ) { + return data; + } + + /* Only text filter */ + if ( + searchValue && + filters?.bannedStatus === BannedStatus.ALL && + filters?.verificationType === VerificationType.ALL + ) { + return data.filter( + ({ profile, id }) => + profile?.username?.toLowerCase().includes(searchValue.toLowerCase()) || + profile?.walletAddress + ?.toLowerCase() + .includes(searchValue.toLowerCase()) || + id?.toLowerCase().includes(searchValue.toLowerCase()), + ); + } + + /* All other combinations */ + return data.filter(({ banned, isWhitelisted, profile, id }) => { + const textFilter = + searchValue === undefined || searchValue === '' + ? true + : profile?.username + ?.toLowerCase() + .includes(searchValue.toLowerCase()) || + profile?.walletAddress + ?.toLowerCase() + .includes(searchValue.toLowerCase()) || + id?.toLowerCase().includes(searchValue.toLowerCase()); + + if (filters?.verificationType === VerificationType.ALL) { + if (filters?.bannedStatus === BannedStatus.BANNED) { + return banned && textFilter; + } + + if (filters?.bannedStatus === BannedStatus.NOT_BANNED) { + return !banned && textFilter; + } + } + + if (filters?.bannedStatus === BannedStatus.ALL) { + if (filters.verificationType === VerificationType.VERIFIED) { + return isWhitelisted && textFilter; + } + + if (filters.verificationType === VerificationType.UNVERIFIED) { + return !isWhitelisted && textFilter; + } + } + + if ( + filters?.verificationType === VerificationType.VERIFIED && + filters?.bannedStatus === BannedStatus.BANNED + ) { + return isWhitelisted && banned && textFilter; + } + + if ( + filters?.verificationType === VerificationType.UNVERIFIED && + filters?.bannedStatus === BannedStatus.BANNED + ) { + return !isWhitelisted && banned && textFilter; + } + + if ( + filters?.verificationType === VerificationType.VERIFIED && + filters?.bannedStatus === BannedStatus.NOT_BANNED + ) { + return isWhitelisted && !banned && textFilter; + } + + return !isWhitelisted && !banned && textFilter; + }); +}; diff --git a/src/modules/dashboard/components/Members/index.ts b/src/modules/dashboard/components/Members/index.ts index 3fa4272a08..c61bc48fda 100644 --- a/src/modules/dashboard/components/Members/index.ts +++ b/src/modules/dashboard/components/Members/index.ts @@ -1 +1,2 @@ -export { default } from './Members'; +export { default, Member } from './Members'; +export { default as MembersTitle } from './MembersTitle'; diff --git a/src/modules/dashboard/components/MotionDomainSelect/MotionDomainSelect.tsx b/src/modules/dashboard/components/MotionDomainSelect/MotionDomainSelect.tsx index 906bcfcb99..c5fdfbb5e5 100644 --- a/src/modules/dashboard/components/MotionDomainSelect/MotionDomainSelect.tsx +++ b/src/modules/dashboard/components/MotionDomainSelect/MotionDomainSelect.tsx @@ -86,6 +86,7 @@ const MotionDomainSelect = ({ showAllDomains={false} showDescription={false} disabled={disabled} + dropdownSize="normal" />
diff --git a/src/modules/dashboard/components/UserPermissions/UserPermissions.css b/src/modules/dashboard/components/UserPermissions/UserPermissions.css index 20f9615af3..b097a585e1 100644 --- a/src/modules/dashboard/components/UserPermissions/UserPermissions.css +++ b/src/modules/dashboard/components/UserPermissions/UserPermissions.css @@ -1,6 +1,6 @@ .main { - padding: 0 30px; + padding: 0 25px; font-size: var(--size-tiny); font-weight: var(--weight-bold); white-space: nowrap; diff --git a/src/modules/dashboard/components/UserPermissions/UserPermissions.tsx b/src/modules/dashboard/components/UserPermissions/UserPermissions.tsx index 442317df02..f02632e65f 100644 --- a/src/modules/dashboard/components/UserPermissions/UserPermissions.tsx +++ b/src/modules/dashboard/components/UserPermissions/UserPermissions.tsx @@ -18,6 +18,7 @@ interface Props { directRoles: ColonyRole[]; appearance?: Appearance; banned?: boolean; + hideHeadRole?: boolean; } const displayName = 'dashboard.UserPermissions'; @@ -27,6 +28,7 @@ const UserPermissions = ({ directRoles, appearance, banned = false, + hideHeadRole = false, }: Props) => { const sortedRoles = roles .filter( @@ -48,14 +50,14 @@ const UserPermissions = ({ )} - {!isNil(headRole) && ( + {!hideHeadRole && !isNil(headRole) && ( )} - {restRoles.map((role) => ( + {(hideHeadRole ? sortedRoles : restRoles).map((role) => ( { + const [sortingMethod, setSortingMethod] = useState( + SORTING_METHODS.BY_HIGHEST_REP, + ); + + const sortedUsers = useMemo(() => { + if (!isContributorsSection) { + return members; + } + + return [...(members as ColonyContributor[])].sort((user1, user2) => { + if (sortingMethod === SORTING_METHODS.BY_HIGHEST_REP) { + return new Decimal(user2.userReputation) + .sub(user1.userReputation) + .toNumber(); + } + if (sortingMethod === SORTING_METHODS.BY_LOWEST_REP) { + return new Decimal(user1.userReputation) + .sub(user2.userReputation) + .toNumber(); + } + + if (sortingMethod === SORTING_METHODS.BY_MORE_PERMISSIONS) { + return user2.roles.length - user1.roles.length; + } + if (sortingMethod === SORTING_METHODS.BY_LESS_PERMISSIONS) { + return user1.roles.length - user2.roles.length; + } + + return 0; + }); + }, [sortingMethod, members, isContributorsSection]); + + return { + sortedMembers: sortedUsers, + handleSortingMethodChange: setSortingMethod, + sortingMethod, + }; +}; + +export default useColonyMembersSorting; diff --git a/src/modules/externalUrls.ts b/src/modules/externalUrls.ts index d26ffd2186..3afe73ddf3 100644 --- a/src/modules/externalUrls.ts +++ b/src/modules/externalUrls.ts @@ -13,6 +13,8 @@ export const NETWORK_RELEASES = `https://github.com/JoinColony/colonyNetwork/rel export const ETHERSCAN_CONVERSION_RATE = `https://api.etherscan.io/api?module=stats&action=ethprice`; export const ETH_GAS_STATION = `https://ethgasstation.info/json/ethgasAPI.json`; export const XDAI_GAS_STATION = `https://blockscout.com/xdai/mainnet/api/v1/gas-price-oracle`; +export const getBlockscoutUserURL = (userAddress: string) => + `https://blockscout.com/xdai/mainnet/address/${userAddress}/transactions`; /* * Coin Machine diff --git a/src/modules/users/components/ConnectWalletWizard/StepGanache/StepGanache.css b/src/modules/users/components/ConnectWalletWizard/StepGanache/StepGanache.css index c4826bc2a0..ccb3dd8ca2 100644 --- a/src/modules/users/components/ConnectWalletWizard/StepGanache/StepGanache.css +++ b/src/modules/users/components/ConnectWalletWizard/StepGanache/StepGanache.css @@ -18,3 +18,11 @@ composes: content; margin-top: 30px; } + +.content button i { + min-width: 10px; +} + +.content button > div { + height: 21px; +} diff --git a/src/utils/hooks/useAvatarDisplayCounter.ts b/src/utils/hooks/useAvatarDisplayCounter.ts index ce74eda6fd..31ab7d79c6 100644 --- a/src/utils/hooks/useAvatarDisplayCounter.ts +++ b/src/utils/hooks/useAvatarDisplayCounter.ts @@ -1,9 +1,11 @@ -import Maybe from 'graphql/tsutils/Maybe'; import { useMemo } from 'react'; +import Maybe from 'graphql/tsutils/Maybe'; + +import { ColonyContributor, ColonyWatcher } from '~data/index'; const useAvatarDisplayCounter = ( maxAvatars: number, - members: Maybe, + members: ColonyContributor[] | ColonyWatcher[] | Maybe, isLastAvatarIncluded = true, ) => { const avatarsDisplaySplitRules = useMemo(() => {