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