diff --git a/package.json b/package.json index ba5e928..3f8fe02 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,10 @@ "cross-fetch": "^3.0.4", "dotenv": "^8.2.0", "effector": "^21.8.12", - "effector-inspector": "^0.4.1", + "effector-inspector": "^0.5.0", "effector-react": "^21.3.3", "effector-reflect": "^0.3.2", - "effector-root": "^1.1.0", + "effector-root": "^1.3.0", "express": "^4.17.1", "history": "^4.10.1", "http-proxy-middleware": "^1.0.3", diff --git a/src/features/navigation/index.ts b/src/features/navigation/index.ts index c224789..84704a3 100644 --- a/src/features/navigation/index.ts +++ b/src/features/navigation/index.ts @@ -43,3 +43,5 @@ if (process.env.BUILD_TARGET === 'client') { fn: ({ pathname, params }) => `${pathname}${queryToString(params)}`, }); } + +export { EffectorSsrRedirect } from './lib'; diff --git a/src/features/navigation/lib/effector-ssr-redirect.ts b/src/features/navigation/lib/effector-ssr-redirect.ts new file mode 100644 index 0000000..52c185b --- /dev/null +++ b/src/features/navigation/lib/effector-ssr-redirect.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { useEvent } from 'effector-react/ssr'; +import { historyPush } from '../index'; + +/** + * A component similar to 'Redirect' from 'react-router' + * need to use this instead of original component, + * because the one from 'react-router' does not work with ssr + * */ +export const EffectorSsrRedirect = ({ href }: { href: string }) => { + const push = useEvent(historyPush); + useEffect(() => { + push(href); + }, [push, href]); + return null; +}; diff --git a/src/features/navigation/lib/index.ts b/src/features/navigation/lib/index.ts new file mode 100644 index 0000000..de88292 --- /dev/null +++ b/src/features/navigation/lib/index.ts @@ -0,0 +1 @@ +export { EffectorSsrRedirect } from './effector-ssr-redirect'; diff --git a/src/globals.ts b/src/globals.ts index b0a1943..61ff1c3 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -22,7 +22,7 @@ export const Globals = createGlobalStyle` --block-shadow: 0 3px 12px -3px var(--border-color); /* titles */ - --title-color: var(--black) + --title-color: var(--black); --title-height: 1.2rem; --h1-font-size: 4.2rem; diff --git a/src/lib/sleep.ts b/src/lib/sleep.ts new file mode 100644 index 0000000..0d7f188 --- /dev/null +++ b/src/lib/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/pages/access-recovery/confirm/page.tsx b/src/pages/access-recovery/confirm/page.tsx index 34f9de6..3772429 100644 --- a/src/pages/access-recovery/confirm/page.tsx +++ b/src/pages/access-recovery/confirm/page.tsx @@ -44,7 +44,7 @@ export const AccessRecoveryConfirmPage = withStart(pageStart, () => ( - + diff --git a/src/pages/home/page.tsx b/src/pages/home/page.tsx index 4602bc1..ed65f91 100644 --- a/src/pages/home/page.tsx +++ b/src/pages/home/page.tsx @@ -1,10 +1,10 @@ import React from 'react'; +import { createEvent } from 'effector-root'; +import { useStore } from 'effector-react/ssr'; +import { reflect } from 'effector-reflect/ssr'; import styled from 'styled-components'; import { Button } from 'woly'; import { withStart, createStart } from 'lib/page-routing'; -import { useStore } from 'effector-react/ssr'; -import { createEvent } from 'effector-root'; -import { reflect } from 'effector-reflect/ssr'; import { $fullName, $showError } from './model'; export const pageStarted = createStart(); diff --git a/src/pages/login/page.tsx b/src/pages/login/page.tsx index e6a2740..3126c7d 100644 --- a/src/pages/login/page.tsx +++ b/src/pages/login/page.tsx @@ -7,7 +7,7 @@ import { createEvent, createStore } from 'effector-root'; import { reflect } from 'effector-reflect/ssr'; import { withStart, createStart } from 'lib/page-routing'; import Logo from 'logo.svg'; -import { CenterCardTemplate } from '@auth/ui'; +import { CenterCardTemplate } from 'ui'; import { path } from 'pages/paths'; import { Failure } from './types'; diff --git a/src/pages/paths.ts b/src/pages/paths.ts index 0cd3c08..b0fbd36 100644 --- a/src/pages/paths.ts +++ b/src/pages/paths.ts @@ -6,4 +6,9 @@ export const path = { accessRecovery: () => '/access-recovery', accessRecoveryConfirm: (code: string) => `/access-recovery/confirm-${code}`, oauthAuthorize: () => '/oauth/authorize', + settings: { + base: () => '/settings', + profile: () => `${path.settings.base()}/profile`, + emails: () => `${path.settings.base()}/emails`, + }, }; diff --git a/src/pages/routes.ts b/src/pages/routes.ts index 234e77a..dc6f953 100644 --- a/src/pages/routes.ts +++ b/src/pages/routes.ts @@ -8,6 +8,7 @@ import { RegisterConfirmPage } from './register/confirm'; import { RegisterPage } from './register'; import { AccessRecoveryPage } from './access-recovery'; import { AccessRecoveryConfirmPage } from './access-recovery/confirm'; +import { SettingsPage } from './settings'; export const routes = [ { @@ -45,6 +46,11 @@ export const routes = [ exact: true, component: AccessRecoveryConfirmPage, }, + { + path: path.settings.base(), + exact: false, + component: SettingsPage, + }, { path: '*', component: Error404Page, diff --git a/src/pages/settings/emails/index.ts b/src/pages/settings/emails/index.ts new file mode 100644 index 0000000..55e4008 --- /dev/null +++ b/src/pages/settings/emails/index.ts @@ -0,0 +1,22 @@ +import { sample } from 'effector-root'; +import { contract } from 'lib/contract'; +import * as model from './model'; +import * as page from './page'; + +export { EmailsProfilePage } from './page'; + +sample({ + clock: page.newEmailChanged, + fn: (e) => e.target.value, + target: model.changeEmail, +}); +contract({ + page, + model: { + ...model, + formSubmitted: model.submitForm.prepend(() => undefined), + $userNewEmail: model.$newEmail, + passwordChanged: model.changePassword.prepend((e) => e.target.value), + newEmailChanged: model.changeEmail.prepend((e) => e.target.value), + }, +}); diff --git a/src/pages/settings/emails/model.ts b/src/pages/settings/emails/model.ts new file mode 100644 index 0000000..7ef4051 --- /dev/null +++ b/src/pages/settings/emails/model.ts @@ -0,0 +1,81 @@ +import { + combine, + createStore, + createEffect, + createEvent, + restore, + guard, +} from 'effector-root'; +import { sleep } from 'lib/sleep'; +import { validateEmail } from 'lib/email'; +import { EmailErrorType, RequestFailure } from './types'; + +const requestFx = createEffect( + async (req: { email: string; password: string }) => { + await sleep(2000); + const isSuccess = Math.random() > 0.5; + if (isSuccess) return req; + + throw { + type: RequestFailure.required, + }; + }, +); + +const changeEmail = createEvent(); +const $newEmail = restore(changeEmail, ''); +const changePassword = createEvent(); +const $password = restore(changePassword, ''); + +const $emailError = $newEmail.map((email) => { + if (!email) return EmailErrorType.required; + if (!validateEmail(email)) return EmailErrorType.invalid; + return null; +}); +const $isEmailValid = $emailError.map((error) => !error); +const $isPasswordValid = $password.map(Boolean); + +const $isFormValid = combine( + $isEmailValid, + $isPasswordValid, + (isEmail, isPw) => isEmail && isPw, +); + +const $isFormPending = requestFx.pending; + +const $isSubmitDisabled = combine( + $isFormValid, + $isFormPending, + (isValid, isPending) => !isValid || isPending, +); + +const $isFormDisabled = $isFormPending.map((is) => is); + +const $errorType = createStore(null); + +const submitForm = createEvent(); + +requestFx.finally.watch((res) => { + if (res.status === 'fail') console.log(res.error); + if (res.status === 'done') console.log(res.result); +}); + +guard({ + clock: submitForm, + filter: $isSubmitDisabled.map((is) => !is), + source: { email: $newEmail, password: $password }, + target: requestFx, +}); + +export { + submitForm, + $newEmail, + changeEmail, + $password, + changePassword, + $emailError, + $isFormDisabled, + $isSubmitDisabled, + $isFormPending, + $errorType, +}; diff --git a/src/pages/settings/emails/page.tsx b/src/pages/settings/emails/page.tsx new file mode 100644 index 0000000..98a03db --- /dev/null +++ b/src/pages/settings/emails/page.tsx @@ -0,0 +1,128 @@ +import React, { ChangeEvent, FormEvent } from 'react'; +import styled from 'styled-components'; +import { reflect } from 'effector-reflect'; +import { Button, Form, Input, Title } from 'woly'; +import { createEvent, createStore, StoreValue } from 'effector-root'; +import { EmailErrorType, RequestFailure } from './types'; + +export const formSubmitted = createEvent(); + +export const $userNewEmail = createStore(''); +export const newEmailChanged = createEvent>(); +export const $password = createStore(''); +export const passwordChanged = createEvent>(); + +export const $emailError = createStore(null); +const $emailErrorMessage = $emailError.map((error) => { + if (error === EmailErrorType.required) return 'Email is required'; + if (error === EmailErrorType.invalid) return 'Email is invalid'; + return null; +}); +const $passwordErrorMessage = $password.map((pw) => { + if (!pw) return 'Password is required'; + return null; +}); + +export const $isFormDisabled = createStore(false); +export const $isSubmitDisabled = createStore(false); + +export const $isFormPending = createStore(false); +const $submitText = $isFormPending.map((is) => (is ? 'Sending...' : 'Change')); + +export const $errorType = createStore(null); +const $errorText = $errorType.map((errorType) => { + if (!errorType) return null; + return 'AAAA'; +}); + +export const EmailsProfilePage = () => { + return ( + + + Change email form + Email + + + Password + + + + + + + ); +}; + +const Email = reflect({ + view: Input, + bind: { + value: $userNewEmail, + onChange: newEmailChanged, + disabled: $isFormDisabled, + }, +}); +const Password = reflect({ + view: Input, + bind: { + value: $password, + onChange: passwordChanged, + disabled: $isFormDisabled, + }, +}); +const EmailError = reflect({ + view: ({ error }: { error: StoreValue }) => { + if (error) return {error}; + return null; + }, + bind: { + error: $emailErrorMessage, + }, +}); +const PasswordError = reflect({ + view: ({ error }: { error: StoreValue }) => { + if (error) return {error}; + return null; + }, + bind: { + error: $passwordErrorMessage, + }, +}); + +const ErrorBlock = reflect({ + view: ({ failure }: { failure: string | null }) => { + if (failure) { + return {failure}; + } + return null; + }, + bind: { + failure: $errorText, + }, +}); + +const Submit = reflect({ + view: Button, + bind: { + disable: $isSubmitDisabled, + text: $submitText, + }, +}); + +const EmailForm = reflect({ + view: Form, + bind: { + onSubmit: formSubmitted, + }, +}); + +const Fail = styled.div` + font-size: 1.3rem; + margin-bottom: 1rem; +`; +const ErrorTitle = styled.span` + color: red; + display: block; +`; +const FormWrapper = styled.div` + padding: 3rem; +`; diff --git a/src/pages/settings/emails/types.ts b/src/pages/settings/emails/types.ts new file mode 100644 index 0000000..43dda95 --- /dev/null +++ b/src/pages/settings/emails/types.ts @@ -0,0 +1,7 @@ +export enum EmailErrorType { + required = 'required', + invalid = 'invalid', +} +export enum RequestFailure { + required = 'required', +} diff --git a/src/pages/settings/index.ts b/src/pages/settings/index.ts new file mode 100644 index 0000000..129072d --- /dev/null +++ b/src/pages/settings/index.ts @@ -0,0 +1,7 @@ +import { contract } from 'lib/contract'; +import * as page from './page'; +import * as model from './model'; + +export { SettingsPage } from './page'; + +contract({ page, model }); diff --git a/src/pages/settings/model.ts b/src/pages/settings/model.ts new file mode 100644 index 0000000..f681e10 --- /dev/null +++ b/src/pages/settings/model.ts @@ -0,0 +1,5 @@ +import { createStart } from 'lib/page-routing'; +import { checkAuthenticated } from '../../features/session'; + +export const pageStarted = createStart(); +checkAuthenticated({ when: pageStarted }); diff --git a/src/pages/settings/page.tsx b/src/pages/settings/page.tsx new file mode 100644 index 0000000..14d36fd --- /dev/null +++ b/src/pages/settings/page.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; +import { EffectorSsrRedirect } from 'features/navigation'; +import { createStart, withStart } from 'lib/page-routing'; +import { path } from '../paths'; +import { SettingsProfilePage } from './profile'; +import { EmailsProfilePage } from './emails'; + +export const pageStarted = createStart(); + +export const SettingsPage = withStart(pageStarted, () => { + return ( + + + + + + + + + + + + ); +}); diff --git a/src/pages/settings/profile/index.ts b/src/pages/settings/profile/index.ts new file mode 100644 index 0000000..30d3242 --- /dev/null +++ b/src/pages/settings/profile/index.ts @@ -0,0 +1,15 @@ +import { contract } from 'lib/contract'; +import * as page from './page'; +import * as model from './model'; + +export { SettingsProfilePage } from './page'; + +contract({ + page, + model: { + ...model, + firstNameChanged: model.changeFirstName, + lastNameChanged: model.changeLastName, + formSubmitted: model.submitForm, + }, +}); diff --git a/src/pages/settings/profile/model.ts b/src/pages/settings/profile/model.ts new file mode 100644 index 0000000..2d57df1 --- /dev/null +++ b/src/pages/settings/profile/model.ts @@ -0,0 +1,105 @@ +import { ChangeEvent, FormEvent } from 'react'; +import { createStart } from 'lib/page-routing'; +import { + createStore, + createEvent, + createEffect, + combine, + guard, +} from 'effector-root'; +import { $session } from 'features/session'; +import { sleep } from 'lib/sleep'; +import { RequestFailure, FieldError } from './types'; + +export const pageStarted = createStart(); + +const requestFx = createEffect( + async (req: { firstName: string; lastName: string }) => { + await sleep(2000); + const isSuccess = Math.random() > 0.5; + if (isSuccess) { + return req; + } + const errorTypes = [ + RequestFailure.one, + RequestFailure.two, + RequestFailure.unexpected, + ]; + const errorIndex = Math.floor(Math.random() * errorTypes.length); + throw { + type: errorTypes[errorIndex], + }; + }, +); + +const $userFirstName = createStore(''); +const $userLastName = createStore(''); +const changeFirstName = createEvent>(); +const changeLastName = createEvent>(); + +$userFirstName + .on(changeFirstName, (_, e) => e.target.value) + .on($session, (_, session) => session?.firstName); +$userLastName + .on(changeLastName, (_, e) => e.target.value) + .on($session, (_, session) => session?.lastName); + +function validateField(field: string) { + if (!field) return FieldError.required; + if (field.length > 32) return FieldError.maxLength; + return null; +} +const $firstNameError = $userFirstName.map(validateField); +const $lastNameError = $userLastName.map(validateField); + +const $isFirstNameValid = $firstNameError.map((error) => !error); +const $isLastNameValid = $lastNameError.map((error) => !error); + +const $isFormValid = combine( + $isFirstNameValid, + $isLastNameValid, + (isFirst, isLast) => isFirst && isLast, +); +const $isFormPending = requestFx.pending; + +const $isSubmitDisabled = combine( + $isFormValid, + $isFormPending, + (isValid, isPending) => !isValid || isPending, +); +const $isFormDisabled = $isFormPending.map((is) => is); + +const $errorType = createStore(null); + +const submitForm = createEvent(); + +guard({ + clock: submitForm, + filter: $isSubmitDisabled.map((is) => !is), + source: { firstName: $userFirstName, lastName: $userLastName }, + target: requestFx, +}); + +// todo: replace with mocks +requestFx.finally.watch((res) => { + if (res.status === 'fail') { + console.log(res.error); + } + if (res.status === 'done') { + console.log(res.result); + } +}); + +export { + $userFirstName, + $userLastName, + changeFirstName, + changeLastName, + submitForm, + $firstNameError, + $lastNameError, + $isFormDisabled, + $isSubmitDisabled, + $isFormPending, + $errorType, +}; diff --git a/src/pages/settings/profile/page.tsx b/src/pages/settings/profile/page.tsx new file mode 100644 index 0000000..0a43aed --- /dev/null +++ b/src/pages/settings/profile/page.tsx @@ -0,0 +1,155 @@ +import React, { ChangeEvent, FormEvent } from 'react'; +import { Button, Form, Input, Title } from 'woly'; +import { combine, createEvent, createStore, StoreValue } from 'effector-root'; +import { reflect } from 'effector-reflect/ssr'; +import styled from 'styled-components'; +import { FieldError, RequestFailure } from './types'; + +export const $userFirstName = createStore(''); +export const $userLastName = createStore(''); +export const firstNameChanged = createEvent>(); +export const lastNameChanged = createEvent>(); +export const formSubmitted = createEvent(); + +export const $firstNameError = createStore(null); +export const $lastNameError = createStore(null); +const $firstNameErrorMessage = combine( + $firstNameError, + $userFirstName, + (error, firstName) => { + if (error === FieldError.required) return 'Field is required'; + if (error === FieldError.maxLength) + return `Field is too long (${firstName.length}/32)`; + return null; + }, +); +const $lastNameErrorMessage = combine( + $lastNameError, + $userLastName, + (error, lastName) => { + if (error === FieldError.required) return 'Field is required'; + if (error === FieldError.maxLength) + return `Field is too long (${lastName.length}/32)`; + return null; + }, +); + +export const $isFormDisabled = createStore(false); +export const $isSubmitDisabled = createStore(false); + +export const $isFormPending = createStore(false); +const $submitText = $isFormPending.map((isPending) => + isPending ? 'Sending...' : 'Change', +); + +export const $errorType = createStore(null); +const $errorText = $errorType.map((errorType) => { + if (!errorType) return null; + return 'AAAA'; +}); + +export const SettingsProfilePage = () => { + return ( + + + + + + + + + ); +}; + +const FirstNameField = () => { + return ( + + First name + + + + ); +}; +const LastNameField = () => { + return ( + + Last name + + + + ); +}; + +const FirstName = reflect({ + view: Input, + bind: { + value: $userFirstName, + onChange: firstNameChanged, + disabled: $isFormDisabled, + }, +}); +const LastName = reflect({ + view: Input, + bind: { + value: $userLastName, + onChange: lastNameChanged, + disabled: $isFormDisabled, + }, +}); +const FirstNameError = reflect({ + bind: { + error: $firstNameErrorMessage, + }, + view: ({ error }: { error: StoreValue }) => { + if (error) return {error}; + return null; + }, +}); +const LastNameError = reflect({ + bind: { + error: $lastNameErrorMessage, + }, + view: ({ error }: { error: StoreValue }) => { + if (error) return {error}; + return null; + }, +}); +const ErrorBlock = reflect({ + bind: { + failure: $errorText, + }, + view: ({ failure }: { failure: string | null }) => { + if (failure) { + return {failure}; + } + return null; + }, +}); +const Submit = reflect({ + view: Button, + bind: { + disabled: $isSubmitDisabled, + text: $submitText, + }, +}); +const UserForm = reflect({ + view: Form, + bind: { + onSubmit: formSubmitted, + }, +}); + +const FormWrapper = styled.div` + padding: 3rem; +`; +const Fail = styled.div` + font-size: 1.3rem; + margin-bottom: 1rem; +`; +const ErrorTitle = styled.span` + color: red; +`; +const FieldWrapper = styled.div` + padding: 2rem; + border: 1px solid; +`; diff --git a/src/pages/settings/profile/types.ts b/src/pages/settings/profile/types.ts new file mode 100644 index 0000000..ec81ce4 --- /dev/null +++ b/src/pages/settings/profile/types.ts @@ -0,0 +1,10 @@ +export enum RequestFailure { + one = 'one', + two = 'two', + unexpected = 'unexpected', +} + +export enum FieldError { + required = 'required', + maxLength = 'max-len', +}