From 2355ae4d4477167da1eb9c2e5086400e5c3637df Mon Sep 17 00:00:00 2001 From: Dwayne Harris Date: Fri, 18 Oct 2019 01:04:20 -0400 Subject: [PATCH] WIP --- src/actions/apps.ts | 41 ++++++++++++- src/actions/composer.ts | 52 +++++++++++++++++ src/actions/registration.ts | 14 ++++- src/components/app-list-item.tsx | 29 ++++++++-- src/components/composer.tsx | 46 +++++++++++++++ src/components/create-group-form.tsx | 32 +++-------- src/components/create-group-step.tsx | 2 +- src/components/create-user-form.tsx | 18 ++++-- src/components/create-user-step.tsx | 4 +- src/components/forms/file-field.tsx | 76 ++++++++++++++++++------- src/components/forms/text-field.tsx | 9 +-- src/components/pages/edit-app.tsx | 58 ++++++++++++++++--- src/components/pages/home.tsx | 10 +++- src/components/pages/register-group.tsx | 4 ++ src/components/pages/register.tsx | 10 ++++ src/components/pages/self.tsx | 2 +- src/components/pages/view-app.tsx | 16 ++++-- src/components/user-info.tsx | 63 +++++++++----------- src/hooks/index.ts | 5 +- src/reducers/composer.ts | 28 +++++++++ src/selectors/apps.ts | 2 +- src/selectors/composer.ts | 11 ++++ src/store/index.ts | 2 + src/styles/app.scss | 36 +++++++++++- src/types/entities.ts | 45 ++++++++++++--- src/types/store.ts | 7 +++ src/utils/index.ts | 14 ++++- src/utils/normalization.ts | 74 ++++++++++++------------ 28 files changed, 548 insertions(+), 162 deletions(-) create mode 100644 src/actions/composer.ts create mode 100644 src/components/composer.tsx create mode 100644 src/reducers/composer.ts create mode 100644 src/selectors/composer.ts diff --git a/src/actions/apps.ts b/src/actions/apps.ts index bda301c..7565a7e 100644 --- a/src/actions/apps.ts +++ b/src/actions/apps.ts @@ -127,7 +127,7 @@ export const checkAppAvailability = (name: string): AppThunkAction => async disp } } -interface CreateAppOptions { +interface AppOptions { name: string about?: string websiteUrl?: string @@ -135,14 +135,17 @@ interface CreateAppOptions { version: string composerUrl?: string rendererUrl?: string + imageUrl?: string + coverImageUrl?: string + iconImageUrl?: string } interface CreateAppResponse { id: string } -export const createApp = (options: CreateAppOptions): AppThunkAction => async dispatch => { - const { name, about, websiteUrl, companyName, version, composerUrl, rendererUrl } = options +export const createApp = (options: AppOptions): AppThunkAction => async dispatch => { + const { name, about, websiteUrl, companyName, version, composerUrl, rendererUrl, imageUrl, coverImageUrl, iconImageUrl } = options dispatch(startRequest(RequestKey.CreateApp)) try { @@ -157,6 +160,9 @@ export const createApp = (options: CreateAppOptions): AppThunkAction => version, composerUrl, rendererUrl, + imageUrl, + coverImageUrl, + iconImageUrl, }, }) @@ -168,6 +174,35 @@ export const createApp = (options: CreateAppOptions): AppThunkAction => } } +export const updateApp = (id: string, options: AppOptions): AppThunkAction => async dispatch => { + const { name, about, websiteUrl, companyName, version, composerUrl, rendererUrl, imageUrl, coverImageUrl, iconImageUrl } = options + dispatch(startRequest(RequestKey.CreateApp)) + + try { + await apiFetch({ + path: `/api/app/${id}`, + method: 'put', + body: { + name, + about, + websiteUrl, + companyName, + version, + composerUrl, + rendererUrl, + imageUrl, + coverImageUrl, + iconImageUrl, + }, + }) + + dispatch(finishRequest(RequestKey.CreateApp, true)) + } catch (err) { + dispatch(finishRequest(RequestKey.CreateApp, false)) + throw err + } +} + export const fetchApp = (id: string): AppThunkAction => async dispatch => { dispatch(startRequest(RequestKey.FetchApp)) diff --git a/src/actions/composer.ts b/src/actions/composer.ts new file mode 100644 index 0000000..62a796a --- /dev/null +++ b/src/actions/composer.ts @@ -0,0 +1,52 @@ +import { Action } from 'redux' + +import { setEntities } from 'src/actions/entities' +import { startRequest, finishRequest } from 'src/actions/requests' +import { apiFetch } from 'src/api' +import { normalize } from 'src/utils/normalization' +import { AppThunkAction, Installation, RequestKey, EntityType } from 'src/types' + +export interface SetInstallationsAction extends Action { + type: 'COMPOSER_SET_INSTALLATIONS' + payload: string[] +} + +export interface SetSelectedInstallationAction extends Action { + type: 'COMPOSER_SET_SELECTED_INSTALLATION' + payload?: string +} + +export type ComposerActions = SetInstallationsAction | SetSelectedInstallationAction + +export const setInstallations = (installations: string[]): SetInstallationsAction => ({ + type: 'COMPOSER_SET_INSTALLATIONS', + payload: installations, +}) + +export const setSelectedInstallation = (installation?: string): SetSelectedInstallationAction => ({ + type: 'COMPOSER_SET_SELECTED_INSTALLATION', + payload: installation, +}) + +interface FetchInstallationsResponse { + installations: Installation[] +} + +export const fetchInstallations = (): AppThunkAction => async dispatch => { + dispatch(startRequest(RequestKey.FetchInstallations)) + + try { + const response = await apiFetch({ + path: '/api/installations', + }) + + const result = normalize(response.installations, EntityType.Installation) + + dispatch(setEntities(result.entities)) + dispatch(setInstallations(result.keys)) + dispatch(finishRequest(RequestKey.FetchInstallations, true)) + } catch (err) { + dispatch(finishRequest(RequestKey.FetchInstallations, false)) + throw err + } +} diff --git a/src/actions/registration.ts b/src/actions/registration.ts index e33f477..e8c4c5e 100644 --- a/src/actions/registration.ts +++ b/src/actions/registration.ts @@ -77,6 +77,9 @@ interface CreateGroupOptions { name: string registration: string about?: string + imageUrl?: string + coverImageUrl?: string + iconImageUrl?: string } interface CreateGroupResponse { @@ -84,7 +87,7 @@ interface CreateGroupResponse { } export const createGroup = (options: CreateGroupOptions): AppThunkAction => async dispatch => { - const { name, registration, about } = options + const { name, registration, about, imageUrl, coverImageUrl, iconImageUrl } = options dispatch(startRequest(RequestKey.CreateGroup)) @@ -96,6 +99,9 @@ export const createGroup = (options: CreateGroupOptions): AppThunkAction name, registration, about, + imageUrl, + coverImageUrl, + iconImageUrl, }, }) @@ -113,6 +119,8 @@ interface RegisterOptions { email: string password: string name?: string + imageUrl?: string + coverImageUrl?: string requiresApproval: boolean privacy: string group?: string @@ -125,7 +133,7 @@ interface RegisterResponse { } export const register = (options: RegisterOptions): AppThunkAction => async dispatch => { - const { id, email, password, name, requiresApproval, privacy, group } = options + const { id, email, password, name, imageUrl, coverImageUrl, requiresApproval, privacy, group } = options dispatch(startRequest(RequestKey.Register)) @@ -138,6 +146,8 @@ export const register = (options: RegisterOptions): AppThunkAction => as email, password, name, + imageUrl, + coverImageUrl, requiresApproval, privacy, group, diff --git a/src/components/app-list-item.tsx b/src/components/app-list-item.tsx index 7a04887..6572a64 100644 --- a/src/components/app-list-item.tsx +++ b/src/components/app-list-item.tsx @@ -1,16 +1,33 @@ import React, { FC } from 'react' import { Link } from 'react-router-dom' + +import { useConfig } from 'src/hooks' import { App } from 'src/types' interface Props { app: App } -const AppListItem: FC = ({ app }) => ( -
- {app.name} - {app.about &&

{app.about}

} -
-) +const AppListItem: FC = ({ app }) => { + const config = useConfig() + + return ( +
+ {app.imageUrl && +
+

+ +

+
+ } +
+
+ {app.name} + {app.about &&

{app.about}

} +
+
+
+ ) +} export default AppListItem diff --git a/src/components/composer.tsx b/src/components/composer.tsx new file mode 100644 index 0000000..be99eac --- /dev/null +++ b/src/components/composer.tsx @@ -0,0 +1,46 @@ +import React, { FC, useEffect } from 'react' +import { useSelector, useDispatch } from 'react-redux' + +import { useConfig } from 'src/hooks' +import { fetchInstallations, setSelectedInstallation } from 'src/actions/composer' +import { getInstallations, getSelectedInstallation } from 'src/selectors/composer' +import { AppState, Installation } from 'src/types' + +const Composer: FC = () => { + const installations = useSelector(getInstallations) + const selected = useSelector(getSelectedInstallation) + const config = useConfig() + const dispatch = useDispatch() + + useEffect(() => { + dispatch(fetchInstallations()) + }, []) + + const handleClick = (id: string) => { + if (selected && selected.id === id) { + dispatch(setSelectedInstallation()) + return + } + + dispatch(setSelectedInstallation(id)) + } + + return ( +
+
+

{selected ? selected.app.name : 'Choose an app.'}

+
+ +
+ {installations.map(installation => ( +
handleClick(installation.id)}> + {installation.app.name} +

{installation.app.name}

+
+ ))} +
+
+ ) +} + +export default Composer diff --git a/src/components/create-group-form.tsx b/src/components/create-group-form.tsx index 8bc59a3..8d03ec2 100644 --- a/src/components/create-group-form.tsx +++ b/src/components/create-group-form.tsx @@ -6,9 +6,10 @@ import { faUpload, faIdCard } from '@fortawesome/free-solid-svg-icons' import { checkGroupAvailability } from 'src/actions/registration' -import CheckboxField from './forms/checkbox-field' -import TextField from './forms/text-field' -import SelectField from './forms/select-field' +import CheckboxField from 'src/components/forms/checkbox-field' +import TextField from 'src/components/forms/text-field' +import SelectField from 'src/components/forms/select-field' +import FileField from 'src/components/forms/file-field' const CreateGroupForm: FC = () => { const dispatch = useDispatch() @@ -29,29 +30,14 @@ const CreateGroupForm: FC = () => {
checkAvailability(e.target.value)} />
-
- -
- -
- -
-

Image must be smaller than 5 MBs.

-
+ +
+ +
+
- I agree to the Communities terms and conditions. diff --git a/src/components/create-group-step.tsx b/src/components/create-group-step.tsx index 6024bcb..132406c 100644 --- a/src/components/create-group-step.tsx +++ b/src/components/create-group-step.tsx @@ -49,7 +49,7 @@ const CreateGroupStep: FC = ({ register }) => { } return ( -
+
diff --git a/src/components/create-user-form.tsx b/src/components/create-user-form.tsx index 962b57f..37135aa 100644 --- a/src/components/create-user-form.tsx +++ b/src/components/create-user-form.tsx @@ -5,10 +5,11 @@ import { faEnvelope, faIdCard, faUserShield } from '@fortawesome/free-solid-svg- import { checkUserAvailability } from 'src/actions/registration' import { PRIVACY_OPTIONS } from 'src/constants' -import CheckboxField from './forms/checkbox-field' -import TextField from './forms/text-field' -import PasswordField from './forms/password-field' -import SelectField from './forms/select-field' +import CheckboxField from 'src/components/forms/checkbox-field' +import TextField from 'src/components/forms/text-field' +import PasswordField from 'src/components/forms/password-field' +import SelectField from 'src/components/forms/select-field' +import FileField from 'src/components/forms/file-field' const CreateUserForm: FC = () => { const dispatch = useDispatch() @@ -29,12 +30,17 @@ const CreateUserForm: FC = () => {

+ +
+ +

- You must approve each Subscription request from other users. + Approve each Subscription request from other users. -
+


+ I agree to the User terms and conditions. diff --git a/src/components/create-user-step.tsx b/src/components/create-user-step.tsx index 1caf38e..4ed9843 100644 --- a/src/components/create-user-step.tsx +++ b/src/components/create-user-step.tsx @@ -26,7 +26,7 @@ const CreateUserStep: FC = () => { const name = valueFromForm(form, 'user-name') const email = valueFromForm(form, 'user-email') const password = valueFromForm(form, 'password') - const agree = valueFromForm(form, 'agree') + const agree = valueFromForm(form, 'user-agree') if (!userId || userId === '') { dispatch(setFieldNotification('user-id', NotificationType.Error, 'This is required')) @@ -71,7 +71,7 @@ const CreateUserStep: FC = () => { } return ( -
+
diff --git a/src/components/forms/file-field.tsx b/src/components/forms/file-field.tsx index b78c9f1..88d79ab 100644 --- a/src/components/forms/file-field.tsx +++ b/src/components/forms/file-field.tsx @@ -16,14 +16,17 @@ interface Props { name: string label: string help?: string + previewWidth?: number } -const CheckboxField: FC = ({ name, label, help }) => { +const FileField: FC = ({ name, label, help, previewWidth = 128 }) => { const value = useSelector(state => getFieldValue(state, name, false)) const config = useSelector(getConfig) + const dispatch = useDispatch() + const [progress, setProgress] = useState(0) const [uploading, setUploading] = useState(false) - const dispatch = useDispatch() + const [uploaded, setUploaded] = useState(false) const classes: ClassDictionary = { file: true, @@ -45,7 +48,6 @@ const CheckboxField: FC = ({ name, label, help }) => { const filename = `${id}${ext}` const blobURL = new BlockBlobURL(`${config.blobUrl}${filename}?${sas}`, BlockBlobURL.newPipeline(new AnonymousCredential())) - dispatch(setFieldValue(name, filename)) setUploading(true) await uploadBrowserDataToBlockBlob(Aborter.none, file, blobURL, { @@ -55,10 +57,37 @@ const CheckboxField: FC = ({ name, label, help }) => { } }) + await apiFetch({ + path: '/api/media', + method: 'post', + body: { + name: filename, + size: file.size, + type: file.type, + originalName: file.name, + } + }) + + dispatch(setFieldValue(name, filename)) + setUploaded(true) setUploading(false) } } + const handleDelete = async () => { + if (uploaded) { + await apiFetch({ + path: '/api/media/delete', + method: 'post', + body: { + name: value, + } + }) + } + + dispatch(setFieldValue(name, '')) + } + if (uploading) { return (
@@ -69,25 +98,34 @@ const CheckboxField: FC = ({ name, label, help }) => { } return ( -
- -
-
- {notification && -

{notification.message}

- } + {(!notification && help) &&

{help}

} + {notification &&

{notification.message}

}
) } diff --git a/src/components/pages/edit-app.tsx b/src/components/pages/edit-app.tsx index b2e5cc1..64f3571 100644 --- a/src/components/pages/edit-app.tsx +++ b/src/components/pages/edit-app.tsx @@ -5,14 +5,16 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faIdCard, faCheckCircle } from '@fortawesome/free-solid-svg-icons' import { handleApiError } from 'src/api/errors' -import { fetchApp } from 'src/actions/apps' -import { initForm, initField } from 'src/actions/forms' +import { fetchApp, updateApp } from 'src/actions/apps' +import { initForm, initField, setFieldNotification } from 'src/actions/forms' +import { showNotification } from 'src/actions/notifications' import { getAuthenticatedUserId } from 'src/selectors/authentication' import { getEntity } from 'src/selectors/entities' +import { getForm } from 'src/selectors/forms' import { useAuthenticationCheck, useDeepCompareEffect } from 'src/hooks' -import { setTitle } from 'src/utils' -import { AppState, AppThunkDispatch, EntityType, App } from 'src/types' +import { setTitle, valueFromForm } from 'src/utils' +import { AppState, AppThunkDispatch, EntityType, App, Form, NotificationType } from 'src/types' import PageHeader from 'src/components/page-header' import Loading from 'src/components/pages/loading' @@ -30,6 +32,7 @@ const EditApp: FC = () => { const { id } = useParams() const app = useSelector(state => getEntity(state, EntityType.App, id)) + const form = useSelector(getForm) const selfId = useSelector(getAuthenticatedUserId) const dispatch = useDispatch() const history = useHistory() @@ -66,8 +69,48 @@ const EditApp: FC = () => { if (!app) return - const handleUpdate = () => { + const handleUpdate = async () => { + const name = valueFromForm(form, 'name') + const about = valueFromForm(form, 'about') + const websiteUrl = valueFromForm(form, 'websiteUrl') + const companyName = valueFromForm(form, 'companyName') + const version = valueFromForm(form, 'version') + const composerUrl = valueFromForm(form, 'composerUrl') + const rendererUrl = valueFromForm(form, 'rendererUrl') + const imageUrl = valueFromForm(form, 'image') + const coverImageUrl = valueFromForm(form, 'coverImage') + const iconImageUrl = valueFromForm(form, 'iconImage') + + if (!name) { + dispatch(showNotification(NotificationType.Error, 'Name is required')) + dispatch(setFieldNotification('name', NotificationType.Error, 'This is required')) + return + } + + if (!version) { + dispatch(showNotification(NotificationType.Error, 'Version is required')) + dispatch(setFieldNotification('version', NotificationType.Error, 'This is required')) + return + } + try { + await dispatch(updateApp(id, { + name, + about, + websiteUrl, + companyName, + version, + composerUrl, + rendererUrl, + imageUrl, + coverImageUrl, + iconImageUrl, + })) + + dispatch(showNotification(NotificationType.Success, 'Updated')) + } catch (err) { + handleApiError(err, dispatch, history) + } } return ( @@ -95,13 +138,14 @@ const EditApp: FC = () => {

+ +

+

-
-

diff --git a/src/components/pages/home.tsx b/src/components/pages/home.tsx index 37b2d96..e31df95 100644 --- a/src/components/pages/home.tsx +++ b/src/components/pages/home.tsx @@ -1,8 +1,16 @@ import React, { FC, useEffect } from 'react' +import { useSelector } from 'react-redux' + +import { getAuthenticated } from 'src/selectors/authentication' import { setTitle } from 'src/utils' +import { AppState } from 'src/types' + import PageHeader from 'src/components/page-header' +import Composer from 'src/components/composer' const Home: FC = () => { + const authenticated = useSelector(getAuthenticated) + useEffect(() => { setTitle('Home') }) @@ -12,7 +20,7 @@ const Home: FC = () => {
- Hello. + {authenticated && }
) diff --git a/src/components/pages/register-group.tsx b/src/components/pages/register-group.tsx index 23ff778..b5c17b7 100644 --- a/src/components/pages/register-group.tsx +++ b/src/components/pages/register-group.tsx @@ -43,6 +43,8 @@ const RegisterGroup: FC = () => { dispatch(initField('user-name', '', 'name')) dispatch(initField('user-email', '', 'email')) dispatch(initField('password', '')) + dispatch(initField('user-image', '', 'imageUrl')) + dispatch(initField('user-cover-image', '', 'coverImageUrl')) dispatch(initField('user-agree', false)) setTitle('Register') @@ -64,6 +66,8 @@ const RegisterGroup: FC = () => { email: valueFromForm(form, 'user-email', ''), password: valueFromForm(form, 'password', ''), name: valueFromForm(form, 'user-name', ''), + imageUrl: valueFromForm(form, 'user-image', ''), + coverImageUrl: valueFromForm(form, 'user-cover-image', ''), requiresApproval: valueFromForm(form, 'user-requires-approval', false), privacy: valueFromForm(form, 'user-privacy', 'public'), group: id, diff --git a/src/components/pages/register.tsx b/src/components/pages/register.tsx index 3d32982..8283219 100644 --- a/src/components/pages/register.tsx +++ b/src/components/pages/register.tsx @@ -38,6 +38,8 @@ const Register: FC = () => { email: valueFromForm(form, 'user-email', ''), password: valueFromForm(form, 'password', ''), name: valueFromForm(form, 'user-name', ''), + imageUrl: valueFromForm(form, 'user-image', ''), + coverImageUrl: valueFromForm(form, 'user-cover-image', ''), requiresApproval: valueFromForm(form, 'user-requires-approval', true), privacy: valueFromForm(form, 'user-privacy', 'open') })) @@ -45,6 +47,9 @@ const Register: FC = () => { await dispatch(createGroup({ name: valueFromForm(form, 'group-name', ''), registration: valueFromForm(form, 'group-registration', ''), + imageUrl: valueFromForm(form, 'group-image', ''), + coverImageUrl: valueFromForm(form, 'group-cover-image', ''), + iconImageUrl: valueFromForm(form, 'group-icon-image', ''), })) dispatch(showNotification(NotificationType.Welcome, `Welcome to Flexor!`)) @@ -73,11 +78,16 @@ const Register: FC = () => { dispatch(initForm()) dispatch(initField('group-name', '', 'name')) dispatch(initField('group-registration', 'open', 'registration')) + dispatch(initField('group-image', '', 'imageUrl')) + dispatch(initField('group-cover-image', '', 'coverImageUrl')) + dispatch(initField('group-icon-image', '', 'iconImageUrl')) dispatch(initField('group-agree', false)) dispatch(initField('user-id', '', 'id')) dispatch(initField('user-name', '', 'name')) dispatch(initField('user-email', '', 'email')) dispatch(initField('password', '')) + dispatch(initField('user-image', '', 'imageUrl')) + dispatch(initField('user-cover-image', '', 'coverImageUrl')) dispatch(initField('user-requires-approval', true, 'requiresApproval')) dispatch(initField('user-privacy', 'public', 'privacy')) dispatch(initField('user-agree', false)) diff --git a/src/components/pages/self.tsx b/src/components/pages/self.tsx index 126ba5b..c51c9db 100644 --- a/src/components/pages/self.tsx +++ b/src/components/pages/self.tsx @@ -74,7 +74,7 @@ const Self: FC = () => { useDeepCompareEffect(() => { if (user) { - setTitle(`${user.name} (@${user.id})`) + setTitle(`${user.name} @${user.id}`) dispatch(initForm()) dispatch(initField('name', user.name)) diff --git a/src/components/pages/view-app.tsx b/src/components/pages/view-app.tsx index 0765de9..002fda6 100644 --- a/src/components/pages/view-app.tsx +++ b/src/components/pages/view-app.tsx @@ -13,10 +13,14 @@ import { getEntity } from 'src/selectors/entities' import { getIsFetching } from 'src/selectors/requests' import { setTitle } from 'src/utils' -import { AppState, AppThunkDispatch, EntityType, App, RequestKey } from 'src/types' +import { AppState, AppThunkDispatch, EntityType, App, RequestKey, Installation } from 'src/types' + +import { fetchInstallations } from 'src/actions/composer' +import { getInstallations } from 'src/selectors/composer' import PageHeader from 'src/components/page-header' import Loading from 'src/components/pages/loading' + import { ClassDictionary } from 'src/types' interface Params { @@ -26,6 +30,7 @@ interface Params { const ViewApp: FC = () => { const { id } = useParams() const app = useSelector(state => getEntity(state, EntityType.App, id)) + const installations = useSelector(getInstallations) const selfId = useSelector(getAuthenticatedUserId) const fetching = useSelector(state => getIsFetching(state, RequestKey.InstallApp) || getIsFetching(state, RequestKey.UninstallApp)) const dispatch = useDispatch() @@ -34,6 +39,7 @@ const ViewApp: FC = () => { useEffect(() => { try { dispatch(fetchApp(id)) + dispatch(fetchInstallations()) } catch (err) { handleApiError(err, dispatch, history) } @@ -46,18 +52,18 @@ const ViewApp: FC = () => { if (!app) return const isCreator = app.user.id === selfId + const installed = !!installations.find(i => i.app.id === app.id) const renderButton = () => { const classes: ClassDictionary = { 'button': true, - 'is-danger': app.installed, - 'is-success': !app.installed, + 'is-danger': installed, + 'is-success': !installed, 'is-large': true, - 'is-outlined': true, 'is-loading': fetching, } - if (app.installed) { + if (installed) { const handleClick = async () => { await dispatch(uninstallApp(id)) await dispatch(fetchApp(id)) diff --git a/src/components/user-info.tsx b/src/components/user-info.tsx index b94c669..529da04 100644 --- a/src/components/user-info.tsx +++ b/src/components/user-info.tsx @@ -4,58 +4,48 @@ import { Link } from 'react-router-dom' import { getConfig } from 'src/selectors' import { getAuthenticatedUser } from 'src/selectors/authentication' +import { urlForBlob } from 'src/utils' import { AppState, User, Config } from 'src/types' const UserInfo: FC = () => { const user = useSelector(getAuthenticatedUser) const config = useSelector(getConfig) - const hasAvatar = user && user.imageUrl - const imageUrl = hasAvatar ? `${config.blobUrl}${user!.imageUrl}` : undefined - const name = () => { - if (!user) return - - if (user.name) { - return ( - <> - {user.name} -
- @{user.id} - - ) - } + const imageUrl = user && user.imageUrl ? urlForBlob(config, user.imageUrl) : undefined + const name = (user: User) => { + if (user.name) return {user.name} @{user.id} return @{user.id} } const content = () => { if (user) { + const group = user.group + const groupImageUrl = group && group.coverImageUrl ? urlForBlob(config, group.coverImageUrl) : undefined + return ( -
-
+
+ {name(user)} +
+ {group &&
- {name()} -
- {user.group && - {user.group.name} + {groupImageUrl && +
+ +
} + {group.name}
-
+ }
) } return ( -
-
-
- Log In to Flexor -

- or -

- Create an Account -
-
+
+ Log In to Flexor +

or

+ Create an Account
) } @@ -64,13 +54,16 @@ const UserInfo: FC = () => {
{imageUrl &&
-

- +

+

} - - {content()} +
+
+ {content()} +
+
) } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ca9da54..f55efa1 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,7 +4,8 @@ import { useHistory } from 'react-router-dom' import isEqual from 'lodash/isEqual' import { getAuthenticated, getChecked } from 'src/selectors/authentication' -import { AppState } from 'src/types' +import { getConfig } from 'src/selectors' +import { AppState, Config } from 'src/types' export const useAuthenticationCheck = () => { const checked = useSelector(getChecked) @@ -16,6 +17,8 @@ export const useAuthenticationCheck = () => { }, [checked, authenticated]) } +export const useConfig = () => useSelector(getConfig) + const useDeepCompareMemoize = (value: any) => { const ref = useRef() diff --git a/src/reducers/composer.ts b/src/reducers/composer.ts new file mode 100644 index 0000000..28bd8c3 --- /dev/null +++ b/src/reducers/composer.ts @@ -0,0 +1,28 @@ +import { Reducer } from 'redux' + +import { ComposerActions } from '../actions/composer' +import { ComposerState } from '../types' + +const initialState: ComposerState = { + installations: [], + selected: undefined, +} + +const reducer: Reducer = (state = initialState, action) => { + switch (action.type) { + case 'COMPOSER_SET_INSTALLATIONS': + return { + ...state, + installations: action.payload, + } + case 'COMPOSER_SET_SELECTED_INSTALLATION': + return { + ...state, + selected: action.payload, + } + default: + return state + } +} + +export default reducer diff --git a/src/selectors/apps.ts b/src/selectors/apps.ts index 758b6ad..487b278 100644 --- a/src/selectors/apps.ts +++ b/src/selectors/apps.ts @@ -1,5 +1,5 @@ import { denormalize } from 'src/utils/normalization' -import { AppState, EntityType, App } from 'src/types' +import { AppState, EntityType, App, Installation } from 'src/types' export const getApps = (state: AppState) => denormalize(state.apps.items, EntityType.App, state.entities) as App[] export const getCreatedApps = (state: AppState) => denormalize(state.apps.created.items, EntityType.App, state.entities) as App[] diff --git a/src/selectors/composer.ts b/src/selectors/composer.ts new file mode 100644 index 0000000..571563f --- /dev/null +++ b/src/selectors/composer.ts @@ -0,0 +1,11 @@ +import { denormalize } from 'src/utils/normalization' +import { AppState, EntityType, App, Installation } from 'src/types' + +export const getApps = (state: AppState) => denormalize(state.apps.items, EntityType.App, state.entities) as App[] +export const getCreatedApps = (state: AppState) => denormalize(state.apps.created.items, EntityType.App, state.entities) as App[] +export const getInstallations = (state: AppState) => denormalize(state.composer.installations, EntityType.Installation, state.entities) as Installation[] + +export const getSelectedInstallation = (state: AppState) => { + if (!state.composer.selected) return + return denormalize([state.composer.selected], EntityType.Installation, state.entities)[0] as Installation +} diff --git a/src/store/index.ts b/src/store/index.ts index 9f8c207..a78911f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -3,6 +3,7 @@ import { AppState } from '../types' import apps from '../reducers/apps' import authentication from '../reducers/authentication' +import composer from '../reducers/composer' import config from '../reducers/config' import entities from '../reducers/entities' import forms from '../reducers/forms' @@ -19,6 +20,7 @@ const store = createStore( combineReducers({ apps, authentication, + composer, config, entities, forms, diff --git a/src/styles/app.scss b/src/styles/app.scss index 06833d9..01a1ca3 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -11,11 +11,14 @@ $cyan: hsl(204, 86%, 53%); $blue: hsl(217, 72%, 30%); $purple: hsl(271, 63%, 32%); $red: hsl(348, 71%, 42%); +$grey: hsl(0, 0%, 48%); +$grey-light: hsl(0, 0%, 71%); +$grey-lighter: hsl(0, 0%, 86%); $white-ter: hsl(0, 0%, 96%); +$white-bis: hsl(0, 0%, 98%); $family-sans-serif: "Open Sans", sans-serif; $primary: $blue; -// $title-weight: 400; $body-background-color: $white-ter; $body-size: 14px; @@ -119,3 +122,34 @@ div.invitation-options > div { article#user-info { padding: 20px; } + +div.composer-container { + background-color: white; + border: solid 1px $primary; + + div.composer { + color: $primary; + font-weight: bold; + text-align: center; + } + + div.composer-empty { + padding: 3rem; + } + + div.installations { + background-color: $white-bis; + + div { + border: solid 2px $white-bis; + cursor: pointer; + margin: 10px; + padding: 5px 15px; + text-align: center; + } + + div.selected { + border: solid 2px $green; + } + } +} diff --git a/src/types/entities.ts b/src/types/entities.ts index 71a728c..c81fef1 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -25,14 +25,22 @@ export type Group = Entity & { about: string } -export type Installation = Entity & { - app: App +type BaseInstallation = Entity & { settings: object } -export type User = Entity & { +export type Installation = BaseInstallation & { + app: App + user: User +} + +export type NormalizedInstallation = BaseInstallation & { + app: string + user: string +} + +type BaseUser = Entity & { name: string - group?: Group about?: string imageUrl?: string coverImageUrl?: string @@ -40,22 +48,45 @@ export type User = Entity & { privacy: string } -export type GroupLog = Entity & { - user: User +export type User = BaseUser & { + group?: Group +} + +export type NormalizedUser = BaseUser & { + group?: string +} + +type BaseGroupLog = Entity & { content: string } -export type Invitation = Entity & { +export type GroupLog = BaseGroupLog & { user: User +} + +export type NormalizedGroupLog = BaseGroupLog & { + user: string +} + +type BaseInvitation = Entity & { uses: number expires: number } +export type Invitation = BaseInvitation & { + user: User +} + +export type NormalizedInvitation = BaseInvitation & { + user: string +} + export type App = Entity & { version: string name: string imageUrl?: string coverImageUrl?: string + iconImageUrl?: string about?: string websiteUrl?: string companyName?: string diff --git a/src/types/store.ts b/src/types/store.ts index 398dcc0..0151a87 100644 --- a/src/types/store.ts +++ b/src/types/store.ts @@ -26,6 +26,7 @@ export enum RequestKey { FetchGroupLogs = 'fetch_group_logs', FetchGroupMembers = 'fetch_group_members', FetchGroups = 'fetch_groups', + FetchInstallations = 'fetch_installations', FetchInvitations = 'fetch_invitations', FetchSelfApps = 'fetch_self_apps', FetchUserAvailability = 'fetch_user_availability', @@ -110,6 +111,11 @@ export type AppsState = EntityList & { created: EntityList } +export type ComposerState = { + installations: string[] + selected?: string +} + export type ConfigState = Config export type RequestsState = APIRequestCollection export type NotificationsState = Notification[] @@ -118,6 +124,7 @@ export type EntitiesState = EntityStore export interface AppState { authentication: AuthenticationState apps: AppsState + composer: ComposerState config: ConfigState entities: EntitiesState forms: FormsState diff --git a/src/utils/index.ts b/src/utils/index.ts index fe13244..fcfa36a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,8 +1,11 @@ +import getConfig from 'src/config' + import { NotificationType, Form, FormValue, -} from '../types' + Config, +} from 'src/types' export function notificationTypeToClassName(type: NotificationType): string { switch (type) { @@ -17,7 +20,7 @@ export const objectToQuerystring = (obj: object) => Object.entries(obj).filter(( export function setTitle(title: string, decorate: boolean = true) { if (decorate) { - document.title = `${title} | Flexor` + document.title = `${title} / Flexor` } else { document.title = title } @@ -34,3 +37,10 @@ export function valueFromForm(form: Form, name: string, def return field.value as T } + +export const urlForBlob = (config: Config, name: string) => `${config.blobUrl}${name}` + +export async function urlForBlobAsync(name: string) { + const config = await getConfig() + return urlForBlob(config, name) +} diff --git a/src/utils/normalization.ts b/src/utils/normalization.ts index 6baaa5f..5886f77 100644 --- a/src/utils/normalization.ts +++ b/src/utils/normalization.ts @@ -3,10 +3,14 @@ import { EntityStore, Entity, User, + NormalizedUser, Invitation, + NormalizedInvitation, GroupLog, + NormalizedGroupLog, App, Installation, + NormalizedInstallation, } from '../types' import compact from 'lodash/compact' @@ -16,14 +20,6 @@ export interface NormalizeResult { entities: EntityStore } -type NormalizedInstallation = Entity & { - app: string -} - -type NormalizedUser = Entity & { - installations: NormalizedInstallation[] -} - function set(type: EntityType, store: EntityStore, entity?: Entity): string | undefined { if (!entity) return @@ -68,10 +64,14 @@ export function normalize(entities: Entity[], type: EntityType): NormalizeResult case EntityType.Invitation: keys = entities.map(entity => { const invitation = entity as Invitation + const user = invitation.user return set(type, newStore, { ...invitation, - user: set(EntityType.User, newStore, invitation.user), + user: set(EntityType.User, newStore, { + ...user, + group: set(EntityType.Group, newStore, user.group), + }), }) }) @@ -79,24 +79,20 @@ export function normalize(entities: Entity[], type: EntityType): NormalizeResult case EntityType.Log: keys = entities.map(entity => { const log = entity as GroupLog + const user = log.user return set(type, newStore, { ...log, - user: set(EntityType.User, newStore, log.user), + user: set(EntityType.User, newStore, { + ...user, + group: set(EntityType.Group, newStore, user.group), + }), }) }) break case EntityType.App: - keys = entities.map(entity => { - const app = entity as App - - return set(type, newStore, { - ...app, - user: set(EntityType.User, newStore, app.user) - }) - }) - + keys = entities.map(entity => set(type, newStore, entity)) break case EntityType.Installation: keys = entities.map(entity => { @@ -118,7 +114,7 @@ export function normalize(entities: Entity[], type: EntityType): NormalizeResult export function denormalize(keys: string[], type: EntityType, store: EntityStore): Entity[] { const entities = keys.map(key => { switch (type) { - case EntityType.User: + case EntityType.User: { const user = get(type, store, key) as NormalizedUser if (!user) return @@ -126,40 +122,48 @@ export function denormalize(keys: string[], type: EntityType, store: EntityStore ...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) + case EntityType.Invitation: { + const invitation = get(type, store, key) as NormalizedInvitation if (!invitation) return + const user = get(EntityType.User, store, invitation.user) as NormalizedUser + return { ...invitation, - user: get(EntityType.User, store, invitation.user), + user: { + ...user, + group: get(EntityType.Group, store, user.group), + }, } - case EntityType.Log: - const log = get(type, store, key) + } + case EntityType.Log: { + const log = get(type, store, key) as NormalizedGroupLog if (!log) return + const user = get(EntityType.User, store, log.user) as NormalizedUser + return { ...log, - user: get(EntityType.User, store, log.user), + user: { + ...user, + group: get(EntityType.Group, store, user.group), + }, } + } case EntityType.App: - const app = get(type, store, key) - if (!app) return - - return { - ...app, - user: get(EntityType.User, store, app.user), - } - case EntityType.Installation: - const installation = get(type, store, key) + return get(type, store, key) + case EntityType.Installation: { + const installation = get(type, store, key) as NormalizedInstallation if (!installation) return return { ...installation, app: get(EntityType.App, store, installation.app), } + } } })