Dwayne Harris
5 years ago
28 changed files with 550 additions and 95 deletions
-
10package-lock.json
-
2package.json
-
7src/actions/authentication.ts
-
50src/actions/directory.ts
-
2src/api/fetch.ts
-
15src/components/app/app.scss
-
4src/components/create-group-form/create-group-form.tsx
-
23src/components/forms/select-field/select-field.tsx
-
24src/components/forms/textarea-field/index.ts
-
50src/components/forms/textarea-field/textarea-field.tsx
-
99src/components/group-invitations/group-invitations.tsx
-
38src/components/group-invitations/index.ts
-
7src/components/group-logs/group-logs.tsx
-
0src/components/member-list/index.ts
-
13src/components/member-list/member-list-item/index.tsx
-
2src/components/member-list/member-list.tsx
-
36src/components/pages/group-admin/group-admin.tsx
-
19src/components/pages/group-admin/index.ts
-
34src/components/pages/group/group.tsx
-
17src/hooks/index.ts
-
7src/selectors/authentication.ts
-
26src/selectors/directory.ts
-
7src/selectors/entities.ts
-
12src/store/schemas.ts
-
12src/types/entities.ts
-
2src/types/store.ts
-
6src/utils/index.ts
-
121src/utils/normalization.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) |
@ -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 |
@ -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 |
@ -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) |
@ -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)) |
|||
} |
@ -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[] |
|||
} |
@ -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, |
|||
}) |
@ -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) |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue