refactor: address frontend review feedback
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m54s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 9m59s
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m54s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 9m59s
This commit was merged in pull request #43.
This commit is contained in:
@@ -19,8 +19,6 @@ import {
|
||||
useBankSummary,
|
||||
} from "./queries";
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const card: BankCard
|
||||
= {
|
||||
archivedAt: null,
|
||||
@@ -35,7 +33,6 @@ const card: BankCard
|
||||
version: 1,
|
||||
};
|
||||
|
||||
/* eslint-enable unicorn/no-null */
|
||||
const transfer: CreateBankOperation = {
|
||||
amount: { currency: "RUB", value: "90071992547409.93" },
|
||||
cardId: "a",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
enum BankName {
|
||||
AlfaBank = "Альфа-Банк",
|
||||
Sberbank = "Сбербанк",
|
||||
TBank = "Т-Банк",
|
||||
Vtb = "ВТБ",
|
||||
}
|
||||
|
||||
export { BankName };
|
||||
@@ -32,7 +32,6 @@ function bankToday() {
|
||||
function moneyCents(value: string): bigint | null {
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
|
||||
// eslint-disable-next-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null.
|
||||
return null;
|
||||
|
||||
const [whole, fraction = ""] = normalized
|
||||
|
||||
@@ -2,129 +2,68 @@ import type { OperationIconProps } from "@wealthledger/icons";
|
||||
|
||||
import type { BankOperation } from "../api/queries";
|
||||
|
||||
type OperationIcon = {
|
||||
animation?: string;
|
||||
color: string;
|
||||
variant: OperationIconProps["variant"];
|
||||
};
|
||||
const categoryIcons: {
|
||||
appearance: OperationIconProps;
|
||||
name: string;
|
||||
}[] = [
|
||||
{ appearance: { color: "grape", variant: "wine" }, name: "Алкоголь" },
|
||||
{ appearance: { color: "green", variant: "deposit" }, name: "Вклады" },
|
||||
{ appearance: { color: "orange", variant: "home" }, name: "Жильё" },
|
||||
{ appearance: { color: "red", variant: "medical" }, name: "Здоровье" },
|
||||
{
|
||||
appearance: { color: "green", variant: "growth" },
|
||||
name: "Инвестиции и накопления",
|
||||
},
|
||||
{ appearance: { color: "orange", variant: "coffee" }, name: "Кафе" },
|
||||
{ appearance: { color: "blue", variant: "education" }, name: "Образование" },
|
||||
{ appearance: { color: "blue", variant: "transfer" }, name: "Переводы" },
|
||||
{
|
||||
appearance: { color: "orange", variant: "repayment" },
|
||||
name: "Погашение кредита",
|
||||
},
|
||||
{
|
||||
appearance: { color: "grape", variant: "subscriptions" },
|
||||
name: "Подписки",
|
||||
},
|
||||
{ appearance: { color: "grape", variant: "shopping" }, name: "Покупки" },
|
||||
{ appearance: { color: "green", variant: "basket" }, name: "Продукты" },
|
||||
{ appearance: { color: "blue", variant: "travel" }, name: "Путешествия" },
|
||||
{ appearance: { color: "grape", variant: "music" }, name: "Развлечения" },
|
||||
{ appearance: { color: "blue", variant: "wifi" }, name: "Связь и интернет" },
|
||||
{
|
||||
appearance: { color: "green", variant: "cashWithdrawal" },
|
||||
name: "Снятие наличных",
|
||||
},
|
||||
{ appearance: { color: "grape", variant: "dice" }, name: "Ставки" },
|
||||
{ appearance: { color: "green", variant: "shield" }, name: "Страхование" },
|
||||
{ appearance: { color: "orange", variant: "smoking" }, name: "Табак" },
|
||||
{ appearance: { color: "yellow", variant: "taxi" }, name: "Такси" },
|
||||
{ appearance: { color: "blue", variant: "car" }, name: "Транспорт" },
|
||||
{
|
||||
appearance: { color: "grape", variant: "server" },
|
||||
name: "Хостинг и ИИ-сервисы",
|
||||
},
|
||||
{
|
||||
appearance: { color: "blue", variant: "download" },
|
||||
name: "Цифровые покупки",
|
||||
},
|
||||
];
|
||||
|
||||
const categoryIcons: Record<string, OperationIcon> = {
|
||||
"алкоголь": {
|
||||
animation: "wine",
|
||||
color: "grape",
|
||||
variant: "wine",
|
||||
},
|
||||
"вклады": {
|
||||
animation: "deposit",
|
||||
color: "green",
|
||||
variant: "deposit",
|
||||
},
|
||||
"жилье": { color: "orange", variant: "home" },
|
||||
"здоровье": { color: "red", variant: "medical" },
|
||||
"инвестиции и накопления": {
|
||||
animation: "growth",
|
||||
color: "green",
|
||||
variant: "growth",
|
||||
},
|
||||
"кафе": {
|
||||
animation: "coffee",
|
||||
color: "orange",
|
||||
variant: "coffee",
|
||||
},
|
||||
"образование": {
|
||||
color: "blue",
|
||||
variant: "education",
|
||||
},
|
||||
"переводы": {
|
||||
animation: "transfer",
|
||||
color: "blue",
|
||||
variant: "transfer",
|
||||
},
|
||||
"погашение кредита": {
|
||||
animation: "repayment",
|
||||
color: "orange",
|
||||
variant: "repayment",
|
||||
},
|
||||
"подписки": {
|
||||
color: "grape",
|
||||
variant: "subscriptions",
|
||||
},
|
||||
"покупки": {
|
||||
color: "grape",
|
||||
variant: "shopping",
|
||||
},
|
||||
"продукты": {
|
||||
animation: "basket",
|
||||
color: "green",
|
||||
variant: "basket",
|
||||
},
|
||||
"путешествия": {
|
||||
color: "blue",
|
||||
variant: "travel",
|
||||
},
|
||||
"развлечения": {
|
||||
color: "grape",
|
||||
variant: "music",
|
||||
},
|
||||
"связь и интернет": {
|
||||
animation: "wifi",
|
||||
color: "blue",
|
||||
variant: "wifi",
|
||||
},
|
||||
"снятие наличных": {
|
||||
color: "green",
|
||||
variant: "cashWithdrawal",
|
||||
},
|
||||
"ставки": {
|
||||
color: "grape",
|
||||
variant: "dice",
|
||||
},
|
||||
"страхование": {
|
||||
color: "green",
|
||||
variant: "shield",
|
||||
},
|
||||
"табак": {
|
||||
color: "orange",
|
||||
variant: "smoking",
|
||||
},
|
||||
"такси": {
|
||||
color: "yellow",
|
||||
variant: "taxi",
|
||||
},
|
||||
"транспорт": {
|
||||
animation: "car",
|
||||
color: "blue",
|
||||
variant: "car",
|
||||
},
|
||||
"хостинг и ии-сервисы": {
|
||||
animation: "server",
|
||||
color: "grape",
|
||||
variant: "server",
|
||||
},
|
||||
"цифровые покупки": {
|
||||
color: "blue",
|
||||
variant: "download",
|
||||
},
|
||||
};
|
||||
|
||||
const otherIcon: OperationIcon = {
|
||||
animation: "grid",
|
||||
const otherIcon: OperationIconProps = {
|
||||
color: "gray",
|
||||
variant: "grid",
|
||||
};
|
||||
|
||||
const operationIcons: Record<
|
||||
Exclude<BankOperation["kind"], "expense">,
|
||||
OperationIcon
|
||||
OperationIconProps
|
||||
> = {
|
||||
refund: { color: "teal", variant: "refund" },
|
||||
topup: {
|
||||
animation: "topup",
|
||||
color: "green",
|
||||
variant: "topup",
|
||||
},
|
||||
transfer: {
|
||||
animation: "transfer",
|
||||
color: "blue",
|
||||
variant: "transfer",
|
||||
},
|
||||
@@ -135,17 +74,20 @@ function bankOperationAppearance(
|
||||
categoryName?: null | string,
|
||||
) {
|
||||
if (kind !== "expense")
|
||||
// eslint-disable-next-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn.
|
||||
// eslint-disable-next-line security/detect-object-injection -- Operation kinds are a finite API union.
|
||||
return operationIcons[kind];
|
||||
|
||||
const name = categoryName
|
||||
?.trim()
|
||||
.toLocaleLowerCase("ru-RU")
|
||||
.replaceAll("ё", "е");
|
||||
const name = normalizeCategoryName(categoryName ?? "");
|
||||
|
||||
return name && Object.hasOwn(categoryIcons, name)
|
||||
? categoryIcons[name] // eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn.
|
||||
: otherIcon;
|
||||
const category = categoryIcons.find(
|
||||
item => normalizeCategoryName(item.name) === name,
|
||||
);
|
||||
|
||||
return category?.appearance ?? otherIcon;
|
||||
}
|
||||
|
||||
function normalizeCategoryName(name: string) {
|
||||
return name.trim().toLocaleLowerCase("ru-RU").replaceAll("ё", "е");
|
||||
}
|
||||
|
||||
export { bankOperationAppearance };
|
||||
|
||||
@@ -2,17 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
|
||||
import { BankName } from "../../model/bank";
|
||||
import { BankCardFace } from "./BankCardFace";
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const meta
|
||||
= {
|
||||
args: {
|
||||
card: {
|
||||
archivedAt: null,
|
||||
balance: { currency: "RUB", value: "128450.75" },
|
||||
bankName: "Сбербанк",
|
||||
bankName: BankName.Sberbank,
|
||||
id: "fictional",
|
||||
lastFour: "4829",
|
||||
name: "Для повседневных покупок",
|
||||
@@ -25,7 +24,7 @@ const meta
|
||||
component: BankCardFace,
|
||||
tags: ["autodocs"],
|
||||
title: "Product/BankCards/CardAppearance",
|
||||
} satisfies /* eslint-enable unicorn/no-null */ Meta<typeof BankCardFace>;
|
||||
} satisfies Meta<typeof BankCardFace>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -35,33 +34,28 @@ export const Banks: Story = {
|
||||
render: arguments_ => (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}>
|
||||
{[
|
||||
"Сбербанк",
|
||||
"Т-Банк",
|
||||
"Альфа-Банк",
|
||||
"ВТБ",
|
||||
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
BankName.Sberbank,
|
||||
BankName.TBank,
|
||||
BankName.AlfaBank,
|
||||
BankName.Vtb,
|
||||
null,
|
||||
].map((bankName, index) => (
|
||||
<BankCardFace
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
card={
|
||||
{
|
||||
...arguments_.card,
|
||||
bankName,
|
||||
paymentSystem:
|
||||
card={{
|
||||
...arguments_.card,
|
||||
bankName,
|
||||
paymentSystem:
|
||||
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
|
||||
(["mir", "mastercard", "visa", "mir", null] as const)[index]
|
||||
?? null,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */
|
||||
}
|
||||
}}
|
||||
key={bankName ?? "unknown"}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
),
|
||||
};
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const MissingDetails: Story
|
||||
= {
|
||||
args: {
|
||||
@@ -73,7 +67,7 @@ export const MissingDetails: Story
|
||||
},
|
||||
},
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
export const LongArchived: Story = {
|
||||
args: {
|
||||
card: {
|
||||
|
||||
@@ -22,9 +22,7 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
|
||||
<div className={classes.top}>
|
||||
<span className={classes.bank}>{card.bankName || t("bank.card")}</span>
|
||||
|
||||
{card.archivedAt && (
|
||||
<span className={classes.archive}>{t("bank.archived")}</span>
|
||||
)}
|
||||
{card.archivedAt && <span className={classes.archive}>{t("bank.archived")}</span>}
|
||||
</div>
|
||||
|
||||
<div aria-hidden="true" className={classes.hardware}>
|
||||
@@ -46,9 +44,7 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
|
||||
|
||||
{card.paymentSystem && (
|
||||
<span className={classes.network} data-network={card.paymentSystem}>
|
||||
{card.paymentSystem === "mastercard" && (
|
||||
<span aria-hidden="true" className={classes.circles} />
|
||||
)}
|
||||
{card.paymentSystem === "mastercard" && <span aria-hidden="true" className={classes.circles} />}
|
||||
|
||||
{t(`bank.paymentSystems.${card.paymentSystem}`)}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useController, useFormContext } from "react-hook-form";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { bankDateInputValue } from "../../model/form-validation";
|
||||
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
|
||||
import type { BankCardValues } from "../../model/form.schema";
|
||||
|
||||
import { useOpeningDateField } from "./useOpeningDateField";
|
||||
import { usePaymentSystemField } from "./usePaymentSystemField";
|
||||
|
||||
function useBankCardFieldsVm() {
|
||||
const {
|
||||
@@ -15,62 +14,10 @@ function useBankCardFieldsVm() {
|
||||
} = useFormContext<BankCardValues>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const openingDate = useOpeningDateField(control);
|
||||
const paymentSystem = usePaymentSystemField(control);
|
||||
|
||||
const { field: paymentSystemField } = useController({
|
||||
control,
|
||||
name: "paymentSystem",
|
||||
});
|
||||
|
||||
const { field: openingDateField } = useController({
|
||||
control,
|
||||
name: "openingDate",
|
||||
});
|
||||
|
||||
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
|
||||
label: t(`bank.paymentSystems.${value}`),
|
||||
value,
|
||||
}));
|
||||
|
||||
const onPaymentSystemChange: (value: null | string) => void = value =>
|
||||
paymentSystemField.onChange(value ?? "");
|
||||
|
||||
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
|
||||
bankDateSchema.safeParse(bankDateInputValue(value)).success
|
||||
? bankDateInputValue(value)
|
||||
: null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled openingDateField as null.
|
||||
|
||||
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
|
||||
event,
|
||||
) => {
|
||||
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
||||
openingDateField.onBlur();
|
||||
};
|
||||
|
||||
const onOpeningDateChange: (value: null | string) => void = value =>
|
||||
openingDateField.onChange(value ?? "");
|
||||
|
||||
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
|
||||
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
||||
|
||||
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
|
||||
.success
|
||||
? openingDateField.value
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
dateParser,
|
||||
errors,
|
||||
onOpeningDateBlur,
|
||||
onOpeningDateChange,
|
||||
onOpeningDateInput,
|
||||
onPaymentSystemChange,
|
||||
openingDateField,
|
||||
openingDateValue,
|
||||
paymentSystemField,
|
||||
paymentSystems,
|
||||
register,
|
||||
t,
|
||||
};
|
||||
return { ...openingDate, ...paymentSystem, errors, register, t };
|
||||
}
|
||||
|
||||
export { useBankCardFieldsVm };
|
||||
|
||||
@@ -12,8 +12,6 @@ import type { BankCard } from "../../api/queries";
|
||||
|
||||
import { BankCardForm } from "./BankCardForm";
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const card: BankCard
|
||||
= {
|
||||
archivedAt: null,
|
||||
@@ -28,7 +26,6 @@ const card: BankCard
|
||||
version: 1,
|
||||
};
|
||||
|
||||
/* eslint-enable unicorn/no-null */
|
||||
function setup(value: BankCard) {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
@@ -75,7 +72,7 @@ describe("BankCardForm", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalled());
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
expect(body).toEqual(
|
||||
{
|
||||
bankName: null,
|
||||
@@ -84,7 +81,6 @@ describe("BankCardForm", () => {
|
||||
paymentSystem: null,
|
||||
version: 1,
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
);
|
||||
});
|
||||
it("loads and saves the selected payment system", async () => {
|
||||
|
||||
@@ -57,7 +57,6 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
||||
placeholder={t("bank.paymentSystemUnknown")}
|
||||
value={
|
||||
paymentSystemField.value
|
||||
// eslint-disable-next-line unicorn/no-null -- Mantine represents an empty controlled paymentSystemField as null.
|
||||
|| null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -49,16 +49,13 @@ function useBankCardFormVm(props: BankCardFormProps) {
|
||||
const metadata = {
|
||||
bankName:
|
||||
values.bankName
|
||||
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|
||||
|| null,
|
||||
lastFour:
|
||||
values.lastFour
|
||||
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|
||||
|| null,
|
||||
name: values.name,
|
||||
paymentSystem:
|
||||
values.paymentSystem
|
||||
// eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields.
|
||||
|| null,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useController } from "react-hook-form";
|
||||
|
||||
import { bankDateInputValue } from "../../model/form-validation";
|
||||
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
|
||||
|
||||
function useOpeningDateField(control: Control<BankCardValues>) {
|
||||
const { field: openingDateField } = useController({
|
||||
control,
|
||||
name: "openingDate",
|
||||
});
|
||||
|
||||
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
|
||||
bankDateSchema.safeParse(bankDateInputValue(value)).success
|
||||
? bankDateInputValue(value)
|
||||
: null;
|
||||
|
||||
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
|
||||
event,
|
||||
) => {
|
||||
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
||||
openingDateField.onBlur();
|
||||
};
|
||||
|
||||
const onOpeningDateChange = (value: null | string) =>
|
||||
openingDateField.onChange(value ?? "");
|
||||
|
||||
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
|
||||
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
||||
|
||||
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
|
||||
.success
|
||||
? openingDateField.value
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
dateParser,
|
||||
onOpeningDateBlur,
|
||||
onOpeningDateChange,
|
||||
onOpeningDateInput,
|
||||
openingDateField,
|
||||
openingDateValue,
|
||||
};
|
||||
}
|
||||
|
||||
export { useOpeningDateField };
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
import { useController } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BankCardValues } from "../../model/form.schema";
|
||||
|
||||
function usePaymentSystemField(control: Control<BankCardValues>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { field: paymentSystemField } = useController({
|
||||
control,
|
||||
name: "paymentSystem",
|
||||
});
|
||||
|
||||
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
|
||||
label: t(`bank.paymentSystems.${value}`),
|
||||
value,
|
||||
}));
|
||||
|
||||
const onPaymentSystemChange = (value: null | string) =>
|
||||
paymentSystemField.onChange(value ?? "");
|
||||
|
||||
return { onPaymentSystemChange, paymentSystemField, paymentSystems };
|
||||
}
|
||||
|
||||
export { usePaymentSystemField };
|
||||
@@ -10,7 +10,9 @@ const BankOperationIcon = (props: {
|
||||
categoryName?: null | string;
|
||||
kind: "expense" | "refund" | "topup" | "transfer";
|
||||
}) => (
|
||||
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
|
||||
<OperationIcon
|
||||
{...bankOperationAppearance(props.kind, props.categoryName)}
|
||||
/>
|
||||
);
|
||||
|
||||
const meta = {
|
||||
|
||||
@@ -8,7 +8,9 @@ const BankOperationIcon = (props: {
|
||||
categoryName?: null | string;
|
||||
kind: "expense" | "refund" | "topup" | "transfer";
|
||||
}) => (
|
||||
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
|
||||
<OperationIcon
|
||||
{...bankOperationAppearance(props.kind, props.categoryName)}
|
||||
/>
|
||||
);
|
||||
|
||||
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
|
||||
@@ -28,7 +30,6 @@ it("matches normalized category names, falls back after renaming and distinguish
|
||||
for (const categoryName of [
|
||||
"Моя категория",
|
||||
"constructor",
|
||||
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
null,
|
||||
]) {
|
||||
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
|
||||
|
||||
@@ -4,8 +4,6 @@ import { BankSummaryView } from "./BankSummary.view";
|
||||
|
||||
const money = (value: string) => ({ currency: "RUB", value });
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const meta
|
||||
= {
|
||||
args: {
|
||||
@@ -62,14 +60,14 @@ const meta
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
title: "Product/BankCards/Summary",
|
||||
} satisfies /* eslint-enable unicorn/no-null */ Meta<typeof BankSummaryView>;
|
||||
} satisfies Meta<typeof BankSummaryView>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Loaded: Story = { name: "Расходы по категориям" };
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const RefundMonth: Story
|
||||
= {
|
||||
args: {
|
||||
@@ -93,8 +91,7 @@ export const RefundMonth: Story
|
||||
},
|
||||
name: "Возврат покупки прошлого месяца",
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const Empty: Story
|
||||
= {
|
||||
args: {
|
||||
@@ -109,7 +106,7 @@ export const Empty: Story
|
||||
},
|
||||
name: "Известные нули",
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
export const Loading: Story = {
|
||||
args: { data: undefined, loading: true },
|
||||
name: "Начальная загрузка",
|
||||
@@ -122,7 +119,7 @@ export const CachedError: Story = {
|
||||
args: { error: true },
|
||||
name: "Сохранённые суммы при ошибке обновления",
|
||||
};
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const LongAmounts: Story
|
||||
= {
|
||||
args: {
|
||||
@@ -145,4 +142,3 @@ export const LongAmounts: Story
|
||||
},
|
||||
name: "Точный итог длиннее предела отдельного ввода",
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
@@ -109,9 +109,7 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{props.data && props.fetching && (
|
||||
<Text role="status">{t("bank.refreshing")}</Text>
|
||||
)}
|
||||
{props.data && props.fetching && <Text role="status">{t("bank.refreshing")}</Text>}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -88,9 +88,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
|
||||
<Text>{category.name}</Text>
|
||||
|
||||
{category.isDefault && (
|
||||
<Badge>{t("bank.defaultCategory")}</Badge>
|
||||
)}
|
||||
{category.isDefault && <Badge>{t("bank.defaultCategory")}</Badge>}
|
||||
</Group>
|
||||
|
||||
<Button onClick={category.onRename} variant="subtle">
|
||||
@@ -100,9 +98,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
);
|
||||
})}
|
||||
|
||||
{categories.isLoading && (
|
||||
<Text role="status">{t("bank.loading")}</Text>
|
||||
)}
|
||||
{categories.isLoading && <Text role="status">{t("bank.loading")}</Text>}
|
||||
|
||||
{categories.isError && (
|
||||
<BankFeedback
|
||||
|
||||
@@ -34,7 +34,7 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||
t("ledger.occurredAt", { date: formatSnapshotTime(operation.occurredAt) }),
|
||||
instrument,
|
||||
props.withDetails
|
||||
? null // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
? null
|
||||
: operation.account?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -45,7 +45,6 @@ function brokerageAccountCards(
|
||||
return {
|
||||
account,
|
||||
positionCounts:
|
||||
// eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
|
||||
null,
|
||||
source,
|
||||
summary: { state: "unavailable" },
|
||||
|
||||
-6
@@ -64,8 +64,6 @@ export const Available: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const Unavailable: Story
|
||||
= {
|
||||
args: {
|
||||
@@ -83,7 +81,6 @@ export const Unavailable: Story
|
||||
},
|
||||
},
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
@@ -193,8 +190,6 @@ export const LargeValues: Story = {
|
||||
name: "Длинные суммы",
|
||||
};
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const IndependentFreshness: Story
|
||||
= {
|
||||
args: {
|
||||
@@ -236,4 +231,3 @@ export const IndependentFreshness: Story
|
||||
},
|
||||
name: "Независимая свежесть и ошибка сводок",
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
+4
-5
@@ -29,7 +29,7 @@ describe("BrokerageAccountList", () => {
|
||||
],
|
||||
},
|
||||
type: "BROKERAGE",
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
},
|
||||
{
|
||||
id: "account-2",
|
||||
@@ -38,10 +38,10 @@ describe("BrokerageAccountList", () => {
|
||||
portfolioSummary: null,
|
||||
type: "BROKERAGE",
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */ {
|
||||
{
|
||||
id: "account-3",
|
||||
name: "Пустой",
|
||||
openedOn: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
|
||||
openedOn: null,
|
||||
portfolioSummary: {
|
||||
bondPositionCount: 0,
|
||||
stockPositionCount: 2,
|
||||
@@ -84,7 +84,7 @@ describe("BrokerageAccountList", () => {
|
||||
it("shows portfolio summary freshness independently for each account", () => {
|
||||
const accounts = brokerageAccountCards(
|
||||
["fresh", "stale"].map(
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
id =>
|
||||
({
|
||||
id,
|
||||
@@ -97,7 +97,6 @@ it("shows portfolio summary freshness independently for each account", () => {
|
||||
},
|
||||
type: "BROKERAGE" as const,
|
||||
}),
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
[
|
||||
resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" })
|
||||
|
||||
@@ -105,13 +105,9 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{source?.sync.state === "failed" && (
|
||||
<Text size="xs">{t("accounts.refreshError")}</Text>
|
||||
)}
|
||||
{source?.sync.state === "failed" && <Text size="xs">{t("accounts.refreshError")}</Text>}
|
||||
|
||||
{source?.freshness === "stale" && (
|
||||
<Text size="xs">{t("investment.stale")}</Text>
|
||||
)}
|
||||
{source?.freshness === "stale" && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
</Stack>
|
||||
|
||||
{!!positionCounts && (
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("useBrokerageEvents", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
|
||||
HttpResponse.json(
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
resource(
|
||||
{
|
||||
accountId: "account-1",
|
||||
@@ -56,7 +56,6 @@ describe("useBrokerageEvents", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -67,7 +66,7 @@ describe("useBrokerageEvents", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
expect(result.current.data?.events).toEqual([
|
||||
{
|
||||
date: "2026-09-01",
|
||||
@@ -80,7 +79,7 @@ describe("useBrokerageEvents", () => {
|
||||
ticker: "SBER",
|
||||
type: "dividend",
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
|
||||
date,
|
||||
event.dateKind === "registry"
|
||||
? t("events.dateKind.registry")
|
||||
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
: null,
|
||||
event.isin,
|
||||
event.account?.name,
|
||||
]
|
||||
|
||||
+1
-9
@@ -6,8 +6,6 @@ import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||
|
||||
import { BrokerageEventsPreview } from "./BrokerageEventsPreview";
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const syntheticEvent
|
||||
= {
|
||||
date: "2026-10-15",
|
||||
@@ -20,7 +18,7 @@ const syntheticEvent
|
||||
projectedAmount: { currency: "RUB", value: "850.50" },
|
||||
ticker: "DEMO",
|
||||
type: "coupon",
|
||||
} satisfies /* eslint-enable unicorn/no-null */ BrokerageEvent;
|
||||
} satisfies BrokerageEvent;
|
||||
|
||||
const eventsPath = "/accounts/storybook-account/events";
|
||||
|
||||
@@ -121,8 +119,6 @@ export const Empty: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const MissingAmount: Story
|
||||
= {
|
||||
args: {
|
||||
@@ -142,9 +138,6 @@ export const MissingAmount: Story
|
||||
},
|
||||
},
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const LongEvent: Story
|
||||
= {
|
||||
@@ -165,4 +158,3 @@ export const LongEvent: Story
|
||||
},
|
||||
name: "Длинное название на узком экране",
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
@@ -46,31 +46,21 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||
|
||||
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
|
||||
{!!events.refreshError && (
|
||||
<Text role="status">{t("accounts.refreshError")}</Text>
|
||||
)}
|
||||
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{events.state === "loading" && (
|
||||
<LoadingSkeleton label={t("events.recentLoading")}>
|
||||
{["first", "second", "third"].map(placeholder => (
|
||||
<Skeleton height={48} key={placeholder} radius="sm" />
|
||||
))}
|
||||
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
|
||||
</LoadingSkeleton>
|
||||
)}
|
||||
|
||||
{events.state === "error" && (
|
||||
<Alert color="red">{t("events.recentError")}</Alert>
|
||||
)}
|
||||
{events.state === "error" && <Alert color="red">{t("events.recentError")}</Alert>}
|
||||
|
||||
{events.state === "empty" && (
|
||||
<Text c="dimmed">{t("events.recentEmpty")}</Text>
|
||||
)}
|
||||
{events.state === "empty" && <Text c="dimmed">{t("events.recentEmpty")}</Text>}
|
||||
|
||||
{events.state === "partial" && (
|
||||
<Text role="status">{t("investment.partial")}</Text>
|
||||
)}
|
||||
{events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
|
||||
|
||||
{(events.state === "loaded" || events.state === "partial")
|
||||
&& events.items.map(event => (
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("persistent resource query lifecycle", () => {
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
await act(() => result.current.observe("new"));
|
||||
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
|
||||
|
||||
expect(modes).toEqual([null, "observe", "observe"]);
|
||||
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
|
||||
expect(
|
||||
@@ -89,14 +89,13 @@ describe("persistent resource query lifecycle", () => {
|
||||
expect(result.current.status).toBe("pending");
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
expect(result.current.transport).toEqual(
|
||||
{
|
||||
error: null,
|
||||
fetchStatus: "idle",
|
||||
status: "success",
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
);
|
||||
expect(result.current.meta?.availability).toBe("missing");
|
||||
});
|
||||
|
||||
@@ -9,15 +9,12 @@ function formatShare(
|
||||
denominator: Money | null,
|
||||
): null | string {
|
||||
if (!numerator || !denominator)
|
||||
// eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
|
||||
return null;
|
||||
const total = Number(denominator.value);
|
||||
if (!Number.isFinite(total) || total <= 0)
|
||||
// eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
|
||||
return null;
|
||||
const percent = (Number(numerator.value) / total) * PERCENT_SCALE;
|
||||
if (!Number.isFinite(percent))
|
||||
// eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
|
||||
return null;
|
||||
|
||||
const rounded = new Intl.NumberFormat("ru-RU", {
|
||||
|
||||
Reference in New Issue
Block a user