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 { History } from 'history' |
||||
|
import isEqual from 'lodash/isEqual' |
||||
|
|
||||
export const useAuthenticationCheck = (checked: boolean, authenticated: boolean, history: History) => { |
export const useAuthenticationCheck = (checked: boolean, authenticated: boolean, history: History) => { |
||||
useEffect(() => { |
useEffect(() => { |
||||
if (checked && !authenticated) history.push('/login') |
if (checked && !authenticated) history.push('/login') |
||||
}, [checked, authenticated]) |
}, [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 { createSelector } from 'reselect' |
||||
import filter from 'lodash/filter' |
|
||||
|
|
||||
import { groupSchema, userSchema, logSchema } from '../store/schemas' |
|
||||
import { getEntityStore } from './entities' |
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 getGroupIds = (state: AppState) => state.directory.groups |
||||
|
|
||||
export const getGroups = createSelector( |
export const getGroups = createSelector( |
||||
[getEntityStore, getGroupIds], |
[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) => { |
export const getGroupMembers = (state: AppState, group: string) => { |
||||
const users = state.entities[EntityType.User] |
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