diff --git a/package-lock.json b/package-lock.json index 10a8ed1..80fae0c 100644 --- a/package-lock.json +++ b/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", diff --git a/package.json b/package.json index 4d982de..dc4535a 100644 --- a/package.json +++ b/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", diff --git a/src/actions/authentication.ts b/src/actions/authentication.ts index 120887c..ef7c558 100644 --- a/src/actions/authentication.ts +++ b/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)) diff --git a/src/actions/directory.ts b/src/actions/directory.ts index 5124cd7..8b6bd15 100644 --- a/src/actions/directory.ts +++ b/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({ + 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 + } +} diff --git a/src/api/fetch.ts b/src/api/fetch.ts index 6f59191..806f856 100644 --- a/src/api/fetch.ts +++ b/src/api/fetch.ts @@ -31,7 +31,7 @@ interface ErrorResponse { errors?: FormError[] } -type APIFetch = (options: FetchOptions) => Promise +type APIFetch = (options: FetchOptions) => Promise const mapErrorsToFormNotifications = (errors?: FormError[]): FormNotification[] => { if (!errors) return [] diff --git a/src/components/app/app.scss b/src/components/app/app.scss index 8e02761..eac63b8 100644 --- a/src/components/app/app.scss +++ b/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; +} diff --git a/src/components/create-group-form/create-group-form.tsx b/src/components/create-group-form/create-group-form.tsx index 4543aa3..7daf535 100644 --- a/src/components/create-group-form/create-group-form.tsx +++ b/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 = ({ checkAvailability }) => {
- +
diff --git a/src/components/forms/select-field/select-field.tsx b/src/components/forms/select-field/select-field.tsx index 4aeb2e5..14c8299 100644 --- a/src/components/forms/select-field/select-field.tsx +++ b/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 = ({ 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 = ({ helpClassDictionary[ncn] = true } + if (icon) { + controlClassDictionary['has-icons-left'] = true + } + return (
- +
+ +
+ {icon && +
+ +
+ }
{notification &&

{notification.message}

diff --git a/src/components/forms/textarea-field/index.ts b/src/components/forms/textarea-field/index.ts new file mode 100644 index 0000000..af04946 --- /dev/null +++ b/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(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) diff --git a/src/components/forms/textarea-field/textarea-field.tsx b/src/components/forms/textarea-field/textarea-field.tsx new file mode 100644 index 0000000..c085735 --- /dev/null +++ b/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 = ({ + 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 ( +
+ +
+ -
-
+
-
} - {tab === 'members' && } + {tab === 'members' && +
+ +
+ +
+ } + {tab === 'logs' && }
diff --git a/src/components/pages/group-admin/index.ts b/src/components/pages/group-admin/index.ts index d5e6991..0f66614 100644 --- a/src/components/pages/group-admin/index.ts +++ b/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(state, EntityType.Group, ownProps.match.params.id), + about: getFieldValue(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( diff --git a/src/components/pages/group/group.tsx b/src/components/pages/group/group.tsx index 9c123a1..dd89478 100644 --- a/src/components/pages/group/group.tsx +++ b/src/components/pages/group/group.tsx @@ -45,25 +45,31 @@ const GroupPage: FC = ({ group, fetchGroup }) => {
-

-
- {isAdmin && - +
+

{group.about}

+
+ +
+ {isAdmin && + + + + + Edit {group.name} + + } + + - + - Edit {group.name} + Create an Account - } - - - - - - Create an Account - +
+ +
) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index dcd8e70..bffebc1 100644 --- a/src/hooks/index.ts +++ b/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)) +} diff --git a/src/selectors/authentication.ts b/src/selectors/authentication.ts index be0ee73..ef57c93 100644 --- a/src/selectors/authentication.ts +++ b/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 } ) diff --git a/src/selectors/directory.ts b/src/selectors/directory.ts index a393487..8ea2a63 100644 --- a/src/selectors/directory.ts +++ b/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[] +} diff --git a/src/selectors/entities.ts b/src/selectors/entities.ts index 45ec352..a0d050d 100644 --- a/src/selectors/entities.ts +++ b/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 = (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 } diff --git a/src/store/schemas.ts b/src/store/schemas.ts deleted file mode 100644 index 2fd89a3..0000000 --- a/src/store/schemas.ts +++ /dev/null @@ -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, -}) diff --git a/src/types/entities.ts b/src/types/entities.ts index c903129..374c061 100644 --- a/src/types/entities.ts +++ b/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 { diff --git a/src/types/store.ts b/src/types/store.ts index 41846e7..5c6f848 100644 --- a/src/types/store.ts +++ b/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 diff --git a/src/utils/index.ts b/src/utils/index.ts index faf4883..fe13244 100644 --- a/src/utils/index.ts +++ b/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) { diff --git a/src/utils/normalization.ts b/src/utils/normalization.ts new file mode 100644 index 0000000..33be3bb --- /dev/null +++ b/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 = [] + 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) +}