Skip to content

Commit 0f700ad

Browse files
antonmazhutoАнтон Мажутоsergeysova
authored
BOX-94 BOX-237 Add ability to add/remove card to favorites (#84)
* BOX-94 Add ability to add/remove card to favorites * BOX-94 Add ability to add/remove card to favorites * BOX-94 Add ability to add/remove card to favorites * BOX-94 Add ability to add/remove card to favorites * BOX-94 Add ability to add/remove card to favorites * BOX-94 Add ability to add/remove card to favorites * refactor(entities/card): remove currentCard * refactor(entities/card): remove unused $cards * fix(entities/card): favorites should work Co-authored-by: Антон Мажуто <[email protected]> Co-authored-by: Sergey Sova <[email protected]>
1 parent 9433af9 commit 0f700ad

File tree

15 files changed

+298
-109
lines changed

15 files changed

+298
-109
lines changed

openapi.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module.exports = {
22
file: 'https://cardbox.github.io/backend/api-internal/openapi.yaml',
33
templateFileNameCode: 'index.gen.ts',
4-
outputDir: './src/api/internal',
4+
outputDir: './src/shared/api/internal',
55
presets: [
66
[
77
'effector-openapi-preset',

src/entities/card/model/index.ts

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,61 @@
1-
import type { Card } from '@box/shared/api';
2-
import { createEvent, createStore } from 'effector';
3-
import { internalApi } from '@box/shared/api';
4-
5-
export const setCards = createEvent<Card[]>();
1+
import { Card, internalApi } from '@box/shared/api';
2+
import { attach, combine, createEvent, createStore, sample } from 'effector';
63

74
export const $cardsCache = createStore<{ cache: Record<string, Card> }>({
85
cache: {},
96
});
107

11-
export const $cards = createStore<Card[]>([]);
12-
export const $currentCard = createStore<Card | null>(null);
13-
// TODO: remove current card id and current card store from entities
14-
export const $currentCardId = $currentCard.map((card) =>
15-
card ? card.id : null,
16-
);
8+
export const cardsSaveFx = attach({ effect: internalApi.cardsSave });
9+
export const cardsUnsaveFx = attach({ effect: internalApi.cardsUnsave });
1710

18-
$cards.on(internalApi.cardsList.done, (cards, { params, result }) =>
19-
!params.body?.favorites ? (result.answer.cards as Card[]) : cards,
20-
);
11+
// @TODO It's bad practice to use global store. Will be fixed after BOX-250
12+
export const $favoritesIds = createStore<string[]>([]);
2113

22-
$cards.on(setCards, (_, cards) => cards);
14+
export const changeFavorites = createEvent<string[]>();
15+
changeFavorites.watch((list) => console.info('————', list));
16+
export const $favoritesCards = combine(
17+
$favoritesIds,
18+
$cardsCache,
19+
(ids, { cache }) => ids.map((id) => cache[id] ?? null),
20+
);
2321

24-
$currentCard.on(internalApi.cardsGet.doneData, (_, { answer }) => answer.card);
22+
export const favoritesAdd = createEvent<string>();
23+
export const favoritesRemove = createEvent<string>();
2524

2625
$cardsCache
2726
.on(internalApi.cardsList.doneData, (cache, { answer }) =>
2827
updateCache(cache, answer.cards as Card[]),
2928
)
30-
.on(internalApi.cardsGet.doneData, (cache, { answer }) =>
31-
updateCache(cache, [answer.card as Card]),
32-
)
33-
.on(internalApi.cardsCreate.doneData, (cache, { answer }) =>
34-
updateCache(cache, [answer.card as Card]),
35-
)
36-
.on(internalApi.cardsEdit.doneData, (cache, { answer }) =>
37-
updateCache(cache, [answer.card as Card]),
29+
.on(
30+
[
31+
internalApi.cardsGet.doneData,
32+
internalApi.cardsCreate.doneData,
33+
internalApi.cardsEdit.doneData,
34+
internalApi.cardsSave.doneData,
35+
internalApi.cardsUnsave.doneData,
36+
],
37+
(cache, { answer }) => updateCache(cache, [answer.card as Card]),
38+
);
39+
40+
sample({
41+
clock: favoritesAdd,
42+
fn: (cardId) => ({ body: { cardId } }),
43+
target: cardsSaveFx,
44+
});
45+
46+
sample({
47+
clock: favoritesRemove,
48+
fn: (cardId) => ({
49+
body: { cardId },
50+
}),
51+
target: cardsUnsaveFx,
52+
});
53+
54+
$favoritesIds
55+
.on(changeFavorites, (_, ids) => ids)
56+
.on(cardsSaveFx.doneData, (ids, { answer }) => [...ids, answer.card.id])
57+
.on(cardsUnsaveFx.doneData, (ids, { answer }) =>
58+
ids.filter((s) => s !== answer.card.id),
3859
);
3960

4061
function updateCache<T extends { id: string }>(

src/entities/card/organisms/card-list.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,7 @@ export const CardList = ({ cards, loading }: Props) => {
2626
return (
2727
<Container>
2828
{cards.map((card, i) => (
29-
<CardPreview
30-
key={card.id}
31-
card={card}
32-
// FIXME: temp hack, will be optimized later
33-
isCardInFavorite={i % 2 === 0}
34-
href={paths.cardView(card.id)}
35-
size="small"
36-
/>
29+
<CardPreview key={card.id} card={card} size="small" />
3730
))}
3831
</Container>
3932
);

src/entities/card/organisms/card-preview.tsx

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'dayjs/plugin/relativeTime';
22

33
import dayjs from 'dayjs';
44
import styled from 'styled-components';
5-
import React, { forwardRef } from 'react';
5+
import React, { forwardRef, useCallback } from 'react';
66
import {
77
Button,
88
IconDeckArrow,
@@ -11,23 +11,23 @@ import {
1111
Skeleton,
1212
Text,
1313
} from '@box/shared/ui';
14-
import type { Card, User } from '@box/shared/api';
14+
import type { Card } from '@box/shared/api';
1515
import { Editor, useExtendedEditor } from '@cardbox/editor';
1616
import type { EditorValue } from '@cardbox/editor';
1717
import { HighlightText } from '@box/entities/search';
1818
import { Link } from 'react-router-dom';
1919
import { breakpoints } from '@box/shared/lib/breakpoints';
20+
import { cardModel } from '@box/entities/card';
2021
import { navigationModel } from '@box/entities/navigation';
22+
import { paths } from '@box/pages/paths';
2123
import { theme } from '@box/shared/lib/theme';
22-
import { useEvent } from 'effector-react';
24+
import { useEvent, useStoreMap } from 'effector-react/scope';
2325
import { useMouseSelection } from '@box/shared/lib/use-mouse-selection';
2426

2527
type CardSize = 'small' | 'large';
2628

2729
interface CardPreviewProps {
2830
card: Card;
29-
isCardInFavorite?: boolean;
30-
href?: string;
3131
loading?: boolean;
3232
/**
3333
* @remark May be in future - make sense to split independent components - CardItem, CardDetails
@@ -38,13 +38,25 @@ interface CardPreviewProps {
3838

3939
export const CardPreview = ({
4040
card,
41-
isCardInFavorite = false,
42-
href,
4341
loading = false,
4442
size = 'small',
4543
}: CardPreviewProps) => {
44+
const href = paths.cardView(card.id);
45+
const isCardInFavorites = useStoreMap({
46+
store: cardModel.$favoritesCards,
47+
keys: [card.id],
48+
fn: (list, [id]) => list.some((card) => card.id === id),
49+
});
50+
51+
const addToFavorites = useEvent(cardModel.favoritesAdd);
52+
const removeFromFavorites = useEvent(cardModel.favoritesRemove);
4653
const historyPush = useEvent(navigationModel.historyPush);
4754

55+
const toggleFavorites = useCallback(() => {
56+
if (isCardInFavorites) removeFromFavorites(card.id);
57+
else addToFavorites(card.id);
58+
}, [addToFavorites, removeFromFavorites, card.id, isCardInFavorites]);
59+
4860
const { handleMouseDown, handleMouseUp, buttonRef } = useMouseSelection(
4961
(inNewTab = false) => {
5062
if (!href) return;
@@ -66,13 +78,18 @@ export const CardPreview = ({
6678
aria-label="Open card"
6779
>
6880
<ContentBlock>
69-
<Content card={card} href={href} size={size} />
81+
<Content card={card} size={size} />
7082

7183
<OverHelm />
7284
</ContentBlock>
7385

7486
{size === 'small' && <Meta card={card} />}
75-
<AddButton ref={buttonRef} isCardToDeckAdded={isCardInFavorite} />
87+
<SaveCardButton
88+
ref={buttonRef}
89+
onClick={toggleFavorites}
90+
isCardInFavorites={isCardInFavorites}
91+
card={card}
92+
/>
7693
</PaperContainerStyled>
7794
);
7895
};
@@ -120,9 +137,10 @@ const PaperContainerStyled = styled(PaperContainer)<{
120137
}
121138
`;
122139

123-
type ContentProps = { card: Card } & Pick<CardPreviewProps, 'href' | 'size'>;
140+
type ContentProps = { card: Card } & Pick<CardPreviewProps, 'size'>;
124141

125-
const Content = ({ card, size, href }: ContentProps) => {
142+
const Content = ({ card, size }: ContentProps) => {
143+
const href = paths.cardView(card.id);
126144
const editor = useExtendedEditor();
127145
return (
128146
<ContentStyled>
@@ -200,35 +218,41 @@ const ContentStyled = styled.div`
200218
overflow: hidden;
201219
`;
202220

203-
const AddButton = forwardRef<HTMLButtonElement, { isCardToDeckAdded: boolean }>(
204-
({ isCardToDeckAdded }, ref) => {
205-
const handleClick: React.MouseEventHandler = (e) => {
206-
e.stopPropagation();
207-
};
208-
209-
if (isCardToDeckAdded) {
210-
return (
211-
<CardButton
212-
ref={ref}
213-
onClick={handleClick}
214-
variant="outlined"
215-
theme="primary"
216-
icon={<IconDeckCheck title="Remove card from my deck" />}
217-
/>
218-
);
219-
}
221+
const SaveCardButton = forwardRef<
222+
HTMLButtonElement,
223+
{
224+
isCardInFavorites: boolean;
225+
card: Card;
226+
onClick: (id: string) => void;
227+
}
228+
>(({ isCardInFavorites, card, onClick }, ref) => {
229+
const handleClick: React.MouseEventHandler = (e) => {
230+
e.stopPropagation();
231+
onClick(card.id);
232+
};
220233

234+
if (isCardInFavorites) {
221235
return (
222236
<CardButton
223237
ref={ref}
224238
onClick={handleClick}
225239
variant="outlined"
226-
theme="secondary"
227-
icon={<IconDeckArrow title="Add card to my deck" />}
240+
theme="primary"
241+
icon={<IconDeckCheck title="Remove card from my deck" />}
228242
/>
229243
);
230-
},
231-
);
244+
}
245+
246+
return (
247+
<CardButton
248+
ref={ref}
249+
onClick={handleClick}
250+
variant="outlined"
251+
theme="secondary"
252+
icon={<IconDeckArrow title="Add card to my deck" />}
253+
/>
254+
);
255+
});
232256

233257
const ContentBlock = styled.div`
234258
display: flex;

src/features/card/draft/model.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as editorLib from '@box/shared/lib/editor';
2-
import type { CardContent } from '@box/shared/api';
2+
import type { Card, CardContent } from '@box/shared/api';
33
import {
44
StoreValue,
55
combine,
@@ -24,6 +24,7 @@ export const lastTagRemoved = createEvent();
2424
// FIXME: remove after converting to page-unique fabric
2525

2626
export const _formInit = createEvent();
27+
export const setInitialState = createEvent<Card>();
2728
export const formReset = createEvent<string>();
2829

2930
const draft = createDomain();
@@ -58,7 +59,7 @@ export type Draft = StoreValue<typeof $draft>;
5859

5960
// Init
6061
spread({
61-
source: internalApi.cardsGet.doneData.map(({ answer }) => answer.card),
62+
source: setInitialState,
6263
targets: {
6364
id: $id,
6465
title: $title,

src/pages/card/edit/model.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
11
import * as sessionModel from '@box/entities/session';
2+
import { Card, internalApi } from '@box/shared/api';
23
import {
34
attach,
45
createDomain,
56
createEvent,
7+
createStore,
68
guard,
79
merge,
810
sample,
911
} from 'effector';
1012
import { cardDraftModel } from '@box/features/card/draft';
11-
import { cardModel } from '@box/entities/card';
1213
import { createHatch } from 'framework';
1314
import { historyPush } from '@box/entities/navigation';
14-
import { internalApi } from '@box/shared/api';
15-
16-
import { paths } from '../../paths';
15+
import { paths } from '@box/pages/paths';
1716

1817
export const hatch = createHatch(createDomain('CardEditPage'));
18+
const $currentCardId = hatch.$params.map((params) => params.cardId || null);
1919

2020
export const cardsGetFx = attach({ effect: internalApi.cardsGet });
2121
export const cardUpdateFx = attach({ effect: internalApi.cardsEdit });
2222

23+
const $currentCard = createStore<Card | null>(null);
24+
2325
// FIXME: may be should be replace to "$errors" in future
24-
export const $isCardFound = cardModel.$currentCard.map((card) => Boolean(card));
26+
export const $isCardFound = $currentCard.map((card) => Boolean(card));
2527

2628
// Подгружаем данные после монтирования страницы
2729
const shouldLoadCard = sample({
@@ -35,6 +37,8 @@ sample({
3537
target: cardsGetFx,
3638
});
3739

40+
$currentCard.on(cardsGetFx.doneData, (_, { answer }) => answer.card);
41+
3842
const cardCtxLoaded = sample({
3943
clock: cardsGetFx.doneData,
4044
source: sessionModel.$session,
@@ -55,6 +59,20 @@ sample({
5559
target: historyPush,
5660
});
5761

62+
const isAuthorViewing = guard({
63+
source: cardCtxLoaded,
64+
filter: ({ viewer, card }) => {
65+
if (!viewer) return false;
66+
return viewer.id === card.answer.card.authorId;
67+
},
68+
});
69+
70+
sample({
71+
clock: isAuthorViewing,
72+
fn: ({ card }) => card.answer.card,
73+
target: cardDraftModel.setInitialState,
74+
});
75+
5876
// Ивент, который сабмитит форму при отправке ее со страницы редактирования карточки
5977
const formEditSubmitted = createEvent<string>();
6078

@@ -91,7 +109,7 @@ guard({
91109
// Возвращаем на страницу карточки после сохранения/отмены изменений
92110
sample({
93111
clock: merge([cardUpdateFx.done, formEditReset]),
94-
source: cardModel.$currentCardId,
112+
source: $currentCardId,
95113
fn: (cardId) => (cardId ? paths.cardView(cardId) : paths.home()),
96114
target: historyPush,
97115
});

0 commit comments

Comments
 (0)