diff --git a/.cspell-ignore b/.cspell-ignore index bb17f60..dc44ae2 100644 --- a/.cspell-ignore +++ b/.cspell-ignore @@ -405,3 +405,5 @@ Layout’ов стабов фейковых happydom +autorun +MVVM diff --git a/docs/arch/images/logic-deps.png b/docs/arch/images/logic-deps.png new file mode 100644 index 0000000..76b909f Binary files /dev/null and b/docs/arch/images/logic-deps.png differ diff --git a/docs/arch/images/props-deps.png b/docs/arch/images/props-deps.png new file mode 100644 index 0000000..cd1abe5 Binary files /dev/null and b/docs/arch/images/props-deps.png differ diff --git a/docs/arch/modules/domain.md b/docs/arch/modules/domain.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/arch/modules/features/UILogic.md b/docs/arch/modules/features/UILogic.md new file mode 100644 index 0000000..aa85968 --- /dev/null +++ b/docs/arch/modules/features/UILogic.md @@ -0,0 +1,320 @@ +--- +sidebar_position: 1 +--- + +# Отделение логики от view + +UI компонент должен быть ответственным **только за отображение,** количество ui логики в компоненте должно быть сведено к нулю. + +Вся логика реализуется вне компонента в [`UIStore`](./UIStore) или [`useLogic`](./useLogic). + +![UILogic](../../images/ui-logic.png) + +## Мотивация + +Отделение view слоя от логики дает следующие преимущества: + +- Возможность изменять логику и ui независимо +- Простота переиспользования логики или ui по необходимости +- Независимость от используемого фреймворка. Фреймворк при определенных обстоятельствах можно заменить, а логику переиспользовать +- Простота тестирования. Возможность тестировать отдельно логику и ui +- Однозначность расположения логики. Вся логика всегда находится в одном месте +- Логика не “размазывается” по компонентам. Избавляет от сложностей в поддержке кода +- Повышение читаемости кода +- Упрощение поддержки приложения + +## UI компонент + +Компонент должен содержать только то, что непосредственно связано с фреймворком, ответственным за отображение. + +### ❌ Компонент не должен содержать + +Все нижеперечисленные функции должны находиться в [`UIStore`](./UIStore) или [`useLogic`](./useLogic). + +#### Логика форматирования данных для отображения + +##### Форматирование дат для отображения + +###### ❌ Invalid +```tsx +export const Card = ({ date }: Props) => { + return ( + + {date.toLocaleDateString()} + + ); +}; +``` + +###### ✅ Valid +```tsx +export const Card = (props: Props) => { + const [{ issueDate }] = useState(() => createUIStore(props)); + + return ( + + {issueDate} + + ); +}; +``` + +--- + +##### Склеивание строк для отображения + +###### ❌ Invalid +```tsx +export const Card = ({ name, surname }: Props) => { + return ( + + {`${name} ${surname}`} + + ); +}; +``` + +###### ✅ Valid +```tsx +export const Card = (props: Props) => { + const [{ fullName }] = useState(() => createUIStore(props)); + + return ( + + {fullName} + + ); +}; +``` + +--- + +#### Формирование массивов или объектов + +##### ❌ Invalid +```tsx +export const List = ({ list }: Props) => { + const data = useMemo( + () => list.map(({ name, surname }) => `${name} ${surname}`), + [list], + ); + + return ( + + {data.map((fullName) => ( +
  • + {fullName} +
  • + ))} +
    + ); +}; +``` + +###### ✅ Valid +```tsx +export const List = ({ list }: Props) => { + const [{ data, updateList }] = useState(() => createUIStore(list)); + + useEffect(() => { + updateList(list); + }, [list]); + + return ( + + {data.map((fullName) => ( +
  • + {fullName} +
  • + ))} +
    + ); +}; +``` + +--- + +#### Форматирование props для компонентов + +##### ❌ Invalid +```tsx +export const Card = () => { + const [{ name, list }] = useState(createUIStore); + + return ( + + description)} + /> + + ); +}; +``` + +##### ✅ Valid +```tsx +export const Card = () => { + const [{ viewerTitle, descriptions }] = useState(createUIStore); + + return ( + + + + ); +}; +``` + +#### Расчет флагов, отвечающих за отображение частей ui + +##### ❌ Invalid +```tsx +export const Card = ({ name, isOwner }: Props) => { + const isShowTitle = Boolean(name) && isOwner; + + return {isShowTitle && Заголовок}; +}; +``` + +##### ✅ Valid +```tsx +export const Card = ({ name, isOwner }: Props) => { + const [{ isShowTitle }] = useState(() => createUIStore({ name, isOwner })); + + return {isShowTitle && Заголовок}; +}; + +``` + +### ✅ В компоненте может находится + +ℹ️ Все нижеперечисленные ниже функции могут находиться как в компоненте, так и в `useLogic`, если `useLogic` определен. + +#### Получение ref и передача HTMLElement в логику для последующей обработки + +Пример: +```tsx +export const Card = () => { + const [{ setContainer }] = useState(createUIStore); + + const containerRef = useRef(); + + useEffect(() => { + setContainer(containerRef.current); + }, []); + + return ( + + ... + + ); +}; +``` + +#### Подвязка логики к методам жизненного цикла компонента + +Пример: +```tsx +export const Card = () => { + const [{ mount, unmount }] = useState(createUIStore); + + useEffect(() => { + mount(); + + return unmount; + }, []); + + return ...; +}; +``` + +#### Определение обработчиков событий + +Пример: +```tsx +export const Input = () => { + const [{ value, changeValue }] = useState(createUIStore); + + const handleChange = (event: ChangeEvent) => { + changeValue(event.target.value); + }; + + return ; +}; +``` + +Примечание: SyntheticEvent являются частью React, поэтому проникновение events в слой логики нежелательно. + +#### Определение render функций + +Пример: +```tsx +export const Status = () => { + const [{ statusType, info }] = useState(createUIStore); + + const renderStatus = () => { + switch (statusType) { + case StatusType.Success: + return ; + case StatusType.Error: + return ; + default: + return ; + } + }; + + return {renderStatus()}; +}; +``` + +**UI компонент потребляет логику фичи и полностью зависит от ее интерфейсов.** + +## Логика + +В рамках `features` бизнес-логика и ui логика не разделяется. + +Логика в `feature` содержит: + +- Формирование данных для отображения в ui +- Работу с данными, взаимодействие с `Data` слоем +- Работу с флагами, которые в компоненте будут ответственны за отображение компонента (например, удаление из DOM, изменение цвета и т.п.) + +**Логика фичи не должна зависеть от ui компонента**. **Зависимости направлены от ui к логике:** + +![UILogic](../../images/ui-logic.png) + +Логика может быть реализована на любом предпочтительном стэке с использованием: + +- state manager +- hook (React стэк) +- service +- utils + +### Мотивация объединения бизнес и ui логики в фиче + +- Если оставлять ui логику в компоненте, то велика вероятность просачивания бизнес логики в компонент +- В реальном проекте зачастую достаточно сложно решить что относится к бизнес логике, а что относится к ui. Возникают ситуации, когда разработчики замедляются в реализации фичи из-за дилеммы: куда поместить эту логику? В компонент или в store? + +### Реализация UI логики + +Для реализации логики рекомендуется использовать state manager в [UIStore](./UIStore). + +State manager позволит: + +- Не завязываться на специфику ui фреймворка +- Избежать нежелательных зависимостей от ui. Технически невозможно в state manager поместить специфику ui фреймворка +- Писать простые тесты для логики + +Использование react hooks допустимо для реализации логики в [useLogic](./useLogic), но желательно избегать данного сценария. +Так как hooks - это часть react, то в них доступны все методы по работе с ui, это значит в логику проникнет специфика фреймворка. + +Из этого могут возникнуть проблемы: + +- Невозможность переиспользования логики в другом стэке +- Смешивание ui и логики. Без контроля в hooks будет попадать логика работы с ref, react событиями и т.п. +- Невозможность переиспользования логики из-за косвенной зависимости от ui +- Сложность работы с глобальными данными +- Сложность тестирования. Для тестирования hooks необходимы дополнительные инструменты (react-testing-library, jsdom | happydom) diff --git a/docs/arch/modules/features/UILogic/_category_.json b/docs/arch/modules/features/UILogic/_category_.json deleted file mode 100644 index a2863bb..0000000 --- a/docs/arch/modules/features/UILogic/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "UILogic", - "position": 1, - "link": { - "type": "generated-index" - } -} diff --git a/docs/arch/modules/features/UILogic/overview.md b/docs/arch/modules/features/UILogic/overview.md deleted file mode 100644 index ca5bc3a..0000000 --- a/docs/arch/modules/features/UILogic/overview.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -sidebar_position: 0 ---- - -# Отделение логики от view - -UI компонент должен быть ответственным **только за отображение,** количество ui логики в компоненте должно быть сведено к нулю. - -Вся логика реализуется вне компонента в [`UIStore`](../UIStore/overview) или [`useLogic`](../useLogic/overview). - -![UILogic](../../../images/ui-logic.png) - -## Мотивация - -Отделение view слоя от логики дает следующие преимущества: - -- Возможность изменять логику и ui независимо -- Простота переиспользования логики или ui по необходимости -- Независимость от используемого фреймворка. Фреймворк при определенных обстоятельствах можно заменить, а логику переиспользовать -- Простота тестирования. Можно тестировать отдельно логику и ui -- Однозначность расположения логики. Вся логика всегда находится в одном месте -- Логика не “размазывается” по компонентам. Избавляет от сложностей в поддержке кода -- Повышение читаемости кода -- Упрощение поддержки и доработки приложения - -## UI компонент - -Компонент должен содержать только то, что непосредственно связано с фреймворком, ответственным за отображение. - -В ui компоненте **не должно** находится: - -- Логики форматирования данных для отображения -- Логики работы с данными -- Флагов, отвечающих за отображение частей ui - -В ui компоненте **должно находится**: - -- Работа с браузерным API -- Работа со спецификой фреймворка - - Обработка пользовательского ввода с целью передачи данных в логику - - Работа с DOM -- Потребление данных из логики для их отображения -- Коннект методов логики с обработкой пользовательского ввода и браузерных событий - -**UI компонент потребляет логику фичи и полностью зависит от ее интерфейсов.** - -## Логика - -В рамках `features` бизнес-логика и ui логика не разделяется. - -Логика в `feature` содержит: - -- Формирование данных для отображения в ui -- Работу с данными, взаимодействие с `Data` слоем -- Работу с флагами, которые в компоненте будут ответственны за отображение компонента (например, удаление из DOM, изменение цвета и т.п.) - -**Логика фичи не должна зависеть от ui компонента**. **Зависимости направлены от ui к логике:** - -![UILogic](../../../images/ui-logic.png) - -Логика может быть реализована на любом предпочтительном стэке с использованием: - -- state manager -- hook (React стэк) -- service -- utils - -### Мотивация объединения бизнес и ui логики в фиче - -- Если оставлять ui логику в компоненте, то велика вероятность просачивания бизнес логики в компонент -- В реальном проекте зачастую достаточно сложно решить что относится к бизнес логике, а что относится к ui. Возникают ситуации, когда разработчики замедляются в реализации фичи из-за дилеммы: куда поместить эту логику? В компонент или в store? - -### State manager для реализации логики - -Для реализации логики рекомендуется использовать state manager. - -State manager позволит: - -- Не завязываться на специфику ui фреймворка -- Избежать нежелательных зависимостей от ui. Технически невозможно в state manager поместить специфику ui фреймворка -- Писать простые тесты для логики -- Простота распространения данных в приложении - -### Использование react hooks для реализации логики - -Желательно избегать использования react hooks для реализации логики. Так как hooks - это часть react, то в них доступны все методы по работе с ui, это значит в логику проникнет специфика фреймворка. - -Из этого могут возникнуть проблемы: - -- Невозможность переиспользования логики в другом стэке -- Смешивание ui и логики. Без контроля в hooks будет попадать логика работы с ref, react событиями и т.п. -- Невозможность переиспользования логики из-за косвенной зависимости от ui -- Сложность работы с глобальными данными -- Сложность тестирования. Для тестирования hooks необходимы дополнительные инструменты (react-testing-library, jsdom | happydom) - -### Переиспользование логики между фичами - -Логику необходимо выносить в `Domain` , если логику, реализованную внутри фичи, потребовалось: - -- Переиспользовать в другой фиче -- Переиспользовать в другом модуле -- Использовать для интеграции с другой фичей - -[Подробный обзор Domain](../domain) - -### Использование DI для контроля зависимостей - -Логика должна использовать базовую концепцию DI (dep. injection) для того, чтобы контролировать свои зависимости. - -Плюсы подхода: - -- Логику проще поддерживать за счет того, что нет скрытых зависимостей. Все зависимости сразу видны и очевидны -- Логику проще тестировать. Зависимости можно просто подменять на тестовые сущности - -**Пример** - -```tsx -import { makeAutoObservable } from 'mobx'; -import { CartStore } from '@astral/modules/cart'; - -export class CatalogStore { - constructor(private readonly cartStore: CartStore) { - makeAutoObservable(this, {}, { autoBind: true }); - } - - addToCart = (productID: string) => { - this.cartStore.add(productID); - }; -} -``` - -## Демонстрация профита отделения ui и логики - -Представим, что у нас есть блок, в котором происходит оплата услуги по карте. - -Данный блок является фичей `CardPayment` в модуле `Payment`. - -Сейчас фича производит оплату через конкретную платежную систему после успешной оплаты отправляем нашему API данные. - -Через некоторое время появилась необходимость реализовать новую фичу, ui которой должен повторять `CardPayment`, но при этом должна использоваться другая платежная система и после оплаты данные отправляются на другое API. - -Так как мы сразу отделили логику и ui компонент, то мы можем без особых проблем вынести исходную логику `CardPayment` в `Domain` и использовать для `CardPayment` один и тот же ui, но разную логику. - diff --git a/docs/arch/modules/features/UIStore.md b/docs/arch/modules/features/UIStore.md new file mode 100644 index 0000000..1fc854b --- /dev/null +++ b/docs/arch/modules/features/UIStore.md @@ -0,0 +1,420 @@ +--- +sidebar_position: 2 +--- + +# UIStore + +`UIStore` - это логика фичи, реализованная с помощью state manager. + +`UIStore` можно рассматривать как **View-Model** из паттерна **MVVM** или [Supervising Controller](https://www.martinfowler.com/eaaDev/SupervisingPresenter.html). + +Рекомендуется отдавать предпочтение реализации логики через `UIStore` перед `useLogic`. Причины: +- Возможность упрощения реактивной логики за счет использования state manager +- Более простые тесты для логики +- Меньшая связь со спецификой ui фреймворка + +## Структура + +``` +├── app/ +├── screens/ +├── modules/ +| └── payment/ +| | ├── features/ +| | | ├── PaymentSwitch/ +| | | | ├── PaymentSwitch.tsx +| | | | ├── UIStore/ +| | | | | ├── UIStore.ts +| | | | | └── index.ts +| | | | └── index.ts +| | | ├── CashPayment/ +| | | └── index.ts +| | ├── domain/ +| | └── index.ts +├── data/ +└── shared/ +``` + +## Style guide + +[Style guide | UIStore](https://kaluga-astral.github.io/style-guide/docs/rules/arch/modules/features/UIStore) + +## Работа с data слоем + +`UIStore` взаимодействует с `data` слоем для: +- Получения данных +- Форматирования данных для отображения в компоненте +- Формирования флагов состояния загрузки данных для отображения в компоненте + +## Формирование данных для отображения + +### Форматирование дат для отображения + +```tsx +export class UIStore { + constructor(private readonly params: { issueDate: Date }) { + makeAutoObservable(this); + } + + public get issueDate() { + return this.params.issueDate.toLocaleDateString(); + } +} +``` + +```tsx +export const Card = (props: Props) => { + const [{ issueDate }] = useState(() => createUIStore(props)); + + return ( + + {issueDate} + + ); +}; +``` + +--- + +### Склеивание строк для отображения + +```ts +export class UIStore { + constructor(private readonly params: { name: string; surname: string }) { + makeAutoObservable(this); + } + + public get fullName() { + return `${this.params.name} ${this.params.surname}`; + } +} +``` + +```tsx +export const Card = (props: Props) => { + const [{ fullName }] = useState(() => createUIStore(props)); + + return ( + + {fullName} + + ); +}; +``` + +--- + +### Формирование массивов или объектов + +```ts +export class UIStore { + constructor( + private readonly params: { list: Array<{ name: string; surname: string }> }, + ) { + makeAutoObservable(this); + } + + public get data() { + return this.params.list.map(({ name, surname }) => `${name} ${surname}`); + } +} +``` + +```tsx +export const List = (props: Props) => { + const [{ data }] = useState(() => createUIStore(props)); + + return ( + + {data.map((fullName) => ( +
  • + {fullName} +
  • + ))} +
    + ); +}; +``` + +--- + +### Расчет флагов, отвечающих за отображение частей ui + +```ts +export class UIStore { + constructor(private readonly params: { name?: string; isOwner: boolean }) { + makeAutoObservable(this); + } + + public get isShowTitle() { + return Boolean(this.params.name) && this.params.isOwner; + } +} +``` + +```tsx +export const Card = ({ name, isOwner }: Props) => { + const [{ isShowTitle }] = useState(() => createUIStore({ name, isOwner })); + + return {isShowTitle && Заголовок}; +}; +``` + +--- + +### Форматирование props для компонентов + +```ts +export class UIStore { + constructor(private readonly userStore: UserStore) { + makeAutoObservable(this); + } + + public get viewerTitle() { + const { name } = this.userStore; + + return `Подробная информация о ${name}`; + } + + public get descriptions() { + return this.userStore.descriptions.map(({ text }) => text); + } +} +``` + +```tsx +export const Card = () => { + const [{ viewerTitle, descriptions }] = useState(createUIStore); + + return ( + + + + ); +}; +``` + +## UIStore не должен зависеть от props компонента текущей фичи + +Если `UIStore` будет зависеть от props компонента текущей фичи, то возникнут циклические зависимости. + +Типы `UIStore` могут зависеть от: +- Props компонентов других фичей +- Props shared компонентов +- Domain любых модулей +- Data слоя + +![PropsDeps](../../images/props-deps.png) + +[Про формирование props для компонента фичи](./props). + +## Отслеживание изменений props компонента + +Зачастую в `UIStore` необходимо отслеживать изменения props текущей фичи. +Для этого необходимо в компоненте через `useEffect` точечно подписываться на изменение конкретных props и передавать их в `UIStore`: + +```tsx +const FullName = ({ name, surname }: Props) => { + const [{ fullName, updateUserInfo }] = useState(() => + createUIStore({ name, surname }), + ); + + useEffect(() => { + updateUserInfo({ name, surname }); + }, [name, surname]); + + return {fullName}; +}; +``` + +## Render компонентов в store + +[Modules Guides | Render компонентов в store](../../guides/renderComponentInStore). + +## Проброс ссылок на ref + +В `UIStore` допустимо пробрасывать `ref` для передачи ссылок в компоненты или сервисы: + +```ts +import type { Ref } from 'react'; + +export class UIStore { + private containerRef?: Ref; + + constructor(private readonly scroller: Scroller) { + makeAutoObservable(this); + } + + public setContainerRef = (ref: Ref) => { + this.scroller.setScrollContainer(ref); + }; +} +``` + +## Подвязка на mount и unmount компонента + +```ts +import { autorun, makeAutoObservable } from 'mobx'; +import type { Ref } from 'react'; + +export class UIStore { + private unobserveSearch: () => void = () => {}; + + public search: string = ''; + + constructor( + private readonly listStore: ListStore, + private readonly scroller: Scroller, + ) { + makeAutoObservable(this); + } + + private observeSearch = () => + autorun(() => { + this.listStore.changeParams({ search: this.search }); + }); + + public setSearch = (search: string) => { + this.search = search; + }; + + public get list() { + return this.listStore.data; + } + + public mount = (containerRef: Ref) => { + this.scroller.setScrollContainer(containerRef); + this.unobserveSearch = this.observeSearch(); + }; + + public unmount = () => { + this.unobserveSearch(); + }; +} +``` + +```tsx +const List = () => { + const containerRef = useRef(); + + const [{ mount, unmount }] = useState(createUIStore); + + useEffect(() => { + mount(containerRef); + + return unmount; + }, []); + + ... +}; +``` + +### Методы, связанные с mount и unmount компонента должны называться соответственно + +##### ✨ Мотивация + +Однозначная связь жизненного цикла компонента и названий методов `UIStore`. + +##### ✅ Valid + +```tsx +const List = () => { + const containerRef = useRef(); + + const [{ mount, unmount }] = useState(createUIStore); + + useEffect(() => { + mount(containerRef); + + return unmount; + }, []); + + ... +}; +``` + +##### ❌ Invalid + +```tsx +const List = () => { + const containerRef = useRef(); + + const [{ init, destroy }] = useState(createUIStore); + + useEffect(() => { + init(containerRef); + + return destroy; + }, []); + +... +}; +``` + +## Работа с Browser API через абстракцию + +Работа с Browser API необходимо проводить через абстракцию. + +##### ✨ Мотивация + +Позволяет писать упрощенные тесты за счет использования тестовых зависимостей вместо реальных. + +Примеры: +```ts +export class UIStore { + constructor( + private readonly storage: LocalStorageService, + ) { + makeAutoObservable(this); + } + + public setSearch = (search: string) => { + this.storage.setItem('search', search) + } +} +``` + +```ts +export class UIStore { + constructor( + private readonly intersectionObserver: IntersectionObserver, + ) { + makeAutoObservable(this); + } + + ... + + public mount = (itemRef: Ref) => { + this.intersectionObserver(this.showAction, { root: itemRef.current }) + } +} +``` + +## Все входные зависимости UIStore должны быть инвертированы через DI + +UIStore должен использовать базовую концепцию DI (dep. injection) для того, чтобы контролировать свои зависимости. + +Плюсы подхода: + +- Логику проще поддерживать за счет того, что нет скрытых зависимостей. Все зависимости сразу видны и очевидны +- Логику проще тестировать. Зависимости можно просто подменять на тестовые сущности + +**Пример** + +```tsx +import { makeAutoObservable } from 'mobx'; +import { CartStore } from '@astral/modules/cart'; + +export class CatalogStore { + constructor(private readonly cartStore: CartStore) { + makeAutoObservable(this, {}, { autoBind: true }); + } + + addToCart = (productID: string) => { + this.cartStore.add(productID); + }; +} +``` diff --git a/docs/arch/modules/features/UIStore/_category_.json b/docs/arch/modules/features/UIStore/_category_.json deleted file mode 100644 index 9291981..0000000 --- a/docs/arch/modules/features/UIStore/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "UIStore", - "position": 1, - "link": { - "type": "generated-index" - } -} diff --git a/docs/arch/modules/features/UIStore/overview.md b/docs/arch/modules/features/UIStore/overview.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/arch/modules/features/UIStore/renderComponentInStore.md b/docs/arch/modules/features/UIStore/renderComponentInStore.md deleted file mode 100644 index 117857b..0000000 --- a/docs/arch/modules/features/UIStore/renderComponentInStore.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Render компонентов в store diff --git a/docs/arch/modules/features/dependencies.md b/docs/arch/modules/features/dependencies.md index 6cb764f..9feb946 100644 --- a/docs/arch/modules/features/dependencies.md +++ b/docs/arch/modules/features/dependencies.md @@ -1,5 +1,5 @@ --- -sidebar_position: 2 +sidebar_position: 5 --- # Зависимости фичей diff --git a/docs/arch/modules/features/domain.md b/docs/arch/modules/features/domain.md index e69de29..e5a58a3 100644 --- a/docs/arch/modules/features/domain.md +++ b/docs/arch/modules/features/domain.md @@ -0,0 +1,9 @@ +--- +sidebar_position: 7 +--- + +# Domain + +## Render компонента в store + +[Modules Guides | Render компонентов в store](../guides/renderComponentInStore). diff --git a/docs/arch/modules/features/props.md b/docs/arch/modules/features/props.md new file mode 100644 index 0000000..e24bdb6 --- /dev/null +++ b/docs/arch/modules/features/props.md @@ -0,0 +1,23 @@ +--- +sidebar_position: 6 +--- + +# Формирование props компонента фичи + +Props фичи может формироваться из: +- Типов `UIStore` +- Типов DTO из `data` +- Props других фичей +- Props shared компонентов + +```ts +type Props = { + data: UIStore['data']; + list: RequestsRepositoryDTO.List; + onClick: ButtonProps['onClick']; +}; +``` + +![PropsDeps](../../images/props-deps.png) + +**`UIStore` при этом не зависит от props компонента своей фичи**. diff --git a/docs/arch/modules/features/shared-logic.md b/docs/arch/modules/features/shared-logic.md new file mode 100644 index 0000000..9787111 --- /dev/null +++ b/docs/arch/modules/features/shared-logic.md @@ -0,0 +1,13 @@ +--- +sidebar_position: 4 +--- + +# Переиспользование логики между фичами + +Логику необходимо выносить в `Domain` , если логику, реализованную внутри фичи, потребовалось: + +- Переиспользовать в другой фиче +- Переиспользовать в другом модуле +- Использовать для интеграции с другой фичей + +[Подробный обзор Domain](./domain) diff --git a/docs/arch/modules/features/style-guide.md b/docs/arch/modules/features/style-guide.md index 6db9939..09e50eb 100644 --- a/docs/arch/modules/features/style-guide.md +++ b/docs/arch/modules/features/style-guide.md @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 8 --- # Style Guide diff --git a/docs/arch/modules/features/testing.md b/docs/arch/modules/features/testing.md index 0952fd5..57904dc 100644 --- a/docs/arch/modules/features/testing.md +++ b/docs/arch/modules/features/testing.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 9 --- # Тестирование diff --git a/docs/arch/modules/features/useLogic.md b/docs/arch/modules/features/useLogic.md new file mode 100644 index 0000000..7195807 --- /dev/null +++ b/docs/arch/modules/features/useLogic.md @@ -0,0 +1,230 @@ +--- +sidebar_position: 3 +--- + +# useLogic + +`useLogic` предназначен для реализации логики фичи, сильно зацепленной на используемую react-библиотеку и фичи самого react. +Если логику можно реализовать без использования хука, то стоит отдать предпочтение [UIStore](./UIStore). + +``` +├── PaymentForm/ +| ├── UIStore/ +| ├── useLogic/ +| | |── utils/ +| | |── hooks/ +| | |── useLogic.ts +| | |── useLogic.test.ts +| | |── constants.ts +| | |── enums.ts +| | |── types.ts +| | └── index.ts +| ├── PaymentForm.tsx +| └── index.ts +``` + +## useLogic - единственная точка входа данных для компонента + +Если в фиче есть `useLogic`, то только из него должны потребляться данные для компонента. +Даже если в фиче используется UIStore или другие stores: + +```tsx +const Card = (props: Props) => { + const [uiStore] = useState(() => createUIStore(props)); + + const { fullName, isShowDescription, description } = useLogic(uiStore); + + return ( + + {fullName} + {isShowDescription && {description}} + + ); +}; +``` + +```ts +export const useLogic = (store: UIStore) => { + const isShowDescription = useEndScroll(); + + return { + isShowDescription, + fullName: store.fullName, + description: store.fullName, + }; +}; +``` + +Hook должен возвращать абстрактный интерфейс, с которым работает компонент. + +Благодаря этой абстракции появляется возможность изменить инструмент реализации логики, при этом оставить выходной интерфейс и ui неизменными. + +## Взаимодействие с UIStore + +`useLogic` должен принимать `UIStore` и другие stores параметром по ссылке для возможности более простого тестирования: +```ts +export const useLogic = (store: UIStore) => { + const isShowDescription = useEndScroll(); + + return { + isShowDescription, + fullName: store.fullName, + description: store.fullName, + }; +}; +``` + +### Инициализация UIStore + +`UIStore` при использовании `useLogic` инициализируется в компоненте и передается в `useLogic`: +```tsx +const Card = (props: Props) => { + const [uiStore] = useState(createUIStore); + + const { fullName, isShowDescription, description } = useLogic(uiStore); + + return ( + + {fullName} + {isShowDescription && {description}} + + ); +}; +``` + +### Зависимости + +`UIStore` не должен зависеть от `useLogic`: + +![LogicDeps](../../images/logic-deps.png) + +Типы должны импортироваться из `UIStore` и `useLogic`. +А в свою очередь компонент для формирования своих props может использовать типы как из `useLogic`, так и из `UIStore`. + +## Разделение зон ответственности между UIStore и useLogic + +### Зона ответственности store + +- Работа с данными. Взаимодействие с слоем `data` +- Форматирование данных для отображения, если эти данные не завязаны на изменение state формы + +## Зона ответственности hooks + +### Описание типов формы + +`useLogic` должен содержать типы формы: + +```tsx +export type BookFormValues = { + name: string; + genre: BookRepositoryDTO.GenreDTO; + pageCount: string; + author: AdministrationRepositoryDTO.CreateBookInputDTO['author']; + coAuthor?: AdministrationRepositoryDTO.CreateBookInputDTO['coAuthor']; + isPresentCoAuthor: boolean; +}; +``` + +### Валидация формы + +`useForm` должен взаимодействовать с валидацией формы: + +```tsx +const validationSchema = v.object({ + name: v.string(), + genre: v.object({ + id: v.string(), + name: v.string(), + description: v.optional(v.string()), + }), + pageCount: v.number(), + author: v.object({ + name: v.string(), + surname: v.string(), + }), + isPresentCoAuthor: v.optional(v.boolean()), + coAuthor: v.when({ + is: (_, ctx) => Boolean(ctx.values?.isPresentCoAuthor), + then: v.object({ + name: v.string(), + surname: v.string(), + }), + otherwise: v.any(), + }), +}); + +export const useLogic = () => { + const form = useForm({ validationSchema }); + + ... + +}; +``` + +### Инициализация формы с необходимыми параметрами + +```tsx +export const useLogic = (): Result => { + const form = useForm({ validationSchema }); + + ... + +}; +``` + +### Подписка на изменение полей и state формы + +```tsx +export const useLogic = ( + store: UIStore, + { onSubmit }: Params, +): Result => { + const form = useForm({ validationSchema }); + + const isPresentCoAuthor = form.watch('isPresentCoAuthor'); + + const name = form.watch('name'); + + useEffect(() => { + store.findBook(name); + }, [name]); + + return { form, isPresentCoAuthor, submit: form.handleSubmit(onSubmit) }; +}; +``` + +### Формирование данных для отображения + +Если данные для отображения завязаны на изменение state формы, то логика форматирования помещается в хук: + +```tsx +export const useLogic = (): Returned => { + const { watch } = useBookFormContext(); + + const { name, author } = watch(); + + return { + name, + authorFullName: `${author.name} ${author.surname}`, + }; +}; +``` + +## При использовании useLogic в компоненте не должно оставаться никакой логики, кроме инициализации + +В `useLogic` переносятся из компонента: +- Подвязка на `mount`, `unmount` +- Создание `ref` +- Обработка событий + +## Style guide + +[Style Guide | useLogic](https://kaluga-astral.github.io/style-guide/docs/rules/arch/modules/features/logic-hook) + +## Тестирование + +Тестировать store и hook необходимо отдельно. + +## Пример + +[Vite-boilerplate](https://github.com/kaluga-astral/vite-boilerplate/tree/main/modules/administration/features/BookForm) diff --git a/docs/arch/modules/features/useLogic/overview.md b/docs/arch/modules/features/useLogic/overview.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/arch/modules/features/useLogic/_category_.json b/docs/arch/modules/guides/_category_.json similarity index 69% rename from docs/arch/modules/features/useLogic/_category_.json rename to docs/arch/modules/guides/_category_.json index fe1b4dd..1cfc37c 100644 --- a/docs/arch/modules/features/useLogic/_category_.json +++ b/docs/arch/modules/guides/_category_.json @@ -1,5 +1,5 @@ { - "label": "useLogic", + "label": "Modules Guides", "position": 2, "link": { "type": "generated-index" diff --git a/docs/arch/modules/guides/renderComponentInStore.md b/docs/arch/modules/guides/renderComponentInStore.md new file mode 100644 index 0000000..4bad059 --- /dev/null +++ b/docs/arch/modules/guides/renderComponentInStore.md @@ -0,0 +1,136 @@ +# Render компонентов в store + +Производить render компонента в store необходимо в случаях: +- Проброс кастомного отображения в сервис +- Формирование props для компонента + +## adaptComponentToDomain + +`adaptComponentToDomain` позволяет вызывать рендер компонента вне `jsx`, а именно в `UIStore`. + +Реализация функции: +```ts +import { type FunctionComponent, type ReactNode, createElement } from 'react'; + +export type RenderComponentInDomain = ( + props: TProps, +) => ReactNode; + +/** + * Позволяет использовать react-компонент в бизнес-логике как render функцию + */ +export const adaptComponentToDomain = + ( + component: FunctionComponent, + ): RenderComponentInDomain => + (props) => + createElement(component, props); +``` + +## Проброс render в service. Пример с notify + +Задача: при вызове notify использовать кастомное отображение сообщения. + +```ts +import { PaymentMessage } from '../../features'; + +export class PaymentStore { + constructor( + private readonly notify: Notify, + private readonly paymentRepo: PaymentRepo, + private readonly renderPaymentMessage: RenderComponentInDomain<{ + productID: string; + }>, + ) { + makeAutoObservable(this); + } + + public pay = async (productID: string) => { + await this.paymentRepo.pay(productID); + + this.notify.success('Оплачено', { + content: this.renderPaymentMessage({ productID }), + }); + }; +} + +export const createPaymentStore = () => + new PaymentStore( + notifyService, + paymentRepository, + adaptComponentToDomain(PaymentMessage), + ); +``` + +## Формирование props для компонента + +```ts +import { DeleteIcon, EditIcon } from '@example/shared'; +import type { ActionCellProps } from '@example/shared'; + +type ActionIcons = { + renderEdit: RenderComponentInDomain; + renderDelete: RenderComponentInDomain; +}; + +export class UIStore { + constructor(private readonly actionIcons: ActionIcons) { + makeAutoObservable(this); + } + + public get actions(): ActionCellProps { + return [ + { + icon: this.actionIcons.renderDelete(), + onClick: () => this.delete(), + }, + { + icon: this.actionIcons.renderEdit(), + onClick: () => this.edit(), + }, + ]; + } +} + +export const createUIStore = () => + new UIStore({ + renderDelete: adaptComponentToDomain(DeleteIcon), + renderEdit: adaptComponentToDomain(EditIcon), + }); +``` + +## Сохранение ссылки на ReactNode для последующего использования + +```ts +import type { ReactNode } from 'react'; + +export class UIStore { + private alertMessage: ReactNode; + + constructor(private readonly notify: Notify) { + makeAutoObservable(this); + } + + public mount = (message: ReactNode) => { + this.alertMessage = message; + }; + + public send = () => { + this.notify(this.alertMessage); + }; +} +``` + +```tsx +const Alert = () => { + const [{ mount }] = useState(createUIStore); + + const message = Hello; + + useEffect(() => { + mount(message); + }, []); + + ... +}; +```