Dwayne Harris 5 years ago
parent
commit
2355ae4d44
  1. 41
      src/actions/apps.ts
  2. 52
      src/actions/composer.ts
  3. 14
      src/actions/registration.ts
  4. 29
      src/components/app-list-item.tsx
  5. 46
      src/components/composer.tsx
  6. 32
      src/components/create-group-form.tsx
  7. 2
      src/components/create-group-step.tsx
  8. 18
      src/components/create-user-form.tsx
  9. 4
      src/components/create-user-step.tsx
  10. 76
      src/components/forms/file-field.tsx
  11. 9
      src/components/forms/text-field.tsx
  12. 58
      src/components/pages/edit-app.tsx
  13. 10
      src/components/pages/home.tsx
  14. 4
      src/components/pages/register-group.tsx
  15. 10
      src/components/pages/register.tsx
  16. 2
      src/components/pages/self.tsx
  17. 16
      src/components/pages/view-app.tsx
  18. 63
      src/components/user-info.tsx
  19. 5
      src/hooks/index.ts
  20. 28
      src/reducers/composer.ts
  21. 2
      src/selectors/apps.ts
  22. 11
      src/selectors/composer.ts
  23. 2
      src/store/index.ts
  24. 36
      src/styles/app.scss
  25. 45
      src/types/entities.ts
  26. 7
      src/types/store.ts
  27. 14
      src/utils/index.ts
  28. 74
      src/utils/normalization.ts

41
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<string> => async dispatch => {
const { name, about, websiteUrl, companyName, version, composerUrl, rendererUrl } = options
export const createApp = (options: AppOptions): AppThunkAction<string> => 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<string> =>
version,
composerUrl,
rendererUrl,
imageUrl,
coverImageUrl,
iconImageUrl,
},
})
@ -168,6 +174,35 @@ export const createApp = (options: CreateAppOptions): AppThunkAction<string> =>
}
}
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))

52
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<FetchInstallationsResponse>({
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
}
}

14
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<string> => 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<string>
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<string> => 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<string> => as
email,
password,
name,
imageUrl,
coverImageUrl,
requiresApproval,
privacy,
group,

29
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<Props> = ({ app }) => (
<div className="app-list-item">
<Link to={`/a/${app.id}`} className="title has-text-primary">{app.name}</Link>
{app.about && <p>{app.about}</p>}
</div>
)
const AppListItem: FC<Props> = ({ app }) => {
const config = useConfig()
return (
<article className="media">
{app.imageUrl &&
<figure className="media-left">
<p className="image is-64x64">
<img src={`${config.blobUrl}${app.imageUrl}`} style={{ width: 64 }} />
</p>
</figure>
}
<div className="media-content">
<div className="content">
<Link to={`/a/${app.id}`} className="title has-text-primary">{app.name}</Link>
{app.about && <p>{app.about}</p>}
</div>
</div>
</article>
)
}
export default AppListItem

46
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<AppState, Installation[]>(getInstallations)
const selected = useSelector<AppState, Installation | undefined>(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 (
<div className="container composer-container">
<div className="composer composer-empty">
<p>{selected ? selected.app.name : 'Choose an app.'}</p>
</div>
<div className="installations is-flex">
{installations.map(installation => (
<div key={installation.id} className={selected && selected.id === installation.id ? 'selected' : ''} onClick={() => handleClick(installation.id)}>
<img src={`${config.blobUrl}${installation.app.iconImageUrl}`} alt={installation.app.name} style={{ width: 32 }} />
<p className="is-size-7 has-text-weight-bold">{installation.app.name}</p>
</div>
))}
</div>
</div>
)
}
export default Composer

32
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 = () => {
<div className="container">
<TextField name="group-name" label="Community Name" onBlur={e => checkAvailability(e.target.value)} />
<br />
<SelectField name="group-registration" label="Registration" options={registrationOptions} icon={faIdCard} />
<br />
<div className="field">
<label className="label">Community Image</label>
<div className="file is-primary">
<label className="file-label">
<input className="file-input" type="file" name="image" />
<span className="file-cta">
<span className="file-icon">
<FontAwesomeIcon icon={faUpload} />
</span>
<span className="file-label">
Choose an image...
</span>
</span>
</label>
</div>
<p className="help">Image must be smaller than 5 MBs.</p>
</div>
<FileField name="group-image" label="Community Image" help="Approx 128 x 128. Max 5 MBs." />
<br />
<FileField name="group-cover-image" label="Cover Image" help="Approx 400 x 200. Max 5 MBs." />
<br />
<FileField name="group-icon-image" label="Icon Image" help="Approx 32 x 32. Max 5 MBs." />
<br />
<CheckboxField name="group-agree">
I agree to the Communities <Link to="/terms/communities">terms and conditions</Link>.
</CheckboxField>

2
src/components/create-group-step.tsx

@ -49,7 +49,7 @@ const CreateGroupStep: FC<Props> = ({ register }) => {
}
return (
<div className="centered-content">
<div className="centered-content" style={{ maxWidth: 800 }}>
<div className="centered-content-icon has-background-primary">
<span className="icon is-large has-text-white">
<FontAwesomeIcon icon={faBuilding} size="2x" />

18
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 = () => {
<br />
<PasswordField placeholder="Your new password" />
<br />
<FileField name="user-image" label="Avatar" help="Approx 128 x 128. Max 5 MBs." previewWidth={64} />
<br />
<FileField name="user-cover-image" label="Cover Image" help="Approx 400 x 200. Max 5 MBs." />
<br />
<SelectField name="user-privacy" label="Privacy" options={PRIVACY_OPTIONS} icon={faUserShield} />
<br />
<CheckboxField name="user-requires-approval">
You must approve each Subscription request from other users.
Approve each Subscription request from other users.
</CheckboxField>
<br />
<br /><br /><br />
<CheckboxField name="user-agree">
I agree to the User <Link to="/terms">terms and conditions</Link>.
</CheckboxField>

4
src/components/create-user-step.tsx

@ -26,7 +26,7 @@ const CreateUserStep: FC = () => {
const name = valueFromForm<string>(form, 'user-name')
const email = valueFromForm<string>(form, 'user-email')
const password = valueFromForm<string>(form, 'password')
const agree = valueFromForm<boolean>(form, 'agree')
const agree = valueFromForm<boolean>(form, 'user-agree')
if (!userId || userId === '') {
dispatch(setFieldNotification('user-id', NotificationType.Error, 'This is required'))
@ -71,7 +71,7 @@ const CreateUserStep: FC = () => {
}
return (
<div className="centered-content">
<div className="centered-content" style={{ maxWidth: 800 }}>
<div className="centered-content-icon has-background-primary">
<span className="icon is-large has-text-white">
<FontAwesomeIcon icon={faUser} size="2x" />

76
src/components/forms/file-field.tsx

@ -16,14 +16,17 @@ interface Props {
name: string
label: string
help?: string
previewWidth?: number
}
const CheckboxField: FC<Props> = ({ name, label, help }) => {
const FileField: FC<Props> = ({ name, label, help, previewWidth = 128 }) => {
const value = useSelector<AppState, boolean>(state => getFieldValue<boolean>(state, name, false))
const config = useSelector<AppState, Config>(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<Props> = ({ 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<Props> = ({ 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 (
<div className="field">
@ -69,25 +98,34 @@ const CheckboxField: FC<Props> = ({ name, label, help }) => {
}
return (
<div className="field">
<label className="label">{label}</label>
<div className={classNames(classes)}>
<label className="file-label">
<input className="file-input" type="file" name={name} onChange={handleChange} />
<span className="file-cta">
<span className="file-icon">
<FontAwesomeIcon icon={faUpload} />
</span>
<span className="file-label">
Upload
<div>
<div className="field">
<label className="label">{label}</label>
{value &&
<div style={{ padding: '10px 0px' }}>
<img src={`${config.blobUrl}${value}`} style={{ width: previewWidth }} />
<br />
<a className="is-danger is-size-7" onClick={() => handleDelete()}>Delete</a>
</div>
}
<div className={classNames(classes)}>
<label className="file-label">
<input className="file-input" type="file" name={name} onChange={handleChange} />
<span className="file-cta">
<span className="file-icon">
<FontAwesomeIcon icon={faUpload} />
</span>
<span className="file-label">
Upload
</span>
</span>
</span>
{value && <span className="file-name">{value}</span>}
</label>
{value && <span className="file-name">{value}</span>}
</label>
</div>
<p className="help">{help}</p>
</div>
<p className="help">{help}</p>
</div>
)
}
export default CheckboxField
export default FileField

9
src/components/forms/text-field.tsx

@ -8,13 +8,14 @@ import { IconDefinition } from '@fortawesome/fontawesome-common-types'
import { setFieldValue } from 'src/actions/forms'
import { getFieldValue, getFieldNotification } from 'src/selectors/forms'
import { notificationTypeToClassName } from 'src/utils'
import { AppState, AppThunkDispatch, FormNotification, ClassDictionary } from 'src/types'
import { AppState, FormNotification, ClassDictionary } from 'src/types'
interface Props {
name: string
label: string
type?: 'text' | 'email'
placeholder?: string
help?: string
icon?: IconDefinition
onBlur?: FocusEventHandler<HTMLInputElement>
}
@ -24,6 +25,7 @@ const TextField: FC<Props> = ({
label,
type = 'text',
placeholder,
help,
icon,
onBlur = noop,
}) => {
@ -60,9 +62,8 @@ const TextField: FC<Props> = ({
</span>
}
</div>
{notification &&
<p className={classNames(helpClassDictionary)}>{notification.message}</p>
}
{(!notification && help) && <p className="help">{help}</p>}
{notification && <p className={classNames(helpClassDictionary)}>{notification.message}</p>}
</div>
)
}

58
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<Params>()
const app = useSelector<AppState, App | undefined>(state => getEntity<App>(state, EntityType.App, id))
const form = useSelector<AppState, Form>(getForm)
const selfId = useSelector<AppState, string | undefined>(getAuthenticatedUserId)
const dispatch = useDispatch<AppThunkDispatch>()
const history = useHistory()
@ -66,8 +69,48 @@ const EditApp: FC = () => {
if (!app) return <Loading />
const handleUpdate = () => {
const handleUpdate = async () => {
const name = valueFromForm<string>(form, 'name')
const about = valueFromForm<string>(form, 'about')
const websiteUrl = valueFromForm<string>(form, 'websiteUrl')
const companyName = valueFromForm<string>(form, 'companyName')
const version = valueFromForm<string>(form, 'version')
const composerUrl = valueFromForm<string>(form, 'composerUrl')
const rendererUrl = valueFromForm<string>(form, 'rendererUrl')
const imageUrl = valueFromForm<string>(form, 'image')
const coverImageUrl = valueFromForm<string>(form, 'coverImage')
const iconImageUrl = valueFromForm<string>(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 = () => {
<br />
<TextField name="companyName" label="Company" placeholder="Your company or organization (optional)" />
<br />
<TextField name="version" label="Version" placeholder="Current Version of the app (ex: 0.0.1beta5)" help={`Last Version: ${app.version}`} />
<br /><hr />
<FileField name="image" label="Image" />
<br />
<FileField name="coverImage" label="Cover Image" />
<br />
<FileField name="iconImage" label="Icon Image" />
<br />
<TextField name="version" label="Version" placeholder="Current Version of the app (ex: 0.0.1beta5)" />
<br /><hr />
<TextField name="composerUrl" label="Composer URL" placeholder="URL for the composer web page" />

10
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<AppState, boolean>(getAuthenticated)
useEffect(() => {
setTitle('Home')
})
@ -12,7 +20,7 @@ const Home: FC = () => {
<PageHeader title="Home" />
<div className="main-content">
Hello.
{authenticated && <Composer />}
</div>
</div>
)

4
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<string>(form, 'user-email', ''),
password: valueFromForm<string>(form, 'password', ''),
name: valueFromForm<string>(form, 'user-name', ''),
imageUrl: valueFromForm<string>(form, 'user-image', ''),
coverImageUrl: valueFromForm<string>(form, 'user-cover-image', ''),
requiresApproval: valueFromForm<boolean>(form, 'user-requires-approval', false),
privacy: valueFromForm<string>(form, 'user-privacy', 'public'),
group: id,

10
src/components/pages/register.tsx

@ -38,6 +38,8 @@ const Register: FC = () => {
email: valueFromForm<string>(form, 'user-email', ''),
password: valueFromForm<string>(form, 'password', ''),
name: valueFromForm<string>(form, 'user-name', ''),
imageUrl: valueFromForm<string>(form, 'user-image', ''),
coverImageUrl: valueFromForm<string>(form, 'user-cover-image', ''),
requiresApproval: valueFromForm<boolean>(form, 'user-requires-approval', true),
privacy: valueFromForm<string>(form, 'user-privacy', 'open')
}))
@ -45,6 +47,9 @@ const Register: FC = () => {
await dispatch(createGroup({
name: valueFromForm<string>(form, 'group-name', ''),
registration: valueFromForm<string>(form, 'group-registration', ''),
imageUrl: valueFromForm<string>(form, 'group-image', ''),
coverImageUrl: valueFromForm<string>(form, 'group-cover-image', ''),
iconImageUrl: valueFromForm<string>(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))

2
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))

16
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<Params>()
const app = useSelector<AppState, App | undefined>(state => getEntity<App>(state, EntityType.App, id))
const installations = useSelector<AppState, Installation[]>(getInstallations)
const selfId = useSelector<AppState, string | undefined>(getAuthenticatedUserId)
const fetching = useSelector<AppState, boolean>(state => getIsFetching(state, RequestKey.InstallApp) || getIsFetching(state, RequestKey.UninstallApp))
const dispatch = useDispatch<AppThunkDispatch>()
@ -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 <Loading />
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))

63
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<AppState, User | undefined>(getAuthenticatedUser)
const config = useSelector<AppState, Config>(getConfig)
const hasAvatar = user && user.imageUrl
const imageUrl = hasAvatar ? `${config.blobUrl}${user!.imageUrl}` : undefined
const name = () => {
if (!user) return <span></span>
if (user.name) {
return (
<>
<Link to="/self" className="is-size-4 has-text-white">{user.name}</Link>
<br />
<Link to="/self" className="is-size-6 has-text-white-ter">@{user.id}</Link>
</>
)
}
const imageUrl = user && user.imageUrl ? urlForBlob(config, user.imageUrl) : undefined
const name = (user: User) => {
if (user.name) return <Link to="/self" className="is-size-6 has-text-white">{user.name} @{user.id}</Link>
return <Link to="/self" className="is-size-4 has-text-white-ter">@{user.id}</Link>
}
const content = () => {
if (user) {
const group = user.group
const groupImageUrl = group && group.coverImageUrl ? urlForBlob(config, group.coverImageUrl) : undefined
return (
<div className="media-content">
<div className="content">
<div>
{name(user)}
<br />
{group &&
<div>
{name()}
<br />
{user.group &&
<Link to={`/c/${user.group.id}`} className="is-size-5 has-text-success">{user.group.name}</Link>
{groupImageUrl &&
<figure className="image is-16x16">
<img src="groupImageUrl" style={{ width: 16 }} />
</figure>
}
<Link to={`/c/${group.id}`} className="is-size-5 has-text-success">{group.name}</Link>
</div>
</div>
}
</div>
)
}
return (
<div className="media-content">
<div className="content">
<div className="has-text-centered">
<Link to="/login" className="is-size-5 has-text-white">Log In to Flexor</Link>
<p className="is-size-7 has-text-primary is-uppercase">
or
</p>
<Link to="/communities" className="is-size-6 has-text-light">Create an Account</Link>
</div>
</div>
<div className="has-text-centered">
<Link to="/login" className="is-size-5 has-text-white">Log In to Flexor</Link>
<p className="is-size-7 has-text-primary is-uppercase">or</p>
<Link to="/communities" className="is-size-6 has-text-light">Create an Account</Link>
</div>
)
}
@ -64,13 +54,16 @@ const UserInfo: FC = () => {
<article id="user-info" className="media has-background-black">
{imageUrl &&
<figure className="media-left">
<p className="image is-64x64">
<img src={imageUrl} style={{ width: 64 }} />
<p className="image is-32x32">
<img src={imageUrl} style={{ width: 32 }} />
</p>
</figure>
}
{content()}
<div className="media-content">
<div className="content">
{content()}
</div>
</div>
</article>
)
}

5
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<AppState, boolean>(getChecked)
@ -16,6 +17,8 @@ export const useAuthenticationCheck = () => {
}, [checked, authenticated])
}
export const useConfig = () => useSelector<AppState, Config>(getConfig)
const useDeepCompareMemoize = (value: any) => {
const ref = useRef()

28
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<ComposerState, ComposerActions> = (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

2
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[]

11
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
}

2
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<AppState>({
apps,
authentication,
composer,
config,
entities,
forms,

36
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;
}
}
}

45
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

7
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

14
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<T extends FormValue>(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)
}

74
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),
}
}
}
})

Loading…
Cancel
Save