Dwayne Harris 5 years ago
parent
commit
f4b6e0957f
  1. 10
      package-lock.json
  2. 2
      package.json
  3. 7
      src/actions/authentication.ts
  4. 50
      src/actions/directory.ts
  5. 2
      src/api/fetch.ts
  6. 15
      src/components/app/app.scss
  7. 4
      src/components/create-group-form/create-group-form.tsx
  8. 23
      src/components/forms/select-field/select-field.tsx
  9. 24
      src/components/forms/textarea-field/index.ts
  10. 50
      src/components/forms/textarea-field/textarea-field.tsx
  11. 99
      src/components/group-invitations/group-invitations.tsx
  12. 38
      src/components/group-invitations/index.ts
  13. 7
      src/components/group-logs/group-logs.tsx
  14. 0
      src/components/member-list/index.ts
  15. 13
      src/components/member-list/member-list-item/index.tsx
  16. 2
      src/components/member-list/member-list.tsx
  17. 36
      src/components/pages/group-admin/group-admin.tsx
  18. 19
      src/components/pages/group-admin/index.ts
  19. 34
      src/components/pages/group/group.tsx
  20. 17
      src/hooks/index.ts
  21. 7
      src/selectors/authentication.ts
  22. 26
      src/selectors/directory.ts
  23. 7
      src/selectors/entities.ts
  24. 12
      src/store/schemas.ts
  25. 12
      src/types/entities.ts
  26. 2
      src/types/store.ts
  27. 6
      src/utils/index.ts
  28. 121
      src/utils/normalization.ts

10
package-lock.json

@ -5402,11 +5402,6 @@
"sort-keys": "^1.0.0"
}
},
"normalizr": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/normalizr/-/normalizr-3.4.1.tgz",
"integrity": "sha512-gei+tJucERU8vYN6TFQL2k5YMLX2Yh7nlylKMJC65+Uu/LS3xQCDJc8cies72aHouycKYyVgcnyLRbaJsigXKw=="
},
"npm-run-all": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz",
@ -6193,6 +6188,11 @@
}
}
},
"re-reselect": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/re-reselect/-/re-reselect-3.4.0.tgz",
"integrity": "sha512-JsecfN+JlckncVXTWFWjn0Vk6uInl8GSf4eEd9tTk5qXHlgqkPdILpnYpgZcISXNYAzvfvsCZviaDk8AxyS5sg=="
},
"react": {
"version": "16.9.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.9.0.tgz",

2
package.json

@ -56,7 +56,7 @@
"history": "^4.10.1",
"lodash": "^4.17.15",
"moment": "^2.24.0",
"normalizr": "^3.4.1",
"re-reselect": "^3.4.0",
"react": "^16.9.0",
"react-avatar-editor": "^11.0.7",
"react-dom": "^16.9.0",

7
src/actions/authentication.ts

@ -1,10 +1,9 @@
import { Action } from 'redux'
import { normalize } from 'normalizr'
import { apiFetch } from 'src/api'
import { setEntities } from 'src/actions/entities'
import { startRequest, finishRequest } from 'src/actions/requests'
import { userSchema } from 'src/store/schemas'
import { normalize } from 'src/utils/normalization'
import {
LOCAL_STORAGE_ACCESS_TOKEN_KEY,
@ -12,7 +11,7 @@ import {
LOCAL_STORAGE_ACCESS_TOKEN_AT_KEY,
} from 'src/constants'
import { AppThunkAction, Entity, RequestKey } from 'src/types'
import { AppThunkAction, Entity, RequestKey, EntityType } from 'src/types'
export interface SetCheckedAction extends Action {
type: 'AUTHENTICATION_SET_CHECKED'
@ -60,7 +59,7 @@ export const fetchSelf = (): AppThunkAction => async dispatch => {
path: '/api/self',
})
const result = normalize(self, userSchema)
const result = normalize([self], EntityType.User)
dispatch(setEntities(result.entities))
dispatch(setUser(self.id))

50
src/actions/directory.ts

@ -1,12 +1,11 @@
import { Action } from 'redux'
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, userSchema, logSchema } from 'src/store/schemas'
import { objectToQuerystring } from 'src/utils'
import { normalize } from 'src/utils/normalization'
import { AppThunkAction, Entity, RequestKey, EntityType, User } from 'src/types'
export interface SetGroupsAction extends Action {
@ -72,10 +71,10 @@ export const fetchGroups = (sort?: string, continuation?: string): AppThunkActio
path: `/api/groups?${objectToQuerystring({ sort, continuation })}`,
})
const groups = normalize(response.groups, [groupSchema])
const groups = normalize(response.groups, EntityType.Group)
dispatch(setEntities(groups.entities))
dispatch(setGroups(groups.result))
dispatch(setGroups(groups.keys))
if (response.continuation) {
dispatch(setContinuation(response.continuation))
@ -101,7 +100,7 @@ export const fetchGroupMembers = (id: string, type?: string, continuation?: stri
path: `/api/group/${id}/members?${objectToQuerystring({ type, continuation })}`,
})
const users = normalize(response.members, [userSchema])
const users = normalize(response.members, EntityType.User)
dispatch(setEntities(users.entities))
dispatch(finishRequest(RequestKey.FetchGroupMembers, true))
@ -124,7 +123,7 @@ export const fetchLogs = (id: string, continuation?: string): AppThunkAction =>
path: `/api/group/${id}/logs?${objectToQuerystring({ continuation })}`,
})
const users = normalize(response.logs, [logSchema])
const users = normalize(response.logs, EntityType.Log)
dispatch(setEntities(users.entities))
dispatch(finishRequest(RequestKey.FetchGroupLogs, true))
@ -158,3 +157,42 @@ export const createInvitation = (id: string, expiration?: number, limit?: number
throw err
}
}
interface InvitationsResponse {
invitations: Entity[]
continuation?: string
}
export const fetchInvitations = (id: string): AppThunkAction => async dispatch => {
dispatch(startRequest(RequestKey.FetchInvitations))
try {
const response = await apiFetch<InvitationsResponse>({
path: `/api/group/${id}/invitations`,
})
const invitations = normalize(response.invitations, EntityType.Invitation)
dispatch(setEntities(invitations.entities))
dispatch(finishRequest(RequestKey.FetchInvitations, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchInvitations, false))
throw err
}
}
export const updateGroup = (id: string, updates: object): AppThunkAction => async dispatch => {
dispatch(startRequest(RequestKey.UpdateGroup))
try {
await apiFetch({
path: `/api/group/${id}`,
method: 'put',
body: updates,
})
dispatch(finishRequest(RequestKey.UpdateGroup, true))
} catch (err) {
dispatch(finishRequest(RequestKey.UpdateGroup, false))
throw err
}
}

2
src/api/fetch.ts

@ -31,7 +31,7 @@ interface ErrorResponse {
errors?: FormError[]
}
type APIFetch = <T>(options: FetchOptions) => Promise<T>
type APIFetch = <T = void>(options: FetchOptions) => Promise<T>
const mapErrorsToFormNotifications = (errors?: FormError[]): FormNotification[] => {
if (!errors) return []

15
src/components/app/app.scss

@ -27,6 +27,7 @@ $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/table.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";
@ -87,3 +88,17 @@ div.group-list-item {
margin: 10px 0px;
padding: 20px;
}
div.member {
border: solid 1px $grey-lighter;
min-width: 200px;
padding: 1rem;
}
div.invitation-options {
display: flex;
}
div.invitation-options > div {
margin-right: 20px;
}

4
src/components/create-group-form/create-group-form.tsx

@ -1,7 +1,7 @@
import React, { FC, FocusEventHandler } from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUpload } from '@fortawesome/free-solid-svg-icons'
import { faUpload, faIdCard } from '@fortawesome/free-solid-svg-icons'
import CheckboxField from '../forms/checkbox-field'
import TextField from '../forms/text-field'
@ -23,7 +23,7 @@ const CreateGroupForm: FC<Props> = ({ checkAvailability }) => {
<TextField name="group-name" label="Community Name" onBlur={checkAvailability} />
<br />
<SelectField name="group-registration" label="Registration" options={registrationOptions} />
<SelectField name="group-registration" label="Registration" options={registrationOptions} icon={faIdCard} />
<br />
<div className="field">

23
src/components/forms/select-field/select-field.tsx

@ -1,6 +1,8 @@
import React, { FC } from 'react'
import classNames from 'classnames'
import noop from 'lodash/noop'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { notificationTypeToClassName } from 'src/utils'
import { FormNotification, ClassDictionary } from 'src/types'
@ -15,6 +17,7 @@ export interface Props {
options: SelectOptions
value?: string
notification?: FormNotification
icon?: IconDefinition
setValue?: (value: string) => void
}
@ -23,10 +26,11 @@ const SelectField: FC<Props> = ({
options,
value,
notification,
icon,
setValue = noop,
}) => {
const opts = Object.entries(options)
const controlClassDictionary: ClassDictionary = { select: true }
const controlClassDictionary: ClassDictionary = { control: true }
const helpClassDictionary: ClassDictionary = { help: true }
if (notification) {
@ -36,13 +40,24 @@ const SelectField: FC<Props> = ({
helpClassDictionary[ncn] = true
}
if (icon) {
controlClassDictionary['has-icons-left'] = true
}
return (
<div className="field">
<label className="label">{label}</label>
<div className={classNames(controlClassDictionary)}>
<select value={value} onChange={e => setValue(e.target.value)}>
{opts.map(([key, value]) => <option key={key} value={key}>{value}</option>)}
</select>
<div className="select">
<select value={value} onChange={e => setValue(e.target.value)}>
{opts.map(([key, value]) => <option key={key} value={key}>{value}</option>)}
</select>
</div>
{icon &&
<div className="icon is-small is-left">
<FontAwesomeIcon icon={icon} />
</div>
}
</div>
{notification &&
<p className={classNames(helpClassDictionary)}>{notification.message}</p>

24
src/components/forms/textarea-field/index.ts

@ -0,0 +1,24 @@
import { Dispatch } from 'redux'
import { connect } from 'react-redux'
import { setFieldValue } from 'src/actions/forms'
import { getFieldValue, getFieldNotification } from 'src/selectors/forms'
import { AppState } from 'src/types'
import TextareaField, { Props } from './textarea-field'
const mapStateToProps = (state: AppState, ownProps: Props) => ({
value: getFieldValue<string>(state, ownProps.name, ''),
notification: getFieldNotification(state, ownProps.name),
})
const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => ({
setValue: (value: string) => {
dispatch(setFieldValue(ownProps.name, value))
}
})
export default connect(
mapStateToProps,
mapDispatchToProps
)(TextareaField)

50
src/components/forms/textarea-field/textarea-field.tsx

@ -0,0 +1,50 @@
import React, { FC, FocusEventHandler } from 'react'
import classNames from 'classnames'
import noop from 'lodash/noop'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { notificationTypeToClassName } from 'src/utils'
import { FormNotification, ClassDictionary } from 'src/types'
export interface Props {
name: string
label: string
placeholder?: string
value?: string
notification?: FormNotification
setValue?: (value: string) => void
onBlur?: FocusEventHandler
}
const TextField: FC<Props> = ({
label,
placeholder,
value = '',
notification,
setValue = noop,
onBlur = noop,
}) => {
const helpClassDictionary: ClassDictionary = { help: true }
const inputClassDictionary: ClassDictionary = { textarea: true }
if (notification) {
const ncn = notificationTypeToClassName(notification.type)
helpClassDictionary[ncn] = true
inputClassDictionary[ncn] = true
}
return (
<div className="field">
<label className="label">{label}</label>
<div className="control">
<textarea className={classNames(inputClassDictionary)} placeholder={placeholder} value={value} onChange={(e) => setValue(e.target.value)} onBlur={onBlur} />
</div>
{notification &&
<p className={classNames(helpClassDictionary)}>{notification.message}</p>
}
</div>
)
}
export default TextField

99
src/components/group-invitations/group-invitations.tsx

@ -0,0 +1,99 @@
import React, { FC, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheckCircle, faStopwatch, faPauseCircle } from '@fortawesome/free-solid-svg-icons'
import noop from 'lodash/noop'
import moment from 'moment'
import { Invitation } from 'src/types'
import SelectField from 'src/components/forms/select-field'
export interface Props {
group: string
invitations?: Invitation[]
expiration?: string
limit?: string
fetchInvitations?: () => void
createInvitation?: (expiration: string, limit: string) => void
}
const GroupInvitations: FC<Props> = ({
group,
invitations = [],
expiration = '0',
limit = '0',
fetchInvitations = noop,
createInvitation = noop,
}) => {
useEffect(() => {
if (invitations.length === 0) fetchInvitations()
}, [group, fetchInvitations])
const expirationOptions = {
'0': 'No Expiration',
'1': '1 Day',
'7': '1 Week',
'30': '1 Month',
}
const limitOptions = {
'0': 'No Limit',
'1': '1',
'5': '5',
'20': '20',
}
return (
<div>
<h1 className="title is-size-4">Invitations</h1>
<h2 className="subtitle is-size-6">Create an invitation for someone to create a new account in this Community.</h2>
<div className="invitation-options">
<SelectField name="expiration" label="Expires" options={expirationOptions} icon={faStopwatch} />
<SelectField name="limit" label="Uses" options={limitOptions} icon={faPauseCircle} />
</div>
<div className="field">
<div className="control">
<button className="button is-primary" onClick={() => createInvitation(expiration, limit)}>
<span className="icon is-small">
<FontAwesomeIcon icon={faCheckCircle} />
</span>
<span>Create</span>
</button>
</div>
</div>
<br />
{invitations.length > 0 &&
<table className="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<thead>
<tr>
<th>Code</th>
<th>Created By</th>
<th>Uses</th>
<th>Limit</th>
<th>Expires</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{invitations.map(invitation => (
<tr key={invitation.id}>
<td>{invitation.id}</td>
<td><Link to={`/u/${invitation.user.id}`}>{invitation.user.id}</Link></td>
<td>{invitation.uses || 0}</td>
<td>{invitation.limit || 'None'}</td>
<td>{invitation.expiration || 'Never'}</td>
<td>{moment(invitation.created).format('MMMM Do YYYY, h:mm:ss a')}</td>
</tr>
))}
</tbody>
</table>
}
</div>
)
}
export default GroupInvitations

38
src/components/group-invitations/index.ts

@ -0,0 +1,38 @@
import { connect } from 'react-redux'
import moment from 'moment'
import { handleApiError } from 'src/api/errors'
import { fetchInvitations, createInvitation } from 'src/actions/directory'
import { getInvitations } from 'src/selectors/directory'
import { getFieldValue } from 'src/selectors/forms'
import { AppState, AppThunkDispatch } from 'src/types'
import GroupInvitations, { Props } from './group-invitations'
const mapStateToProps = (state: AppState) => ({
invitations: getInvitations(state),
expiration: getFieldValue<string>(state, 'expiration', '0'),
limit: getFieldValue<string>(state, 'limit', '0'),
})
const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({
fetchInvitations: () => {
try {
dispatch(fetchInvitations(ownProps.group))
} catch (err) {
handleApiError(err, dispatch)
}
},
createInvitation: async (expiration: string, limit: string) => {
try {
await dispatch(createInvitation(ownProps.group, moment().add(expiration, 'day').valueOf(), parseInt(limit, 10)))
await dispatch(fetchInvitations(ownProps.group))
} catch (err) {
handleApiError(err, dispatch)
}
},
})
export default connect(
mapStateToProps,
mapDispatchToProps
)(GroupInvitations)

7
src/components/group-logs/group-logs.tsx

@ -1,4 +1,5 @@
import React, { FC, useEffect } from 'react'
import { Link } from 'react-router-dom'
import noop from 'lodash/noop'
import moment from 'moment'
import { GroupLog } from 'src/types'
@ -12,10 +13,10 @@ export interface Props {
const MemberList: FC<Props> = ({ group, logs = [], fetchLogs = noop }) => {
useEffect(() => {
if (logs.length === 0) fetchLogs()
}, [group])
}, [group, fetchLogs])
return (
<table className="table">
<table className="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<thead>
<tr>
<th>Who</th>
@ -26,7 +27,7 @@ const MemberList: FC<Props> = ({ group, logs = [], fetchLogs = noop }) => {
<tbody>
{logs.map(log => (
<tr>
<td>{log.user.id}</td>
<td><Link to={`/u/${log.user.id}`}>{log.user.id}</Link></td>
<td>{log.content}</td>
<td>{moment(log.created).format('MMMM Do YYYY, h:mm:ss a')}</td>
</tr>

0
src/components/member-list/index.tsx → src/components/member-list/index.ts

13
src/components/member-list/member-list-item/index.tsx

@ -27,11 +27,14 @@ const MemberListItem: FC<Props> = ({ member }) => {
<article className="media">
<div className="media-content">
<div className="content">
<Link to={`/u/${member.id}`} className="is-size-5">{member.name}</Link>
<br />
<Link to={`/u/${member.id}`} className="is-size-6 has-text-red">@{member.id}</Link>
<br />
<span className={classNames(tagClassDictionary)}>{capitalize(member.membership as string)}</span>
<div className="member">
<Link to={`/u/${member.id}`} className="is-size-5">{member.name}</Link>
<br />
<Link to={`/u/${member.id}`} className="is-size-6 is-red">@{member.id}</Link>
<br />
<span className={classNames(tagClassDictionary)}>{capitalize(member.membership as string)}</span>
</div>
</div>
</div>
</article>

2
src/components/member-list/member-list.tsx

@ -13,7 +13,7 @@ export interface Props {
const MemberList: FC<Props> = ({ group, members = [], fetchGroupMembers = noop }) => {
useEffect(() => {
fetchGroupMembers()
}, [group])
}, [group, fetchGroupMembers])
return (
<div className="is-flex">

36
src/components/pages/group-admin/group-admin.tsx

@ -4,11 +4,14 @@ import { RouteComponentProps, Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons'
import { useDeepCompareEffect } from 'src/hooks'
import { setTitle } from 'src/utils'
import { Group, GroupMembershipType, User } from 'src/types'
import { Group, GroupMembershipType } from 'src/types'
import PageHeader from 'src/components/page-header'
import TextareaField from 'src/components/forms/textarea-field'
import MemberList from 'src/components/member-list'
import GroupInvitations from 'src/components/group-invitations'
import GroupLogs from 'src/components/group-logs'
import Loading from 'src/components/pages/loading'
@ -24,14 +27,18 @@ interface Params {
export interface Props extends RouteComponentProps<Params> {
group?: Group
about?: string
fetchGroup: () => void
createInvitation: (expiration: number, limit: number) => void
initForm: (group: Group) => void
updateGroup: (about: string) => void
}
const GroupAdmin: FC<Props> = ({
group,
about = '',
fetchGroup,
createInvitation,
initForm,
updateGroup,
match,
history,
}) => {
@ -55,7 +62,7 @@ const GroupAdmin: FC<Props> = ({
fetchGroup()
}, [])
useEffect(() => {
useDeepCompareEffect(() => {
if (group && group.membership) {
if (group.membership !== GroupMembershipType.Admin) {
history.push(`/c/${group.id}`)
@ -63,8 +70,9 @@ const GroupAdmin: FC<Props> = ({
}
setTitle(`${group.name} Administration`)
initForm(group)
}
}, [group])
}, [group, initForm])
if (!group) return <Loading />
@ -105,15 +113,10 @@ const GroupAdmin: FC<Props> = ({
</div>
<br />
<div className="field">
<label className="label">About</label>
<div className="control">
<textarea className="textarea" placeholder="About This Community" value={group.about as string}></textarea>
</div>
</div>
<TextareaField name="about" label="About" placeholder="About this Community" />
<br />
<button className="button is-primary">
<button className="button is-primary" onClick={e => updateGroup(about)}>
<span className="icon is-small">
<FontAwesomeIcon icon={faCheckCircle} />
</span>
@ -122,7 +125,14 @@ const GroupAdmin: FC<Props> = ({
</div>
}
{tab === 'members' && <MemberList group={match.params.id} />}
{tab === 'members' &&
<div>
<GroupInvitations group={match.params.id} />
<hr />
<MemberList group={match.params.id} />
</div>
}
{tab === 'logs' && <GroupLogs group={match.params.id} />}
</div>
</div>

19
src/components/pages/group-admin/index.ts

@ -1,13 +1,16 @@
import { connect } from 'react-redux'
import { handleApiError } from 'src/api/errors'
import { fetchGroup, createInvitation } from 'src/actions/directory'
import { fetchGroup, updateGroup } from 'src/actions/directory'
import { getEntity } from 'src/selectors/entities'
import { getFieldValue } from 'src/selectors/forms'
import { AppState, EntityType, Group, AppThunkDispatch } from 'src/types'
import GroupAdmin, { Props } from './group-admin'
import { initForm, initField, setFieldValue } from 'src/actions/forms'
const mapStateToProps = (state: AppState, ownProps: Props) => ({
group: getEntity<Group>(state, EntityType.Group, ownProps.match.params.id),
about: getFieldValue<string>(state, 'about'),
})
const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({
@ -18,13 +21,21 @@ const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({
handleApiError(err, dispatch, ownProps.history)
}
},
createInvitation: (expiration: number, limit: number) => {
initForm: (group: Group) => {
dispatch(initForm())
dispatch(initField('about'))
dispatch(initField('expiration'))
dispatch(initField('limit'))
dispatch(setFieldValue('about', group.about as string))
},
updateGroup: (about: string) => {
try {
dispatch(createInvitation(ownProps.match.params.id, expiration, limit))
dispatch(updateGroup(ownProps.match.params.id, { about }))
dispatch(fetchGroup(ownProps.match.params.id))
} catch (err) {
handleApiError(err, dispatch, ownProps.history)
}
},
}
})
export default connect(

34
src/components/pages/group/group.tsx

@ -45,25 +45,31 @@ const GroupPage: FC<Props> = ({ group, fetchGroup }) => {
<div className="main-content">
<GroupInfo group={group} />
<br /><br />
<div className="buttons">
{isAdmin &&
<Link to={`/c/${group.id}/admin/`} className="button is-warning">
<div className="centered-content">
<p>{group.about}</p>
<br />
<div className="buttons">
{isAdmin &&
<Link to={`/c/${group.id}/admin/`} className="button is-warning">
<span className="icon is-small">
<FontAwesomeIcon icon={faEdit} />
</span>
<span>Edit {group.name}</span>
</Link>
}
<Link to={`/c/${group.id}/register`} className="button is-success">
<span className="icon is-small">
<FontAwesomeIcon icon={faEdit} />
<FontAwesomeIcon icon={faUserCheck} />
</span>
<span>Edit {group.name}</span>
<span>Create an Account</span>
</Link>
}
<Link to={`/c/${group.id}/register`} className="button is-success">
<span className="icon is-small">
<FontAwesomeIcon icon={faUserCheck} />
</span>
<span>Create an Account</span>
</Link>
</div>
</div>
</div>
</div>
)

17
src/hooks/index.ts

@ -1,8 +1,23 @@
import { useEffect } from 'react'
import { useEffect, useRef, EffectCallback } from 'react'
import { History } from 'history'
import isEqual from 'lodash/isEqual'
export const useAuthenticationCheck = (checked: boolean, authenticated: boolean, history: History) => {
useEffect(() => {
if (checked && !authenticated) history.push('/login')
}, [checked, authenticated])
}
const useDeepCompareMemoize = (value: any) => {
const ref = useRef()
if (!isEqual(value, ref.current)) {
ref.current = value
}
return ref.current
}
export const useDeepCompareEffect = (callback: EffectCallback, deps?: readonly any[] | undefined) => {
useEffect(callback, useDeepCompareMemoize(deps))
}

7
src/selectors/authentication.ts

@ -1,9 +1,8 @@
import { denormalize } from 'normalizr'
import { createSelector } from 'reselect'
import { userSchema } from 'src/store/schemas'
import { getEntityStore } from './entities'
import { AppState, User } from 'src/types'
import { denormalize } from 'src/utils/normalization'
import { AppState, User, EntityType } from 'src/types'
export const getChecked = (state: AppState) => state.authentication.checked
export const getAuthenticated = (state: AppState) => state.authentication.authenticated
@ -13,6 +12,6 @@ export const getAuthenticatedUser = createSelector(
[getEntityStore, getAuthenticatedUserId],
(entities, userId) => {
if (!userId) return
return denormalize(userId, userSchema, entities) as User
return denormalize([userId], EntityType.User, entities)[0] as User
}
)

26
src/selectors/directory.ts

@ -1,21 +1,33 @@
import { denormalize } from 'normalizr'
import { createSelector } from 'reselect'
import filter from 'lodash/filter'
import { groupSchema, userSchema, logSchema } from '../store/schemas'
import { getEntityStore } from './entities'
import { AppState, Group, User, EntityType, GroupLog } from 'src/types'
import { denormalize } from 'src/utils/normalization'
import { AppState, Group, User, EntityType, GroupLog, Invitation } from 'src/types'
export const getGroupIds = (state: AppState) => state.directory.groups
export const getGroups = createSelector(
[getEntityStore, getGroupIds],
(entities, groups) => denormalize(groups, [groupSchema], entities) as Group[]
(entities, groups) => denormalize(groups, EntityType.Group, 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[]
if (!users) return []
return denormalize(Object.values(users).filter(user => user.group === group).map(user => user.id), EntityType.User, state.entities) as User[]
}
export const getLogs = (state: AppState) => {
const logs = state.entities[EntityType.Log]
if (!logs) return []
return denormalize(Object.keys(logs), EntityType.Log, state.entities) as GroupLog[]
}
export const getLogs = (state: AppState) => denormalize(state.entities[EntityType.Log], [logSchema], state.entities) as GroupLog[]
export const getInvitations = (state: AppState) => {
const invitations = state.entities[EntityType.Invitation]
if (!invitations) return []
return denormalize(Object.keys(invitations), EntityType.Invitation, state.entities) as Invitation[]
}

7
src/selectors/entities.ts

@ -1,5 +1,4 @@
import { denormalize } from 'normalizr'
import { userSchema, groupSchema } from 'src/store/schemas'
import { denormalize } from 'src/utils/normalization'
import { AppState, Entity, EntityType } from '../types'
export const getEntityStore = (state: AppState) => state.entities
@ -10,9 +9,9 @@ export const getEntity = <T extends Entity = Entity>(state: AppState, type: Enti
switch (type) {
case EntityType.User:
return denormalize(id, userSchema, entities) as T
return denormalize([id], EntityType.User, entities)[0] as T
case EntityType.Group:
return denormalize(id, groupSchema, entities) as T
return denormalize([id], EntityType.Group, entities)[0] as T
default:
return
}

12
src/store/schemas.ts

@ -1,12 +0,0 @@
import { schema } from 'normalizr'
import { EntityType } from 'src/types'
export const groupSchema = new schema.Entity(EntityType.Group)
export const userSchema = new schema.Entity(EntityType.User, {
group: groupSchema,
})
export const logSchema = new schema.Entity(EntityType.Log, {
user: userSchema,
})

12
src/types/entities.ts

@ -1,7 +1,8 @@
export enum EntityType {
User = 'users',
Group = 'groups',
Log = 'log',
Log = 'logs',
Invitation = 'invitations',
}
export enum GroupMembershipType {
@ -11,7 +12,7 @@ export enum GroupMembershipType {
}
export interface Entity {
[key: string]: string | number | boolean | object | any[]
[key: string]: any
id: string
created: number
}
@ -32,7 +33,12 @@ export type User = Entity & {
export type GroupLog = Entity & {
user: User
content: string
created: number
}
export type Invitation = Entity & {
user: User
uses: number
expires: number
}
export interface EntityCollection {

2
src/types/store.ts

@ -14,11 +14,13 @@ export enum RequestKey {
FetchGroupAvailability = 'fetch_group_availability',
FetchUserAvailability = 'fetch_user_availability',
CreateGroup = 'create_group',
UpdateGroup = 'update_group',
Register = 'register',
Authenticate = 'authenticate',
FetchGroupMembers = 'fetch_group_members',
FetchGroupLogs = 'fetch_group_logs',
CreateInvitation = 'create_invitation',
FetchInvitations = 'fetch_invitations',
}
export type FormValue = string | number | boolean

6
src/utils/index.ts

@ -1,4 +1,8 @@
import { NotificationType, Form, FormValue } from '../types'
import {
NotificationType,
Form,
FormValue,
} from '../types'
export function notificationTypeToClassName(type: NotificationType): string {
switch (type) {

121
src/utils/normalization.ts

@ -0,0 +1,121 @@
import {
EntityType,
EntityStore,
Entity,
User,
Invitation,
GroupLog,
} from '../types'
import compact from 'lodash/compact'
export interface NormalizeResult {
keys: string[]
entities: EntityStore
}
function set(type: EntityType, store: EntityStore, entity?: Entity): string | undefined {
if (!entity) return
const collection = store[type] || {}
const existing = collection[entity.id] || {}
collection[entity.id] = {
...existing,
...entity,
}
store[type] = collection
return entity.id
}
function get(type: EntityType, store: EntityStore, id?: string): Entity | undefined {
if (!id) return
const collection = store[type] || {}
return collection[id]
}
export function normalize(entities: Entity[], type: EntityType): NormalizeResult {
let keys: Array<string | undefined> = []
const newStore: EntityStore = {}
switch (type) {
case EntityType.User:
keys = entities.map(entity => {
const user = entity as User
return set(type, newStore, {
...user,
group: set(EntityType.Group, newStore, user.group),
})
})
break
case EntityType.Group:
keys = entities.map(entity => set(type, newStore, entity))
break
case EntityType.Invitation:
keys = entities.map(entity => {
const invitation = entity as Invitation
return set(type, newStore, {
...invitation,
user: set(EntityType.Group, newStore, invitation.user),
})
})
break
case EntityType.Log:
keys = entities.map(entity => {
const log = entity as GroupLog
return set(type, newStore, {
...log,
user: set(EntityType.Group, newStore, log.user),
})
})
break
}
return {
keys: compact(keys),
entities: newStore,
}
}
export function denormalize(keys: string[], type: EntityType, store: EntityStore): Entity[] {
const entities = keys.map(key => {
switch (type) {
case EntityType.User:
const user = get(type, store, key)
if (!user) return
return {
...user,
group: get(EntityType.Group, store, user.group),
}
case EntityType.Group:
return get(type, store, key)
case EntityType.Invitation:
const invitation = get(type, store, key)
if (!invitation) return
return {
...invitation,
user: get(EntityType.User, store, invitation.user)
}
case EntityType.Log:
const log = get(type, store, key)
if (!log) return
return {
...log,
user: get(EntityType.User, store, log.user)
}
}
})
return compact(entities)
}
Loading…
Cancel
Save