Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/pages/applications-edit/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ sample({
});

const appLoaded = guard({
clock: [appLoadFx.doneData],
clock: appLoadFx.doneData,
filter: (application): application is LocalApp => application !== null,
});

Expand Down
2 changes: 1 addition & 1 deletion src/pages/registration-requests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
87 changes: 81 additions & 6 deletions src/pages/registration-requests/model.ts
Original file line number Diff line number Diff line change
@@ -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<RequestStatus>('new');
export const $registerRequests = createStore<LocalRegisterRequest[]>([]);
type Code = string;
export const $registerRequestPendingMap = createStore<Record<Code, boolean>>({});

export const emailForNewRequestChanged = createEvent<string>();
export const createRegistrationRequestClicked = createEvent();
export const registrationRequestDeleteClicked = createEvent<{ code: string }>();
export const registrationRequestGenerateNewClicked =
createEvent<{ generateForEmail: string; clickedOnCode: string }>();

const validateRequestEmailFx = createEffect<string, string, string>((email) => {
if (email.match(/\w+@\w+/gim)) {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -75,20 +102,25 @@ 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,
target: $newRequestStatus,
});

$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');
Expand All @@ -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<T extends object, K extends keyof T>(map: T, key: K): T {
if (map[key]) {
delete map[key];
return { ...map };
}
return map;
}
93 changes: 65 additions & 28 deletions src/pages/registration-requests/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,8 +24,12 @@ export const $registerRequests = createStore<LocalRegisterRequest[]>([]);
export const emailForNewRequestChanged = createEvent<string>();
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<Record<Code, boolean>>({});

export const RegistationRequestsPage = () => {
export const RegistrationRequestsPage = () => {
return (
<NavigationTemplate>
<StackedTemplate title="Registration Requests">
Expand Down Expand Up @@ -75,8 +79,8 @@ function NewRegistrationRequest() {
)}
<InputSearch
disabled={status === 'pending'}
className="rounded-r-none"
placeholder="email@domain.com"
className="rounded-r-none w-60"
placeholder="email@domain.com another@domain.com"
/>
<ButtonWhite
disabled={status === 'pending'}
Expand Down Expand Up @@ -143,7 +147,7 @@ function RegistrationRequestsList() {
<ColumnHead>Email</ColumnHead>
<ColumnHead>Code</ColumnHead>
<ColumnHead>Expiration</ColumnHead>
<ColumnHead>
<ColumnHead className="lg:w-80">
<span className="sr-only">Actions</span>
</ColumnHead>
</TableHead>
Expand All @@ -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 (
<Row className={request.new ? 'bg-yellow-50' : ''}>
<Column>
Expand All @@ -186,33 +198,58 @@ function RegistrationRequest({ request }: { request: LocalRegisterRequest }) {
</Column>
<Column className="text-right">
<span className="lg:hidden pr-2">Actions:</span>
{deleting ? (
{pending ? (
<span className="px-4 py-2 whitespace-nowrap text-md lg:text-sm border border-transparent select-none block text-center">
Pending…
</span>
) : (
<>
<button
className="px-4 py-2 whitespace-nowrap text-right text-md lg:text-sm font-medium text-red-600
{deleteOpened ? null : (
<button
className="px-4 py-2 whitespace-nowrap text-right text-md lg:text-sm font-medium border-transparent border rounded-md
text-indigo-600 hover:text-indigo-900 hover:bg-indigo-50"
onClick={regenerateClicked}
>
Create new
</button>
)}
{deleteOpened ? (
<>
<button
className="px-4 py-2 whitespace-nowrap text-right text-md lg:text-sm font-medium text-red-600
hover:text-white hover:bg-red-600 border-transparent border rounded-md"
onClick={onDelete}
>
Yes
</button>
<button
className="px-4 py-2 whitespace-nowrap text-right text-md lg:text-sm font-medium text-gray-600
onClick={onDelete}
>
Yes, delete
</button>
<button
className="px-4 py-2 whitespace-nowrap text-right text-md lg:text-sm font-medium text-gray-600
hover:text-gray-900 hover:bg-gray-200 border-transparent border rounded-md"
onClick={toggleDelete}
>
No, undo
</button>
</>
) : (
<button
className="px-4 py-2 whitespace-nowrap text-right text-md lg:text-sm font-medium text-red-600
onClick={toggleDelete}
>
No, undo
</button>
</>
) : (
<button
className="px-4 py-2 whitespace-nowrap text-right text-md lg:text-sm font-medium text-red-600
hover:text-red-900 hover:bg-red-50 border-transparent border rounded-md"
onClick={toggleDelete}
>
Delete
</button>
onClick={toggleDelete}
>
Delete
</button>
)}
</>
)}
</Column>
</Row>
);
}

function useRegisterRequestPending(code: Code) {
return useStoreMap({
store: $registerRequestPendingMap,
keys: [code],
fn: (map, [code]) => map[code] ?? false,
});
}
10 changes: 8 additions & 2 deletions src/pages/users-view/model.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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']);

Expand All @@ -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,
});
5 changes: 4 additions & 1 deletion src/pages/users-view/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -101,7 +104,7 @@ function Profile() {
<Card
footer={
<div className="text-right">
<ButtonPrimary>Save</ButtonPrimary>
<ButtonPrimary disabled={saving}>{saving ? 'Saving…' : 'Save'}</ButtonPrimary>
</div>
}
>
Expand Down
Loading