FRONT

Таблицы

Список с фильтрами

Таблицы...

Таблицы с фильтрами

Модалка

Модалка с формой

Табличная верстка
<Table> <Table.Head> <Table.Header width={150}>Номер поставки</Table.Header> <Table.Header>Информация о поставке</Table.Header> <Table.Header>Статус</Table.Header> <Table.Header width={163}>Дата обновления статуса</Table.Header> <Table.Header width={164} /> </Table.Head> <Table.Body> {movements.map(({ Number, Id, OrderDate, FromName, ToName, HardwareCount, State, StatusDate }) => ( <Table.Row key={Id}> <Table.Cell> <Link to={Urls.movement(url, { movementId: Id })}>№ {Number}</Link> <br /> <SecondaryText>от {dayjs(OrderDate).format("L")}</SecondaryText> </Table.Cell> <Table.Cell> {FromName} ➜ {ToName} <br /> <SecondaryText>{HardwareCount} шт.</SecondaryText> </Table.Cell> <Table.Cell> <MovementStatus movementState={State} /> </Table.Cell> <Table.Cell>{dayjs(StatusDate).format("L")}</Table.Cell> <Table.Cell> <Link to={Urls.createIncome(url, { movementId: Id })}>Оформить приход</Link> </Table.Cell> </Table.Row> ))} </Table.Body> </Table>
Табличная верстка
.table { width: 100%; text-align: left; border-collapse: collapse; margin-bottom: 16px; } .header { color: var(--secondaryTextColor); border-bottom: 1px solid var(--pageBorderColor); font-weight: normal; font-size: 12px; line-height: 16px; padding: 12px 8px 11px; } .cell { padding: 12px 8px 11px; vertical-align: baseline; } .row { border-bottom: 1px solid var(--pageBorderColor); }
Гриды

.balances { display: grid; gap: 24px; grid-template-columns: repeat(auto-fill, minmax(183px, 1fr)); }
Jest
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ module.exports = { preset: "ts-jest/presets/js-with-ts", testEnvironment: "jsdom", testResultsProcessor: "jest-teamcity-reporter", //jest гоняется через nodejs => не умеет es import transformIgnorePatterns: ["/node_modules/(?!@skbkontur/ui-helpers)"], setupFiles: ["<rootDir>/App/config/setupTests.ts"] };
Jest - моки
jest.mock("../../../server/SKBKontur/Billy/Billing/WarehouseWeb/Controllers/Api/UserApi", () => jest.requireActual("../../../helpers/mocks/UserApi")); jest.mock("../../../server/SKBKontur/Billy/Billing/WarehouseWeb/Controllers/Api/MovementsApi", () => jest.requireActual("../../../helpers/mocks/MovementsApi") );
test("создает фильтр с указанным автоматическим периодом", () => { jest.useFakeTimers().setSystemTime(new Date(2020, 0, 1)); const filter = new DateRangeFilter(DateRangeType.ThisMonth); expect(filter.from).toBe("01.01.2020"); expect(filter.to).toBe("31.01.2020"); });
dayjs
{dayjs(CreationDate).format("L")} <SecondaryText> {dayjs(CreationDate).format("LT")} </SecondaryText>

const dateStr = dayjs().year() === date.year() ? date.format("D MMMM") : date.format("LL");

MOBX
export class ActiveSupplies { private movementsResource = new ApiResource(MovementsApi.index); public paging = new Paging(); constructor() { makeAutoObservable(this); reaction(() => this.filterForm, this.fetch); } fetch = () => { return this.movementsResource.fetch(userInfoStore.currentWarehouseId, this.filterForm) .then(({ result }) => { this.paging.setTotal(result?.TotalPagesCount ?? 0); }); }; get filterForm() { return createMovementsFilterForm({ States: [MovementState.New, MovementState.Shipped, MovementState.PartiallyCompleted], Types: [MovementType.DistributionToServiceCenter, MovementType.ServiceCenterToServiceCenter], PageNumber: this.paging.current }); }
MOBX - реакции
reaction(() => this.filterForm, this.fetch);
await when(() => userInfo.userResource.isLoaded);
autorun(this.fetch);
MOBX - flow
public async delayedFetch(wait: number, ...args: TParams) { this.awaitingPromise?.cancel(); const fetchPromise = this.fetchFlow(wait, ...args); this.awaitingPromise = fetchPromise; try { return await fetchPromise; } catch (e) { if (isFlowCancellationError(e as Error)) { return { result: null, error: null }; } throw e; } }
MOBX - flow
private fetchFlow = flow(function*(this: ApiResource<TResponse, TParams>, wait: number, ...args: TParams) { this.isLoading = true; if (wait) { yield delay(wait); } try { const { data }: { data: TResponse } = yield this.resourceGetter(...args); this.isLoaded = true; this.result = data; this.error = null; return { result: data, error: null }; } catch (error) { const apiError = convertToApiError(error as AxiosError); this.error = apiError; if (!this.silent) { Toast.push(apiError.message); } return { result: null, error: apiError }; } finally { this.isLoading = false; } });

MOBX - Переиспользуемость

Синхронизация с LocalStorage
public typeFilter = new ItemsFilter(MovementTypeDescription); public hardwareFilter = new ItemsFilter(); public dateRangeFilter = new DateRangeFilter(DateRangeType.ThisMonth); public paging = new Paging(); constructor() { makeAutoObservable(this); syncWithStorage("EventsFeed.dateRangeFilter", this.dateRangeFilter, "type", () => userInfoStore.userId); syncWithStorage("EventsFeed.dateRangeFilter", this.dateRangeFilter, "from", () => userInfoStore.userId); syncWithStorage("EventsFeed.dateRangeFilter", this.dateRangeFilter, "to", () => userInfoStore.userId); syncWithStorage("EventsFeed.typeFilter", this.typeFilter, "value", () => userInfoStore.userId); syncWithStorage("EventsFeed.hardwareFilter", this.hardwareFilter, "value", () => userInfoStore.userId); reaction(() => this.filtersForm, this.fetch); }
MOBX - итог
🌞
⛅
- инкапсуляция
- наследование
- мутации
- почти без бройлерпалэйта
- простота
- не надо отделять сайдэффекты
- удобное тестирование
- неудобный flow
- runInAction
- неудобные dev tools
- несереализуемый стор

Архитектура

Service State
Controller
Store
React
http
axios
saga
reducer
action
selector
selector

M
C
Transport
Layer
M
V
C
VM?
M
C или VM ?
?
Архитектура

Service State
Controller
React
http
axios

M
C
M
V
VM
Store
get
fetch
fetch
Api
Resource
method
get / prop
Transport
Layer
Code-Splitting
Проблема:
- Загружать всё SPA в браузер разом и одним файлом - не эффективно
Их кода приложения обычно отделяют:
- Вендорный код - его много, но он редко изменяется, можно закешировать в браузере
- Код отдельных страниц / виджетов - он не нужен чтобы отрисовать страницу и его можно подгрузить попозже асинхронно
Code-Splitting
splitChunks: { chunks: "all" }
export const EventsFeed = React.lazy(() => import("./EventsFeed"));
// = async + static
Code-Splitting
index.html
main.js
common-vendors.js
page-1.js
page-2.js
page-....js
page-1+page2+vendors.js
page-1+vendors.js
ASYNC
HTMLWebpackPlugin
Code-Splitting
new PreloadWebpackPlugin({ rel: "prefetch" }),
- Ссылки на чанки протухнут после обновления
- Переходы по SPA тригерят загрузку асинхронных чанков
- ...
- UI взорвется с Cannot Load Chunk

Основные чанки
Первый запрос
preload
preloaded
Второй запрос за данными

Code-Splitting
Другой вариант:
react-router - forceReload
+
Webpack Dump Metadata Plugin - hash
react-router
<SwitchPreload> <Route path={Urls.acceptance()}> <DocumentTitle title={"Приемка"}> <Modals /> <ActiveSupplies /> <SupplyHistory /> </DocumentTitle> </Route> <Route path={Urls.delivery()}> <DocumentTitle title={"Выдачи клиентам"} /> </Route> <Route path={Urls.writeoffs()}> <Modals /> <DocumentTitle title={"Списания"} /> <WriteOffs /> </Route> <Route path={Urls.warehouse()}> <DocumentTitle title={"Состояние склада"}> <Modals /> <CurrentBalance /> <EventsFeed /> </DocumentTitle> </Route> </SwitchPreload>
Роуты для модалок
export const Modals = () => { const { url } = useRouteMatch(); return ( <Switch> <Route path={Urls.createIncome(url)}> <CreateIncome backUrl={url} baseUrl={url} /> </Route> </Switch> ); };
url + /createIncome
Вложенность
SwitchPreload
<SwitchPreload> <Route path={Urls.acceptance()}> <DocumentTitle title={"Приемка"}> <Modals /> <ActiveSupplies /> <SupplyHistory /> </DocumentTitle> </Route> <Route path={Urls.delivery()}> <DocumentTitle title={"Выдачи клиентам"} /> </Route> <Route path={Urls.writeoffs()}> <Modals /> <DocumentTitle title={"Списания"} /> <WriteOffs /> </Route> <Route path={Urls.warehouse()}> <DocumentTitle title={"Состояние склада"}> <Modals /> <CurrentBalance /> <EventsFeed /> </DocumentTitle> </Route> </SwitchPreload>
SwitchPreload
export const GlobalProgressBar = ({ progress = "fake" }: Props) => { const { done } = useContext(SwitchPreloadContext); useEffect(() => { return done; }, []);
deck
By Andrey Osipov
deck
- 476