diff --git a/src/actions/directory.ts b/src/actions/directory.ts index 8baf098..08bb694 100644 --- a/src/actions/directory.ts +++ b/src/actions/directory.ts @@ -4,10 +4,10 @@ import { normalize } from 'normalizr' import { apiFetch } from 'src/api' import { setEntity, setEntities } from 'src/actions/entities' import { startRequest, finishRequest } from 'src/actions/requests' -import { groupSchema } from 'src/store/schemas' +import { groupSchema, userSchema } from 'src/store/schemas' import { objectToQuerystring } from 'src/utils' -import { AppThunkAction, Entity, RequestKey, EntityType } from 'src/types' +import { AppThunkAction, Entity, RequestKey, EntityType, User } from 'src/types' export interface SetGroupsAction extends Action { type: 'DIRECTORY_SET_GROUPS' @@ -69,7 +69,7 @@ export const fetchGroups = (sort?: string, continuation?: string): AppThunkActio try { const response = await apiFetch({ - path: `/api/groups?${objectToQuerystring({ sort, continuation })}` + path: `/api/groups?${objectToQuerystring({ sort, continuation })}`, }) const groups = normalize(response.groups, [groupSchema]) @@ -87,3 +87,26 @@ export const fetchGroups = (sort?: string, continuation?: string): AppThunkActio throw err } } + +interface GroupMembersResponse { + members: User[] + continuation?: string +} + +export const fetchGroupMembers = (id: string, type?: string, continuation?: string): AppThunkAction => async dispatch => { + dispatch(startRequest(RequestKey.FetchGroupMembers)) + + try { + const response = await apiFetch({ + path: `/api/group/${id}/members?${objectToQuerystring({ type, continuation })}`, + }) + + const users = normalize(response.members, [userSchema]) + + dispatch(setEntities(users.entities)) + dispatch(finishRequest(RequestKey.FetchGroupMembers, true)) + } catch (err) { + dispatch(finishRequest(RequestKey.FetchGroupMembers, false)) + throw err + } +} diff --git a/src/api/fetch.ts b/src/api/fetch.ts index a423380..6f59191 100644 --- a/src/api/fetch.ts +++ b/src/api/fetch.ts @@ -95,7 +95,7 @@ export const apiFetch: APIFetch = async (options: FetchOptions) => { const refreshToken = localStorage.getItem(LOCAL_STORAGE_REFRESH_TOKEN_KEY) if (accessToken && refreshToken) { - const refreshResponse = await fetch('/api/refresh', { + const refreshResponse = await fetch(`${config.apiUrl}/api/refresh`, { headers: new Headers({ 'Content-Type': contentType, 'Authorization': `Bearer ${accessToken}` diff --git a/src/components/app/app.scss b/src/components/app/app.scss index 7d515ce..8e02761 100644 --- a/src/components/app/app.scss +++ b/src/components/app/app.scss @@ -27,14 +27,16 @@ $body-size: 14px; @import "../../../node_modules/bulma/sass/elements/icon.sass"; @import "../../../node_modules/bulma/sass/elements/notification.sass"; @import "../../../node_modules/bulma/sass/elements/other.sass"; +@import "../../../node_modules/bulma/sass/elements/tag.sass"; @import "../../../node_modules/bulma/sass/elements/title.sass"; @import "../../../node_modules/bulma/sass/layout/hero.sass"; @import "../../../node_modules/bulma/sass/components/level.sass"; @import "../../../node_modules/bulma/sass/components/media.sass"; +@import "../../../node_modules/bulma/sass/components/tabs.sass"; div#main-menu { - background-color: $primary; - border-left: 1px solid $grey-lighter; + background: linear-gradient(135deg, $primary, darken($primary, 20%)); + // border-left: 1px solid $grey-lighter; bottom: 0; display: flex; flex-direction: column; diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index ba56605..d413520 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -13,6 +13,7 @@ import About from '../pages/about' import Developers from '../pages/developers' import Directory from '../pages/directory' import Group from '../pages/group' +import GroupAdmin from '../pages/group-admin' import Home from '../pages/home' import Login from '../pages/login' import Register from '../pages/register' @@ -60,7 +61,8 @@ const App: FC = ({ collapsed, fetching, fetchSelf, setChecked }) => { - + + diff --git a/src/components/member-list/index.tsx b/src/components/member-list/index.tsx new file mode 100644 index 0000000..e642972 --- /dev/null +++ b/src/components/member-list/index.tsx @@ -0,0 +1,17 @@ +import React, { FC } from 'react' + +import { User } from 'src/types' + +import MemberListItem from './member-list-item' + +interface Props { + members: User[] +} + +const MemberList: FC = ({ members }) => ( +
+ {members.map(member => )} +
+) + +export default MemberList diff --git a/src/components/member-list/member-list-item/index.tsx b/src/components/member-list/member-list-item/index.tsx new file mode 100644 index 0000000..461175e --- /dev/null +++ b/src/components/member-list/member-list-item/index.tsx @@ -0,0 +1,41 @@ +import React, { FC } from 'react' +import { Link } from 'react-router-dom' +import classNames from 'classnames' +import capitalize from 'lodash/capitalize' + +import { User, GroupMembershipType, ClassDictionary } from 'src/types' + +interface Props { + member: User +} + +const MemberListItem: FC = ({ member }) => { + const tagClass = () => { + switch (member.membership as GroupMembershipType) { + case GroupMembershipType.Admin: return 'is-success' + case GroupMembershipType.Moderator: return 'is-warning' + case GroupMembershipType.Member: return 'is-info' + } + } + + const tagClassDictionary: ClassDictionary = { + tag: true, + [tagClass()]: true, + } + + return ( +
+
+
+ {member.name} +
+ @{member.id} +
+ {capitalize(member.membership as string)} +
+
+
+ ) +} + +export default MemberListItem diff --git a/src/components/pages/directory/directory.tsx b/src/components/pages/directory/directory.tsx index e5a0ff8..382062b 100644 --- a/src/components/pages/directory/directory.tsx +++ b/src/components/pages/directory/directory.tsx @@ -1,5 +1,7 @@ import React, { FC, useEffect } from 'react' import { Link } from 'react-router-dom' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPlusCircle } from '@fortawesome/free-solid-svg-icons' import { setTitle } from 'src/utils' import { Group } from 'src/types' @@ -32,7 +34,12 @@ const Directory: FC = ({ groups, fetchGroups }) => {

- Create your own Community + + + + + Create your own Community +

diff --git a/src/components/pages/group-admin/group-admin.tsx b/src/components/pages/group-admin/group-admin.tsx new file mode 100644 index 0000000..4fc2ebd --- /dev/null +++ b/src/components/pages/group-admin/group-admin.tsx @@ -0,0 +1,130 @@ +import React, { FC, useEffect } from 'react' +import { RouteComponentProps, Link } from 'react-router-dom' + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCheckCircle } from '@fortawesome/free-solid-svg-icons' + +import { setTitle } from 'src/utils' +import { Group, GroupMembershipType, User } from 'src/types' + +import PageHeader from 'src/components/page-header' +import MemberList from 'src/components/member-list' + +interface Tab { + id: string + label: string +} + +interface Params { + id: string + tab: string +} + +export interface Props extends RouteComponentProps { + group?: Group + members?: User[] + fetchGroup: () => void +} + +const GroupAdmin: FC = ({ group, members = [], fetchGroup, match, history }) => { + useEffect(() => { + fetchGroup() + }, []) + + useEffect(() => { + if (group && group.membership) { + if (group.membership !== GroupMembershipType.Admin) { + history.push(`/c/${group.id}`) + return + } + + setTitle(`${group.name} Administration`) + } + }, [group]) + + if (!group) { + return ( +
+ +
+
+ ) + } + + const selectedTab = match.params.tab ? match.params.tab : '' + const tabs: Tab[] = [ + { + id: '', + label: 'General', + }, + { + id: 'members', + label: 'Members', + } + ] + + return ( +
+ + +
+
+
+
    + {tabs.map(t => ( +
  • + + {t.label} + +
  • + ))} +
+
+ +
+ {selectedTab === '' && +
+
+ +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+
+
+ + +
+ } + + {match.params.tab === 'members' && + + } +
+
+
+
+ ) +} + +export default GroupAdmin diff --git a/src/components/pages/group-admin/index.ts b/src/components/pages/group-admin/index.ts new file mode 100644 index 0000000..27d2aa8 --- /dev/null +++ b/src/components/pages/group-admin/index.ts @@ -0,0 +1,29 @@ +import { connect } from 'react-redux' +import { handleApiError } from 'src/api/errors' +import { fetchGroup, fetchGroupMembers } from 'src/actions/directory' +import { getGroupMembers } from 'src/selectors/directory' +import { getEntity } from 'src/selectors/entities' +import { AppState, EntityType, Group, AppThunkDispatch } from 'src/types' + +import GroupAdmin, { Props } from './group-admin' + +const mapStateToProps = (state: AppState, ownProps: Props) => ({ + group: getEntity(state, EntityType.Group, ownProps.match.params.id), + members: getGroupMembers(state, ownProps.match.params.id), +}) + +const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({ + fetchGroup: () => { + try { + dispatch(fetchGroup(ownProps.match.params.id)) + dispatch(fetchGroupMembers(ownProps.match.params.id)) + } catch (err) { + handleApiError(err, dispatch, ownProps.history) + } + } +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(GroupAdmin) diff --git a/src/components/pages/group/group.tsx b/src/components/pages/group/group.tsx index 4402f1e..9c123a1 100644 --- a/src/components/pages/group/group.tsx +++ b/src/components/pages/group/group.tsx @@ -1,11 +1,11 @@ import React, { FC, useEffect } from 'react' -import { RouteComponentProps } from 'react-router-dom' +import { Link, RouteComponentProps } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faEdit } from '@fortawesome/free-solid-svg-icons' +import { faEdit, faUserCheck } from '@fortawesome/free-solid-svg-icons' import { setTitle } from 'src/utils' -import { Group } from 'src/types' +import { Group, GroupMembershipType } from 'src/types' import PageHeader from 'src/components/page-header' import GroupInfo from 'src/components/group-info' @@ -19,7 +19,7 @@ export interface Props extends RouteComponentProps { fetchGroup: () => void } -const Self: FC = ({ group, fetchGroup }) => { +const GroupPage: FC = ({ group, fetchGroup }) => { useEffect(() => { fetchGroup() }, []) @@ -37,6 +37,8 @@ const Self: FC = ({ group, fetchGroup }) => { ) } + const isAdmin = group.membership === GroupMembershipType.Admin + return (
@@ -45,19 +47,26 @@ const Self: FC = ({ group, fetchGroup }) => {

- {group.membership === 'admin' && -
- -
- } + + } + + + + + + Create an Account + +
) } -export default Self +export default GroupPage diff --git a/src/components/pages/login/login.tsx b/src/components/pages/login/login.tsx index 8ada18a..0979617 100644 --- a/src/components/pages/login/login.tsx +++ b/src/components/pages/login/login.tsx @@ -61,7 +61,12 @@ const Login: FC = ({
- + diff --git a/src/components/pages/self/self.tsx b/src/components/pages/self/self.tsx index 24e29f5..f385953 100644 --- a/src/components/pages/self/self.tsx +++ b/src/components/pages/self/self.tsx @@ -1,8 +1,10 @@ import React, { FC, useEffect } from 'react' import { RouteComponentProps } from 'react-router-dom' import moment from 'moment' -import { useAuthenticationCheck } from 'src/hooks' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faDoorOpen } from '@fortawesome/free-solid-svg-icons' +import { useAuthenticationCheck } from 'src/hooks' import { setTitle } from 'src/utils' import { User } from 'src/types' @@ -38,15 +40,20 @@ const Self: FC = ({ checked, authenticated, user, logout, history }) => {

- +

diff --git a/src/components/user-info/user-info.tsx b/src/components/user-info/user-info.tsx index b9b25ae..2ecfce5 100644 --- a/src/components/user-info/user-info.tsx +++ b/src/components/user-info/user-info.tsx @@ -20,8 +20,8 @@ const UserInfo: FC = ({ authenticated, user }) => { if (user.name) { return ( <> - {user.name} -    + {user.name} +
@{user.id} ) diff --git a/src/selectors/directory.ts b/src/selectors/directory.ts index 010a096..7a6da07 100644 --- a/src/selectors/directory.ts +++ b/src/selectors/directory.ts @@ -1,15 +1,19 @@ import { denormalize } from 'normalizr' import { createSelector } from 'reselect' +import filter from 'lodash/filter' -import { groupSchema } from '../store/schemas' +import { groupSchema, userSchema } from '../store/schemas' import { getEntityStore } from './entities' -import { AppState, Group } from 'src/types' +import { AppState, Group, User, EntityType } from 'src/types' export const getGroupIds = (state: AppState) => state.directory.groups export const getGroups = createSelector( [getEntityStore, getGroupIds], - (entities, groups) => { - return denormalize(groups, [groupSchema], entities) as Group[] - } + (entities, groups) => denormalize(groups, [groupSchema], entities) as Group[] ) + +export const getGroupMembers = (state: AppState, group: string) => { + const users = state.entities[EntityType.User] + return denormalize(filter(users, user => user.group === group), [userSchema], state.entities) as User[] +} diff --git a/src/types/entities.ts b/src/types/entities.ts index 5a3230a..87f3664 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -3,6 +3,12 @@ export enum EntityType { Group = 'groups', } +export enum GroupMembershipType { + Admin = 'admin', + Moderator = 'moderator', + Member = 'member', +} + export interface Entity { [key: string]: string | number | boolean | object | any[] id: string @@ -11,6 +17,7 @@ export interface Entity { export type Group = Entity & { name: string + membership?: GroupMembershipType } export type User = Entity & { diff --git a/src/types/store.ts b/src/types/store.ts index d1fca76..9f8607f 100644 --- a/src/types/store.ts +++ b/src/types/store.ts @@ -16,6 +16,7 @@ export enum RequestKey { CreateGroup = 'create_group', Register = 'register', Authenticate = 'authenticate', + FetchGroupMembers = 'fetch_group_members', } export type FormValue = string | number | boolean