Skip to content

Commit

Permalink
BOX-94 BOX-237 Add ability to add/remove card to favorites (#84)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
3 people authored Nov 10, 2021
1 parent 9433af9 commit 0f700ad
Show file tree
Hide file tree
Showing 15 changed files with 298 additions and 109 deletions.
2 changes: 1 addition & 1 deletion openapi.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module.exports = {
file: 'https://cardbox.github.io/backend/api-internal/openapi.yaml',
templateFileNameCode: 'index.gen.ts',
outputDir: './src/api/internal',
outputDir: './src/shared/api/internal',
presets: [
[
'effector-openapi-preset',
Expand Down
69 changes: 45 additions & 24 deletions src/entities/card/model/index.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,61 @@
import type { Card } from '@box/shared/api';
import { createEvent, createStore } from 'effector';
import { internalApi } from '@box/shared/api';

export const setCards = createEvent<Card[]>();
import { Card, internalApi } from '@box/shared/api';
import { attach, combine, createEvent, createStore, sample } from 'effector';

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

export const $cards = createStore<Card[]>([]);
export const $currentCard = createStore<Card | null>(null);
// TODO: remove current card id and current card store from entities
export const $currentCardId = $currentCard.map((card) =>
card ? card.id : null,
);
export const cardsSaveFx = attach({ effect: internalApi.cardsSave });
export const cardsUnsaveFx = attach({ effect: internalApi.cardsUnsave });

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

$cards.on(setCards, (_, cards) => cards);
export const changeFavorites = createEvent<string[]>();
changeFavorites.watch((list) => console.info('————', list));
export const $favoritesCards = combine(
$favoritesIds,
$cardsCache,
(ids, { cache }) => ids.map((id) => cache[id] ?? null),
);

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

$cardsCache
.on(internalApi.cardsList.doneData, (cache, { answer }) =>
updateCache(cache, answer.cards as Card[]),
)
.on(internalApi.cardsGet.doneData, (cache, { answer }) =>
updateCache(cache, [answer.card as Card]),
)
.on(internalApi.cardsCreate.doneData, (cache, { answer }) =>
updateCache(cache, [answer.card as Card]),
)
.on(internalApi.cardsEdit.doneData, (cache, { answer }) =>
updateCache(cache, [answer.card as Card]),
.on(
[
internalApi.cardsGet.doneData,
internalApi.cardsCreate.doneData,
internalApi.cardsEdit.doneData,
internalApi.cardsSave.doneData,
internalApi.cardsUnsave.doneData,
],
(cache, { answer }) => updateCache(cache, [answer.card as Card]),
);

sample({
clock: favoritesAdd,
fn: (cardId) => ({ body: { cardId } }),
target: cardsSaveFx,
});

sample({
clock: favoritesRemove,
fn: (cardId) => ({
body: { cardId },
}),
target: cardsUnsaveFx,
});

$favoritesIds
.on(changeFavorites, (_, ids) => ids)
.on(cardsSaveFx.doneData, (ids, { answer }) => [...ids, answer.card.id])
.on(cardsUnsaveFx.doneData, (ids, { answer }) =>
ids.filter((s) => s !== answer.card.id),
);

function updateCache<T extends { id: string }>(
Expand Down
9 changes: 1 addition & 8 deletions src/entities/card/organisms/card-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,7 @@ export const CardList = ({ cards, loading }: Props) => {
return (
<Container>
{cards.map((card, i) => (
<CardPreview
key={card.id}
card={card}
// FIXME: temp hack, will be optimized later
isCardInFavorite={i % 2 === 0}
href={paths.cardView(card.id)}
size="small"
/>
<CardPreview key={card.id} card={card} size="small" />
))}
</Container>
);
Expand Down
88 changes: 56 additions & 32 deletions src/entities/card/organisms/card-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'dayjs/plugin/relativeTime';

import dayjs from 'dayjs';
import styled from 'styled-components';
import React, { forwardRef } from 'react';
import React, { forwardRef, useCallback } from 'react';
import {
Button,
IconDeckArrow,
Expand All @@ -11,23 +11,23 @@ import {
Skeleton,
Text,
} from '@box/shared/ui';
import type { Card, User } from '@box/shared/api';
import type { Card } from '@box/shared/api';
import { Editor, useExtendedEditor } from '@cardbox/editor';
import type { EditorValue } from '@cardbox/editor';
import { HighlightText } from '@box/entities/search';
import { Link } from 'react-router-dom';
import { breakpoints } from '@box/shared/lib/breakpoints';
import { cardModel } from '@box/entities/card';
import { navigationModel } from '@box/entities/navigation';
import { paths } from '@box/pages/paths';
import { theme } from '@box/shared/lib/theme';
import { useEvent } from 'effector-react';
import { useEvent, useStoreMap } from 'effector-react/scope';
import { useMouseSelection } from '@box/shared/lib/use-mouse-selection';

type CardSize = 'small' | 'large';

interface CardPreviewProps {
card: Card;
isCardInFavorite?: boolean;
href?: string;
loading?: boolean;
/**
* @remark May be in future - make sense to split independent components - CardItem, CardDetails
Expand All @@ -38,13 +38,25 @@ interface CardPreviewProps {

export const CardPreview = ({
card,
isCardInFavorite = false,
href,
loading = false,
size = 'small',
}: CardPreviewProps) => {
const href = paths.cardView(card.id);
const isCardInFavorites = useStoreMap({
store: cardModel.$favoritesCards,
keys: [card.id],
fn: (list, [id]) => list.some((card) => card.id === id),
});

const addToFavorites = useEvent(cardModel.favoritesAdd);
const removeFromFavorites = useEvent(cardModel.favoritesRemove);
const historyPush = useEvent(navigationModel.historyPush);

const toggleFavorites = useCallback(() => {
if (isCardInFavorites) removeFromFavorites(card.id);
else addToFavorites(card.id);
}, [addToFavorites, removeFromFavorites, card.id, isCardInFavorites]);

const { handleMouseDown, handleMouseUp, buttonRef } = useMouseSelection(
(inNewTab = false) => {
if (!href) return;
Expand All @@ -66,13 +78,18 @@ export const CardPreview = ({
aria-label="Open card"
>
<ContentBlock>
<Content card={card} href={href} size={size} />
<Content card={card} size={size} />

<OverHelm />
</ContentBlock>

{size === 'small' && <Meta card={card} />}
<AddButton ref={buttonRef} isCardToDeckAdded={isCardInFavorite} />
<SaveCardButton
ref={buttonRef}
onClick={toggleFavorites}
isCardInFavorites={isCardInFavorites}
card={card}
/>
</PaperContainerStyled>
);
};
Expand Down Expand Up @@ -120,9 +137,10 @@ const PaperContainerStyled = styled(PaperContainer)<{
}
`;

type ContentProps = { card: Card } & Pick<CardPreviewProps, 'href' | 'size'>;
type ContentProps = { card: Card } & Pick<CardPreviewProps, 'size'>;

const Content = ({ card, size, href }: ContentProps) => {
const Content = ({ card, size }: ContentProps) => {
const href = paths.cardView(card.id);
const editor = useExtendedEditor();
return (
<ContentStyled>
Expand Down Expand Up @@ -200,35 +218,41 @@ const ContentStyled = styled.div`
overflow: hidden;
`;

const AddButton = forwardRef<HTMLButtonElement, { isCardToDeckAdded: boolean }>(
({ isCardToDeckAdded }, ref) => {
const handleClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
};

if (isCardToDeckAdded) {
return (
<CardButton
ref={ref}
onClick={handleClick}
variant="outlined"
theme="primary"
icon={<IconDeckCheck title="Remove card from my deck" />}
/>
);
}
const SaveCardButton = forwardRef<
HTMLButtonElement,
{
isCardInFavorites: boolean;
card: Card;
onClick: (id: string) => void;
}
>(({ isCardInFavorites, card, onClick }, ref) => {
const handleClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
onClick(card.id);
};

if (isCardInFavorites) {
return (
<CardButton
ref={ref}
onClick={handleClick}
variant="outlined"
theme="secondary"
icon={<IconDeckArrow title="Add card to my deck" />}
theme="primary"
icon={<IconDeckCheck title="Remove card from my deck" />}
/>
);
},
);
}

return (
<CardButton
ref={ref}
onClick={handleClick}
variant="outlined"
theme="secondary"
icon={<IconDeckArrow title="Add card to my deck" />}
/>
);
});

const ContentBlock = styled.div`
display: flex;
Expand Down
5 changes: 3 additions & 2 deletions src/features/card/draft/model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as editorLib from '@box/shared/lib/editor';
import type { CardContent } from '@box/shared/api';
import type { Card, CardContent } from '@box/shared/api';
import {
StoreValue,
combine,
Expand All @@ -24,6 +24,7 @@ export const lastTagRemoved = createEvent();
// FIXME: remove after converting to page-unique fabric

export const _formInit = createEvent();
export const setInitialState = createEvent<Card>();
export const formReset = createEvent<string>();

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

// Init
spread({
source: internalApi.cardsGet.doneData.map(({ answer }) => answer.card),
source: setInitialState,
targets: {
id: $id,
title: $title,
Expand Down
30 changes: 24 additions & 6 deletions src/pages/card/edit/model.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
import * as sessionModel from '@box/entities/session';
import { Card, internalApi } from '@box/shared/api';
import {
attach,
createDomain,
createEvent,
createStore,
guard,
merge,
sample,
} from 'effector';
import { cardDraftModel } from '@box/features/card/draft';
import { cardModel } from '@box/entities/card';
import { createHatch } from 'framework';
import { historyPush } from '@box/entities/navigation';
import { internalApi } from '@box/shared/api';

import { paths } from '../../paths';
import { paths } from '@box/pages/paths';

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

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

const $currentCard = createStore<Card | null>(null);

// FIXME: may be should be replace to "$errors" in future
export const $isCardFound = cardModel.$currentCard.map((card) => Boolean(card));
export const $isCardFound = $currentCard.map((card) => Boolean(card));

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

$currentCard.on(cardsGetFx.doneData, (_, { answer }) => answer.card);

const cardCtxLoaded = sample({
clock: cardsGetFx.doneData,
source: sessionModel.$session,
Expand All @@ -55,6 +59,20 @@ sample({
target: historyPush,
});

const isAuthorViewing = guard({
source: cardCtxLoaded,
filter: ({ viewer, card }) => {
if (!viewer) return false;
return viewer.id === card.answer.card.authorId;
},
});

sample({
clock: isAuthorViewing,
fn: ({ card }) => card.answer.card,
target: cardDraftModel.setInitialState,
});

// Ивент, который сабмитит форму при отправке ее со страницы редактирования карточки
const formEditSubmitted = createEvent<string>();

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

0 comments on commit 0f700ad

Please sign in to comment.