diff --git a/src/app/main.tsx b/src/app/main.tsx index 08fb9e5..3741c34 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -2,7 +2,6 @@ import * as dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import { allSettled, createEvent, fork, forward } from 'effector'; import { Provider } from 'effector-react/scope'; -import { createBrowserApplication } from 'framework'; import React from 'react'; import ReactDOM from 'react-dom'; import { Router } from 'react-router-dom'; @@ -11,6 +10,7 @@ import '~/app/main.css'; import { historyChanged, historyPush, historyReplace } from '~/entities/navigation'; import { Pages } from '~/pages'; import { ROUTES } from '~/pages/routes'; +import { createBrowserApplication } from '~/shared/lib/framework'; dayjs.extend(relativeTime); diff --git a/src/pages/applications-edit/model.ts b/src/pages/applications-edit/model.ts index 760b871..c686386 100644 --- a/src/pages/applications-edit/model.ts +++ b/src/pages/applications-edit/model.ts @@ -56,7 +56,7 @@ sample({ }); const appLoaded = guard({ - clock: [appLoadFx.doneData], + clock: appLoadFx.doneData, filter: (application): application is LocalApp => application !== null, }); diff --git a/src/pages/registration-requests/index.ts b/src/pages/registration-requests/index.ts index 9edd383..cbb26d9 100644 --- a/src/pages/registration-requests/index.ts +++ b/src/pages/registration-requests/index.ts @@ -5,4 +5,4 @@ import * as page from './page'; contract({ page, model }); -export const RegistrationRequestsPage = withHatch(model.hatch, page.RegistationRequestsPage); +export const RegistrationRequestsPage = withHatch(model.hatch, page.RegistrationRequestsPage); diff --git a/src/pages/registration-requests/model.ts b/src/pages/registration-requests/model.ts index ebb068a..577d228 100644 --- a/src/pages/registration-requests/model.ts +++ b/src/pages/registration-requests/model.ts @@ -1,19 +1,23 @@ -import { createEffect, createEvent, createStore, guard, sample } from 'effector'; +import { createDomain, createEffect, createEvent, createStore, guard, sample } from 'effector'; import { createHatch } from 'framework'; import { every } from 'patronum'; import { LocalRegisterRequest, RequestStatus } from '~/pages/registration-requests/common'; import { mutation, query, resolved } from '~/shared/api'; -export const hatch = createHatch(); +export const hatch = createHatch(createDomain('registration-requests')); export const $emailForNewRequest = createStore(''); export const $newRequestStatus = createStore('new'); export const $registerRequests = createStore([]); +type Code = string; +export const $registerRequestPendingMap = createStore>({}); export const emailForNewRequestChanged = createEvent(); export const createRegistrationRequestClicked = createEvent(); export const registrationRequestDeleteClicked = createEvent<{ code: string }>(); +export const registrationRequestGenerateNewClicked = + createEvent<{ generateForEmail: string; clickedOnCode: string }>(); const validateRequestEmailFx = createEffect((email) => { if (email.match(/\w+@\w+/gim)) { @@ -29,6 +33,25 @@ const registrationRequestCreateFx = createEffect((targetEmail: string) => }), ); +const batchRegistrationRequestCreateFx = createEffect(async (emails: string[]) => { + for (const email of emails) { + await registrationRequestCreateFx(email); + } +}); + +const registrationRequestMakeANewFx = createEffect( + (options: { generateForEmail: string; clickedOnCode: string }) => + resolved( + () => { + const { email, code, expiresAt } = mutation.registerRequestCreate({ + email: options.generateForEmail, + }); + return { email, code, expiresAt, new: true } as LocalRegisterRequest; + }, + { refetch: true, noCache: true }, + ), +); + const loadRequestsFx = createEffect(() => resolved(() => { return query.registerRequests @@ -66,7 +89,11 @@ $newRequestStatus.on(emailForNewRequestChanged, () => 'new'); guard({ clock: createRegistrationRequestClicked, filter: every({ - stores: [validateRequestEmailFx.pending, registrationRequestCreateFx.pending], + stores: [ + validateRequestEmailFx.pending, + registrationRequestCreateFx.pending, + batchRegistrationRequestCreateFx.pending, + ], predicate: false, }), source: $emailForNewRequest, @@ -75,12 +102,17 @@ guard({ sample({ clock: validateRequestEmailFx.doneData, - target: registrationRequestCreateFx, + fn: (emailInput) => + emailInput + .split(' ') + .map((email) => email.trim()) + .filter(Boolean), + target: batchRegistrationRequestCreateFx, }); sample({ clock: every({ - stores: [validateRequestEmailFx.pending, registrationRequestCreateFx.pending], + stores: [validateRequestEmailFx.pending, batchRegistrationRequestCreateFx.pending], predicate: true, }), fn: () => 'pending' as RequestStatus, @@ -88,7 +120,7 @@ sample({ }); $newRequestStatus.on(registrationRequestCreateFx.done, () => 'done'); -$emailForNewRequest.reset(registrationRequestCreateFx.done); +$emailForNewRequest.reset(batchRegistrationRequestCreateFx.done); $registerRequests.on(registrationRequestCreateFx.doneData, (list, request) => [request, ...list]); $newRequestStatus.on(validateRequestEmailFx.fail, () => 'invalid'); @@ -100,6 +132,49 @@ sample({ target: registerRequestDeleteFx, }); +$registerRequestPendingMap + .on(registerRequestDeleteFx, (map, params) => ({ + ...map, + [params.code]: true, + })) + .on(registerRequestDeleteFx.finally, (map, { params }) => deleteIfExists(map, params.code)); + $registerRequests.on(registerRequestDeleteFx.done, (list, { params }) => list.filter((item) => item.code !== params.code), ); + +sample({ + clock: registrationRequestGenerateNewClicked, + target: registrationRequestMakeANewFx, +}); + +$registerRequestPendingMap + .on(registrationRequestMakeANewFx, (map, params) => ({ + ...map, + [params.clickedOnCode]: true, + })) + .on(registrationRequestMakeANewFx.finally, (map, { params }) => + deleteIfExists(map, params.clickedOnCode), + ); + +$registerRequests.on(registrationRequestMakeANewFx.done, (list, { params, result: request }) => { + const clickedRequestIndex = list.findIndex((item) => item.code === params.clickedOnCode); + + // If clicked on non existent element, index will be -1 + const insertIndex = Math.max(clickedRequestIndex, 0); + + // Add just before clicked element + list.splice(insertIndex, 0, request); + return [...list]; +}); + +/** + * Modifies original map and returns shallow copy + */ +function deleteIfExists(map: T, key: K): T { + if (map[key]) { + delete map[key]; + return { ...map }; + } + return map; +} diff --git a/src/pages/registration-requests/page.tsx b/src/pages/registration-requests/page.tsx index 5e7fb84..9649ccc 100644 --- a/src/pages/registration-requests/page.tsx +++ b/src/pages/registration-requests/page.tsx @@ -1,6 +1,6 @@ import dayjs from 'dayjs'; import { createEvent, createStore } from 'effector'; -import { useEvent, useList, useStore } from 'effector-react/scope'; +import { useEvent, useList, useStore, useStoreMap } from 'effector-react/scope'; import React from 'react'; import { UserAddIcon } from '@heroicons/react/outline'; @@ -24,8 +24,12 @@ export const $registerRequests = createStore([]); export const emailForNewRequestChanged = createEvent(); export const createRegistrationRequestClicked = createEvent(); export const registrationRequestDeleteClicked = createEvent<{ code: string }>(); +export const registrationRequestGenerateNewClicked = + createEvent<{ generateForEmail: string; clickedOnCode: string }>(); +type Code = string; +export const $registerRequestPendingMap = createStore>({}); -export const RegistationRequestsPage = () => { +export const RegistrationRequestsPage = () => { return ( @@ -75,8 +79,8 @@ function NewRegistrationRequest() { )} Email Code Expiration - + Actions @@ -156,16 +160,24 @@ function RegistrationRequest({ request }: { request: LocalRegisterRequest }) { const date = React.useMemo(() => dayjs(request.expiresAt), [request.expiresAt]); const isExpired = dayjs().isAfter(date); - const [deleting, toggleDelete] = React.useReducer((is) => !is, false); + const generateNewCode = useEvent(registrationRequestGenerateNewClicked); + const regenerateClicked = React.useCallback( + () => generateNewCode({ generateForEmail: request.email!, clickedOnCode: request.code! }), + [request.email, request.code, generateNewCode], + ); + + const [deleteOpened, toggleDelete] = React.useReducer((is) => !is, false); const deleteClicked = useEvent(registrationRequestDeleteClicked); const onDelete = React.useCallback( () => deleteClicked({ code: request.code! }), [request.code, deleteClicked], ); React.useEffect(() => { - if (deleting) toggleDelete(); + if (deleteOpened) toggleDelete(); }, [request.code]); + const pending = useRegisterRequestPending(request.code!); + return ( @@ -186,33 +198,58 @@ function RegistrationRequest({ request }: { request: LocalRegisterRequest }) { Actions: - {deleting ? ( + {pending ? ( + + Pending… + + ) : ( <> - + )} + {deleteOpened ? ( + <> + - - - ) : ( - + onClick={toggleDelete} + > + Delete + + )} + )} ); } + +function useRegisterRequestPending(code: Code) { + return useStoreMap({ + store: $registerRequestPendingMap, + keys: [code], + fn: (map, [code]) => map[code] ?? false, + }); +} diff --git a/src/pages/users-view/model.ts b/src/pages/users-view/model.ts index b5e901b..dfc4dc6 100644 --- a/src/pages/users-view/model.ts +++ b/src/pages/users-view/model.ts @@ -1,6 +1,6 @@ import { createEffect, createEvent, createStore, guard, restore, sample } from 'effector'; import { createHatch } from 'framework'; -import { spread } from 'patronum'; +import { every, spread } from 'patronum'; import { mutation, query, resolved, User } from '~/shared/api'; @@ -48,6 +48,7 @@ export const $firstName = restore(firstNameChanged, ''); export const $lastName = restore(lastNameChanged, ''); export const $isUserFound = createStore(false); export const $profileLoading = userLoadFx.pending; +export const $profileEditing = userSaveFx.pending; const $userId = hatch.$params.map((params) => params['userId']); @@ -72,8 +73,13 @@ spread({ $originalName.on(userLoaded, (_, { firstName, lastName }) => `${firstName} ${lastName}`); -sample({ +const readyToSubmit = sample({ source: { id: $id, email: $email, firstName: $firstName, lastName: $lastName }, clock: profileSubmitted, +}); + +guard({ + source: readyToSubmit, + filter: every({ stores: [$profileLoading, $profileEditing], predicate: false }), target: userSaveFx, }); diff --git a/src/pages/users-view/page.tsx b/src/pages/users-view/page.tsx index 684babc..5f17880 100644 --- a/src/pages/users-view/page.tsx +++ b/src/pages/users-view/page.tsx @@ -26,6 +26,7 @@ export const $originalName = createStore(''); export const $id = createStore(''); export const $isUserFound = createStore(false); export const $profileLoading = createStore(false); +export const $profileEditing = createStore(false); export function UsersViewPage() { // TODO: add breadcrumbs @@ -79,6 +80,8 @@ function Profile() { const email = useStore($email); const firstName = useStore($firstName); const lastName = useStore($lastName); + const saving = useStore($profileEditing); + const onEmailChange = useEvent(emailChanged); const onFirstNameChange = useEvent(firstNameChanged); const onLastNameChange = useEvent(lastNameChanged); @@ -101,7 +104,7 @@ function Profile() { - Save + {saving ? 'Saving…' : 'Save'} } > diff --git a/src/shared/lib/framework.ts b/src/shared/lib/framework.ts new file mode 100644 index 0000000..9968d3b --- /dev/null +++ b/src/shared/lib/framework.ts @@ -0,0 +1,106 @@ +import { Domain, Event, combine, forward, guard, createDomain } from 'effector'; +import { getHatch, HatchParams } from 'framework'; +import { splitMap } from 'patronum'; +import { RouteConfig, matchRoutes } from 'react-router-config'; + +import { createNavigation } from './navigation'; + +// eslint-disable-next-line sonarjs/cognitive-complexity +export function createBrowserApplication(config: { + ready: Event; + routes: RouteConfig[]; + domain?: Domain; +}) { + const domain = config.domain ?? createDomain('framework'); + const navigation = createNavigation(domain, { emitHistory: true }); + forward({ from: config.ready, to: navigation.historyEmitCurrent }); + + const routeResolved = navigation.historyChanged.filterMap((change) => { + const routes = matchRoutes(config.routes, change.pathname); + + if (routes.length > 0) { + return { + ...routes[0], + change, + }; + } + }); + + for (const { component, path } of config.routes) { + if (!component) continue; + if ((component as any).load) { + throw new Error( + `[${path}] lazy components temporary is not supported. Please, remove loadable() call`, + ); + } + + const { routeMatched, __: notMatched } = splitMap({ + source: routeResolved, + cases: { + routeMatched: ({ route, match, change }) => { + if (route.path === path) { + return { + // route.path contains params, like /user/:userId + // :userId is a param + // match.params contains parsed params values + // /user/123 will be parsed as { userId: 123 } + params: match.params, + query: Object.fromEntries(new URLSearchParams(change.search)), + }; + } + return undefined; + }, + }, + }); + + const hatchEnter = domain.createEvent({ name: `hatchEnter:${path}` }); + const hatchUpdate = domain.createEvent({ name: `hatchUpdate:${path}` }); + const hatchExit = domain.createEvent({ name: `hatchExit:${path}` }); + + const componentHatch = getHatch(component); + if (componentHatch) { + forward({ from: hatchEnter, to: componentHatch.enter }); + forward({ from: hatchUpdate, to: componentHatch.update }); + forward({ from: hatchExit, to: componentHatch.exit }); + } + + // Shows that user is on the route + const $onRoute = domain.createStore(false, { name: `$onRoute:${path}` }); + + // Shows that user visited route and waited for page + // If true, page.hatch.enter is triggered and logic was run + const $onPage = domain.createStore(false, { name: `$onPage:${path}` }); + + //#region route matched + $onRoute.on(routeMatched, () => true); + + guard({ + clock: routeMatched, + filter: $onPage, + target: hatchUpdate, + }); + + guard({ + clock: routeMatched, + filter: combine($onPage, $onRoute, (page, route) => !page && route), + target: hatchEnter, + }); + + $onPage.on(hatchEnter, () => true); + //#endregion route matched + + //#region NOT matched + $onRoute.on(notMatched, () => false); + + guard({ + clock: notMatched, + filter: $onPage, + target: hatchExit, + }); + + $onPage.on(hatchExit, () => false); + //#endregion NOT matched + } + + return { navigation }; +} diff --git a/src/shared/lib/navigation.ts b/src/shared/lib/navigation.ts new file mode 100644 index 0000000..592f7d2 --- /dev/null +++ b/src/shared/lib/navigation.ts @@ -0,0 +1,75 @@ +import { Domain, sample, scopeBind } from 'effector'; +import { createBrowserHistory, createMemoryHistory } from 'history'; + +export interface HistoryChange { + pathname: string; + hash: string; + search: string; + action: 'PUSH' | 'POP' | 'REPLACE'; +} + +export function createNavigation( + domain: Domain, + { emitHistory = false, trackRedirects = false } = {}, +) { + const history = typeof document !== 'undefined' ? createBrowserHistory() : createMemoryHistory(); + + const historyPush = domain.createEffect(() => {}); + const historyPushSearch = domain.createEffect(() => {}); + const historyReplace = domain.createEffect(() => {}); + + const historyChanged = domain.createEvent(); + + const historyEmitCurrent = domain.createEvent(); + + const $redirectTo = domain.createStore(''); + + // do not actual change history, just trigger history changed with correct arguments + sample({ + clock: historyEmitCurrent, + fn: () => + ({ + action: 'REPLACE', + hash: history.location.hash, + pathname: history.location.pathname, + search: history.location.search, + } as HistoryChange), + target: historyChanged, + }); + + if (emitHistory) { + historyPush.use((url) => history.push(url)); + historyReplace.use((url) => history.replace(url)); + historyPushSearch.use((search) => history.push({ search })); + + historyEmitCurrent.watch(() => { + let historyChangedBound: (payload: HistoryChange) => void; + try { + historyChangedBound = scopeBind(historyChanged); + } catch (_) { + historyChangedBound = (p) => historyChanged(p); + } + + history.listen(({ pathname, search, hash }, action) => { + historyChangedBound({ pathname, search, hash, action }); + }); + }); + } + + if (trackRedirects) { + $redirectTo.on([historyPush, historyReplace], (_, url) => url); + if (emitHistory) { + $redirectTo.on(historyChanged, (_, { pathname, search }) => `${pathname}?${search}`); + } + } + + return { + history, + historyPush, + historyPushSearch, + historyReplace, + historyChanged, + historyEmitCurrent, + $redirectTo, + }; +}