Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7e47952
fix(contacts): extract inline components from Contacts page (SPEK-168)
stepanLav Feb 27, 2026
07654ec
fix(contacts): align EmptyBackendView with shared empty state pattern…
stepanLav Feb 27, 2026
32579e4
fix(contacts): add read-only indicator on backend contacts (SPEK-165)
stepanLav Feb 27, 2026
ea31bda
fix(contacts): add backend URL reachability check (SPEK-163)
stepanLav Feb 27, 2026
583d0f8
fix(settings): add backend configuration section to Settings page (SP…
stepanLav Feb 27, 2026
ae315e3
fix(contacts): add accessibility attributes to address book (SPEK-159)
stepanLav Feb 27, 2026
1e5c99d
fix(contacts): implement SyncStatusBadge with relative time display (…
stepanLav Feb 27, 2026
002a2b1
fix(contacts): add periodic session health check and expiry indicator…
stepanLav Feb 27, 2026
54f69ba
fix(contacts): improve disconnected state visibility in connection ca…
stepanLav Feb 27, 2026
ef0c469
fix(contacts): show cached contacts with error banner on sync failure…
stepanLav Feb 27, 2026
b656a58
Merge branch 'fix/SPEK-158-empty-backend-view' into screenshots/SPEK-155
stepanLav Feb 27, 2026
4f8b58d
fix(contacts): replace ErrorView with Alert component (SPEK-157)
stepanLav Feb 27, 2026
6471639
Merge branch 'fix/SPEK-157-alert-error-view' into screenshots/SPEK-155
stepanLav Feb 27, 2026
da95870
Merge branch 'fix/SPEK-165-readonly-indicator' into screenshots/SPEK-155
stepanLav Feb 27, 2026
0d476fb
Merge branch 'fix/SPEK-163-test-connection' into screenshots/SPEK-155
stepanLav Feb 27, 2026
65a1141
Merge branch 'fix/SPEK-164-settings-backend' into screenshots/SPEK-155
stepanLav Feb 27, 2026
adadf8d
Merge branch 'fix/SPEK-159-accessibility' into screenshots/SPEK-155
stepanLav Feb 27, 2026
2dea091
Merge branch 'fix/SPEK-166-cached-contacts' into screenshots/SPEK-155
stepanLav Feb 27, 2026
047e1c0
Merge branch 'fix/SPEK-167-disconnected-state' into screenshots/SPEK-155
stepanLav Feb 27, 2026
b29353b
feat: add screenshots check
stepanLav Feb 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"test:watch": "cross-env POLKADOTJS_DISABLE_ESM_CJS_WARNING=1 vitest",
"test:coverage": "cross-env POLKADOTJS_DISABLE_ESM_CJS_WARNING=1 vitest run --coverage",
"test:coverage-new-files": "cross-env POLKADOTJS_DISABLE_ESM_CJS_WARNING=1 vitest run --coverage --changed dev",
"test:screenshots": "playwright test --config=playwright.screenshots.config.ts",
"test:system": "cross-env CHAINS_FILE=chains_dev playwright test",
"test:system:regress": "cross-env CHAINS_FILE=chains_dev playwright test --grep @regress",
"test:system:load-fee": "cross-env CHAINS_FILE=chains_dev playwright test --grep @fee-test",
Expand Down
14 changes: 14 additions & 0 deletions playwright.screenshots.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests/screenshots',
fullyParallel: false,
retries: 0,
workers: 1,
reporter: 'list',
use: {
baseURL: 'http://localhost:6006',
...devices['Desktop Chrome'],
},
timeout: 30000,
});
13 changes: 11 additions & 2 deletions src/renderer/domains/network/account/resource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { attach, combine, createStore } from 'effector';

import { type Chain, type Wallet } from '@/shared/core';
import { type Chain, type Contact, type Wallet } from '@/shared/core';
import { type AccountId } from '@/shared/polkadotjs-schemas';
import { createQueryResource } from '@/shared/query';
import { contactModel } from '@/entities/contact';
Expand Down Expand Up @@ -28,7 +28,16 @@ export type AccountsNameParams = {

type NameCache = Record<string, string>;

const getContactsStore = () => contactModel?.$contacts ?? createStore([]);
const $contactsFallback = createStore<Contact[]>([]);

// Defensive: contactModel may be in TDZ during circular module initialization (e.g. Storybook)
const getContactsStore = () => {
try {
return contactModel.$contacts;
} catch {
return $contactsFallback;
}
};

const getNameResolverSource = () => {
return combine({
Expand Down
13 changes: 12 additions & 1 deletion src/renderer/entities/contact/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
export { BackendContactRow, ContactList, ContactRow, EmptyContactList, EmptyFilteredContacts } from './ui';
export {
BackendContactRow,
BackendErrorView,
BackendLoadingView,
CachedWithErrorView,
ContactList,
ContactRow,
ContactSkeleton,
EmptyBackendView,
EmptyContactList,
EmptyFilteredContacts,
} from './ui';
export { contactModel } from './model/contact-model';
55 changes: 55 additions & 0 deletions src/renderer/entities/contact/ui/BackendContactRow.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';

import { type BackendContact } from '@/shared/core';
import { TEST_ACCOUNTS, TEST_ADDRESS } from '@/shared/lib/utils';

import { BackendContactRow } from './BackendContactRow';

const mockContact: BackendContact = {
id: '1',
name: 'Treasury Multisig',
address: TEST_ADDRESS,
accountId: TEST_ACCOUNTS[0],
source: 'backend',
entityNames: ['Nova Foundation', 'Treasury'],
chainId: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
chainName: 'Polkadot',
categoryName: 'Infrastructure',
contactTypeName: 'Multisig',
derivationPath: null,
ownerAccountId: null,
};

const meta: Meta<typeof BackendContactRow> = {
title: 'Address Book/BackendContactRow',
component: BackendContactRow,
decorators: [
(Story) => (
<ul>
<Story />
</ul>
),
],
args: {
contact: mockContact,
onSendTo: () => {},
},
};

export default meta;

type Story = StoryObj<typeof BackendContactRow>;

export const Default: Story = {};

export const MinimalLabels: Story = {
args: {
contact: {
...mockContact,
id: '2',
name: 'Simple Contact',
contactTypeName: null,
entityNames: [],
},
},
};
31 changes: 23 additions & 8 deletions src/renderer/entities/contact/ui/BackendContactRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { Fragment } from 'react';

import { type BackendContact, type Contact } from '@/shared/core';
import { useI18n } from '@/shared/i18n';
import { FootnoteText, IconButton, Plate } from '@/shared/ui';
import { FootnoteText, Icon, IconButton, Plate } from '@/shared/ui';
import { Address } from '@/shared/ui-entities';
import { Copy, Label } from '@/shared/ui-kit';
import { Copy, Label, Tooltip } from '@/shared/ui-kit';

type Props = {
contact: BackendContact;
Expand All @@ -26,10 +26,28 @@ export const BackendContactRow = ({ contact, onSendTo }: Props) => {
<Address address={contact.address} showIcon iconSize={20} variant="truncate" title={contact.name} />
</div>
<div className="ml-auto flex items-center gap-x-1">
<IconButton className="shrink-0 text-icon-default" name="sendArrow" onClick={handleSendTo} />
<IconButton
className="shrink-0 text-icon-default"
name="sendArrow"
ariaLabel={t('addressBook.a11y.sendTo', { name: contact.name })}
onClick={handleSendTo}
/>
<Copy value={contact.address} notification={t('general.notifications.addressCopied')}>
<IconButton className="shrink-0 text-icon-default" name="copy" />
<IconButton
className="shrink-0 text-icon-default"
name="copy"
ariaLabel={t('addressBook.a11y.copyAddress', { name: contact.name })}
/>
</Copy>
<Tooltip enableHover delay={200}>
<Tooltip.Trigger>
<div className="flex items-center gap-x-1 text-text-tertiary">
<Icon name="lock" size={14} />
<FootnoteText className="text-text-tertiary">{t('addressBook.backendContact.synced')}</FootnoteText>
</div>
</Tooltip.Trigger>
<Tooltip.Content>{t('addressBook.backendContact.managedTooltip')}</Tooltip.Content>
</Tooltip>
</div>
</div>

Expand All @@ -39,10 +57,7 @@ export const BackendContactRow = ({ contact, onSendTo }: Props) => {
{contact.chainName && <Label variant="lightBlue">{contact.chainName}</Label>}
{contact.entityNames.map((entityName) => (
<Fragment key={entityName}>
<FootnoteText className="text-text-tertiary">
{/* eslint-disable-next-line i18next/no-literal-string */}
{'\u00b7'}
</FootnoteText>
<FootnoteText className="text-text-tertiary">{'\u00b7'}</FootnoteText>
<Label variant="purple">{entityName}</Label>
</Fragment>
))}
Expand Down
39 changes: 39 additions & 0 deletions src/renderer/entities/contact/ui/BackendErrorView.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';

import { BackendErrorView } from './BackendErrorView';

const meta: Meta<typeof BackendErrorView> = {
title: 'Address Book/BackendErrorView',
component: BackendErrorView,
args: {
onRetry: () => {},
},
};

export default meta;

type Story = StoryObj<typeof BackendErrorView>;

export const NetworkError: Story = {
args: {
error: 'TypeError: Failed to fetch — ECONNREFUSED',
},
};

export const AuthError: Story = {
args: {
error: '401 Unauthorized — session token expired',
},
};

export const TimeoutError: Story = {
args: {
error: 'AbortError: The operation was aborted (timed out)',
},
};

export const GenericError: Story = {
args: {
error: 'Internal server error: unexpected null pointer',
},
};
46 changes: 46 additions & 0 deletions src/renderer/entities/contact/ui/BackendErrorView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useI18n } from '@/shared/i18n';
import { Alert, Button, CaptionText } from '@/shared/ui';

type Props = {
error: string;
onRetry: () => void;
};

function getErrorMessageKey(error: string): string {
const lower = error.toLowerCase();

if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('forbidden')) {
return 'addressBook.sources.errorAuth';
}

if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('aborted')) {
return 'addressBook.sources.errorTimeout';
}

if (
lower.includes('fetch') ||
lower.includes('network') ||
lower.includes('econnrefused') ||
lower.includes('cors') ||
lower.includes('dns')
) {
return 'addressBook.sources.errorNetwork';
}

return 'addressBook.sources.errorGeneric';
}

export const BackendErrorView = ({ error, onRetry }: Props) => {
const { t } = useI18n();

return (
<div className="py-4">
<Alert title={t(getErrorMessageKey(error))} active variant="error">
<CaptionText className="break-all text-text-tertiary">{error}</CaptionText>
<Button variant="text" className="h-4.5 self-start p-0" onClick={onRetry}>
{t('addressBook.sources.retry')}
</Button>
</Alert>
</div>
);
};
14 changes: 14 additions & 0 deletions src/renderer/entities/contact/ui/BackendLoadingView.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';

import { BackendLoadingView } from './BackendLoadingView';

const meta: Meta<typeof BackendLoadingView> = {
title: 'Address Book/BackendLoadingView',
component: BackendLoadingView,
};

export default meta;

type Story = StoryObj<typeof BackendLoadingView>;

export const Default: Story = {};
11 changes: 11 additions & 0 deletions src/renderer/entities/contact/ui/BackendLoadingView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ContactSkeleton } from './ContactSkeleton';

export const BackendLoadingView = () => (
<ul className="flex flex-col gap-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<ContactSkeleton />
</li>
))}
</ul>
);
62 changes: 62 additions & 0 deletions src/renderer/entities/contact/ui/CachedWithErrorView.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';

import { type BackendContact } from '@/shared/core';
import { TEST_ACCOUNTS, TEST_ADDRESS } from '@/shared/lib/utils';

import { CachedWithErrorView } from './CachedWithErrorView';

const mockContacts: BackendContact[] = [
{
id: '1',
name: 'Treasury Multisig',
address: TEST_ADDRESS,
accountId: TEST_ACCOUNTS[0],
source: 'backend',
entityNames: ['Nova Foundation'],
chainId: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
chainName: 'Polkadot',
categoryName: 'Infrastructure',
contactTypeName: 'Multisig',
derivationPath: null,
ownerAccountId: null,
},
{
id: '2',
name: 'Validator Node',
address: TEST_ADDRESS,
accountId: TEST_ACCOUNTS[1],
source: 'backend',
entityNames: ['Staking Ops'],
chainId: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
chainName: 'Kusama',
categoryName: 'Validators',
contactTypeName: null,
derivationPath: null,
ownerAccountId: null,
},
];

const meta: Meta<typeof CachedWithErrorView> = {
title: 'Address Book/CachedWithErrorView',
component: CachedWithErrorView,
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
args: {
error: 'TypeError: Failed to fetch — ECONNREFUSED',
items: mockContacts,
onSendTo: () => {},
onRetry: () => {},
},
};

export default meta;

type Story = StoryObj<typeof CachedWithErrorView>;

export const Default: Story = {};
41 changes: 41 additions & 0 deletions src/renderer/entities/contact/ui/CachedWithErrorView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { type Contact, isBackendContact, isLocalContact } from '@/shared/core';
import { useI18n } from '@/shared/i18n';
import { Alert, BodyText, Button } from '@/shared/ui';

import { BackendContactRow } from './BackendContactRow';
import { ContactRow } from './ContactRow';

type Props = {
error: string;
items: Contact[];
onSendTo: (contact: Contact) => void;
onRetry: () => void;
};

export const CachedWithErrorView = ({ error, items, onSendTo, onRetry }: Props) => {
const { t } = useI18n();

return (
<div className="flex flex-col gap-y-2">
<Alert title={t('addressBook.sources.syncErrorCached')} active variant="warn">
<Alert.Item withDot={false}>
<div className="flex items-center gap-x-2">
<BodyText className="break-all text-text-tertiary">{error}</BodyText>
<Button variant="text" className="h-4.5 shrink-0" onClick={onRetry}>
{t('addressBook.sources.retry')}
</Button>
</div>
</Alert.Item>
</Alert>
<ul className="flex flex-col gap-y-2">
{items.map((contact) =>
isBackendContact(contact) ? (
<BackendContactRow key={contact.id} contact={contact} onSendTo={onSendTo} />
) : isLocalContact(contact) ? (
<ContactRow key={contact.id} contact={contact} onSendTo={onSendTo} />
) : null,
)}
</ul>
</div>
);
};
Loading
Loading