Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
148 changes: 148 additions & 0 deletions lib/note-list/note-cell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import React from 'react';
import { render } from '@testing-library/react';

import { NoteCell } from './note-cell';
import { getSyncErrorMessage } from '../utils/sync-error-message';
import type * as T from '../types';

const noteId = 'note-1' as T.EntityId;

const minimalNote: T.Note = {
content: 'Hello world',
creationDate: 0,
deleted: false,
modificationDate: 0,
systemTags: [],
tags: [],
};

const baseProps = {
displayMode: 'comfy' as T.ListDisplayMode,
invalidateHeight: jest.fn(),
isOffline: false,
isOpened: false,
isSyncing: false,
lastUpdated: -Infinity,
note: minimalNote,
noteId,
openNote: jest.fn(),
pinNote: jest.fn(),
searchQuery: '',
hasPendingChanges: false,
style: {},
syncErrorCode: null as number | null,
};

const expectSpinning = (icon: HTMLElement, spinning: boolean) => {
expect(icon.classList.contains('is-syncing')).toBe(spinning);
};

const expectOfflineStyle = (icon: HTMLElement, offline: boolean) => {
expect(icon.classList.contains('is-offline')).toBe(offline);
};

const expectErrorStyle = (icon: HTMLElement, errored: boolean) => {
expect(icon.classList.contains('has-sync-error')).toBe(errored);
};

describe('NoteCell status icons', () => {
describe('pending sync icon appearance', () => {
it('spinning spinner shows when pending changes regardless of syncing state', () => {
const { getByRole } = render(
<NoteCell {...baseProps} hasPendingChanges />
);
const icon = getByRole('img', { name: 'Pending changes' });

expectSpinning(icon, true);
expectOfflineStyle(icon, false);
expectErrorStyle(icon, false);
});

it('non-spinning spinner shows when pending changes and offline', () => {
const { getByRole } = render(
<NoteCell {...baseProps} hasPendingChanges isOffline />
);
const icon = getByRole('img', {
name: 'Pending changes (waiting for network connection)',
});

expectSpinning(icon, false);
expectOfflineStyle(icon, true);
expectErrorStyle(icon, false);
});

it('non-spinning spinner shows when we have a sync error and we are not actively syncing', () => {
const { getByRole, queryByRole } = render(
<NoteCell {...baseProps} hasPendingChanges syncErrorCode={413} />
);
const icon = getByRole('img', {
name: 'Sync failed',
description: getSyncErrorMessage(413),
});

expectSpinning(icon, false);
expectErrorStyle(icon, true);
expect(queryByRole('img', { name: 'Pending changes' })).toBeNull();
});

it('spinning spinner shows when we are actively syncing and have a sync error', () => {
const { getByRole, queryByRole } = render(
<NoteCell
{...baseProps}
hasPendingChanges
isSyncing
syncErrorCode={413}
/>
);
const icon = getByRole('img', { name: 'Pending changes' });

expectSpinning(icon, true);
expectErrorStyle(icon, false);
expect(queryByRole('img', { name: 'Sync failed' })).toBeNull();
});
});

describe('when the note is not pending', () => {
it('does not render a sync icon', () => {
const { queryByRole } = render(<NoteCell {...baseProps} />);

expect(queryByRole('img', { name: 'Sync failed' })).toBeNull();
expect(queryByRole('img', { name: 'Pending changes' })).toBeNull();
});

it('still renders a sync error icon when syncErrorCode is set', () => {
const { getByRole } = render(
<NoteCell {...baseProps} syncErrorCode={413} />
);

expect(
getByRole('img', {
name: 'Sync failed',
description: getSyncErrorMessage(413),
})
).not.toBeNull();
});
});

describe('published', () => {
it('does not render a published icon when the note is not published', () => {
const { queryByRole } = render(<NoteCell {...baseProps} />);

expect(queryByRole('img', { name: 'Published note' })).toBeNull();
});

it('renders a published icon when publishURL is set', () => {
const { getByRole } = render(
<NoteCell
{...baseProps}
note={{
...minimalNote,
publishURL: 'https://publish.simplenote.com/abc',
}}
/>
);

expect(getByRole('img', { name: 'Published note' })).not.toBeNull();
});
});
});
42 changes: 36 additions & 6 deletions lib/note-list/note-cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SmallSyncIcon from '../icons/sync-small';
import { decorateWith, makeFilterDecorator } from './decorators';
import { getTerms } from '../utils/filter-notes';
import { noteTitleAndPreview } from '../utils/note-utils';
import { getSyncErrorMessage } from '../utils/sync-error-message';
import { withCheckboxCharacters } from '../utils/task-transform';

import actions from '../state/actions';
Expand All @@ -24,12 +25,14 @@ type OwnProps = {

type StateProps = {
displayMode: T.ListDisplayMode;
hasPendingChanges: boolean;
isOffline: boolean;
isOpened: boolean;
isSyncing: boolean;
lastUpdated: number;
note?: T.Note;
searchQuery: string;
hasPendingChanges: boolean;
syncErrorCode: number | null;
};

type DispatchProps = {
Expand Down Expand Up @@ -69,16 +72,18 @@ export class NoteCell extends Component<Props> {
render() {
const {
displayMode,
hasPendingChanges,
isOffline,
isOpened,
isSyncing,
lastUpdated,
noteId,
note,
openNote,
pinNote,
searchQuery,
hasPendingChanges,
style,
syncErrorCode,
} = this.props;

if (!note) {
Expand All @@ -101,6 +106,20 @@ export class NoteCell extends Component<Props> {
'note-list-item-pinned': isPinned,
});
const pinnerLabel = isPinned ? `Unpin note ${title}` : `Pin note ${title}`;
const hasSyncError = null !== syncErrorCode;
const isSyncErrorState = hasSyncError && !isSyncing;
const shouldShowStatusIcon = hasPendingChanges || hasSyncError;
const isSpinning =
isSyncing || (hasPendingChanges && !hasSyncError && !isOffline);
const pendingChangesLabel = isOffline
? 'Pending changes (waiting for network connection)'
: 'Pending changes';
const statusIconLabel = isSyncErrorState
? 'Sync failed'
: pendingChangesLabel;
const statusIconTooltip = isSyncErrorState
? getSyncErrorMessage(syncErrorCode)
: undefined;

const decorators = getTerms(searchQuery).map(makeFilterDecorator);

Expand Down Expand Up @@ -149,17 +168,26 @@ export class NoteCell extends Component<Props> {
)}
</button>
<div className="note-list-item-status-right">
{hasPendingChanges && (
{shouldShowStatusIcon && (
<span
aria-label={statusIconLabel}
className={classNames('note-list-item-pending-changes', {
'is-offline': isOffline,
'has-sync-error': isSyncErrorState,
'is-offline': isOffline && !isSyncErrorState,
'is-syncing': isSpinning,
})}
role="img"
title={statusIconTooltip}
>
<SmallSyncIcon />
</span>
)}
{isPublished && (
<span className="note-list-item-published-icon">
<span
aria-label="Published note"
className="note-list-item-published-icon"
role="img"
>
<PublishIcon />
</span>
)}
Expand All @@ -175,12 +203,14 @@ const mapStateToProps: S.MapState<StateProps, OwnProps> = (
{ noteId }
) => ({
displayMode: state.settings.noteDisplay,
hasPendingChanges: selectors.noteHasPendingChanges(state, noteId),
isOffline: state.simperium.connectionStatus === 'offline',
isOpened: state.ui.openedNote === noteId,
isSyncing: state.simperium.syncingNotes.has(noteId),
lastUpdated: state.simperium.lastRemoteUpdate.get(noteId) ?? -Infinity,
note: state.data.notes.get(noteId),
searchQuery: state.ui.searchQuery,
hasPendingChanges: selectors.noteHasPendingChanges(state, noteId),
syncErrorCode: state.simperium.syncErrors.get(noteId) ?? null,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on a similar note, it would be semantically communicative if this didn’t indicate a stateful UI operation, but rather communicated declarative semantics about the state of the note.

syncRejectionReason: state.simperium.syncErrors.get(noteId) ?? null,
syncFailureCause: state.simperium.syncErrors.get(noteId) ?? null,
syncRejection: state.simperium.syncErrors.get(noteId) ?? null,

trying to stew on names that differentiate between expected sync failures (such as network failure or being offline) and affirmatively rejected syncs that won’t succeed on retry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As of now, this state really just represents errors we receive from the server, so to me syncErrorCode is pretty clear, as in "this is the error code we got while syncing for this note".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it probably seems pretty clear at a glance, but what if the note contents have changed since we received the sync error code, or if we received a remote update to the note? the sync error code was for a version of the note that no longer exists and the error code is out of date, unless we expect it to flash and disappear in these cases.

if we do expect it to automatically flash and disappear, that feels different than a pending-changes indicator because we kind of expected changes to enter a transient state of pending, but it would give me anxiety if I saw an error message appear and then disappear before I get a chance to read it.

});

const mapDispatchToProps: S.MapDispatch<DispatchProps> = {
Expand Down
12 changes: 10 additions & 2 deletions lib/note-list/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,22 @@
}

.note-list-item-pending-changes {
& svg {
&.is-syncing svg {
animation: 2s spin infinite linear;
}

&.is-offline svg {
animation: none;
fill: var(--tertiary-color);
}

&.has-sync-error {
color: var(--tertiary-highlight-color);

svg {
animation: none;
}
}
}

.note-list-item-title {
Expand Down Expand Up @@ -246,7 +254,7 @@
color: var(--primary-color);
}

.note-list-item-pending-changes,
.note-list-item-pending-changes:not(.has-sync-error),
.note-list-item-published-icon {
color: var(--primary-color);
}
Expand Down
8 changes: 8 additions & 0 deletions lib/state/action-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,13 @@ export type NoteBucketRemove = Action<
'NOTE_BUCKET_REMOVE',
{ noteId: T.EntityId }
>;
export type NoteSyncError = Action<
'NOTE_SYNC_ERROR',
{
noteId: T.EntityId;
errorCode: number;
}
>;
export type NoteBucketUpdate = Action<
'NOTE_BUCKET_UPDATE',
{ noteId: T.EntityId; note: T.Note; isIndexing: boolean }
Expand Down Expand Up @@ -370,6 +377,7 @@ export type ActionType =
| MarkdownNote
| NoteBucketRemove
| NoteBucketUpdate
| NoteSyncError
| OpenNote
| OpenRevision
| OpenTag
Expand Down
3 changes: 3 additions & 0 deletions lib/state/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const loadState = (
ghosts: [new Map(state.cvs), new Map(state.ghosts)],
lastRemoteUpdate: new Map(state.lastRemoteUpdate),
lastSync: new Map(state.lastSync),
syncErrors: new Map(state.syncErrors ?? []),
},
};

Expand Down Expand Up @@ -175,6 +176,7 @@ export const saveState = (state: S.State) => {
const ghosts = Array.from(state.simperium.ghosts[1]);
const lastRemoteUpdate = Array.from(state.simperium.lastRemoteUpdate);
const lastSync = Array.from(state.simperium.lastSync);
const syncErrors = Array.from(state.simperium.syncErrors);

const data = {
accountName: state.settings.accountName,
Expand All @@ -187,6 +189,7 @@ export const saveState = (state: S.State) => {
ghosts,
lastRemoteUpdate,
lastSync,
syncErrors,
};

return openDB().then((db) => {
Expand Down
59 changes: 59 additions & 0 deletions lib/state/selectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Ghost } from 'simperium';

import { noteHasPendingChanges } from './selectors';
import type * as S from './';
import type * as T from '../types';

const noteId = 'note-1' as T.EntityId;

const localNote: T.Note = {
content: 'local content',
creationDate: 0,
deleted: false,
modificationDate: 1,
systemTags: [],
tags: [],
};

const ghostNote: T.Note = {
...localNote,
content: 'server content',
};

const makeState = (
overrides: {
note?: T.Note;
ghost?: T.Note;
} = {}
): S.State => {
const note = overrides.note ?? localNote;
const ghost = overrides.ghost ?? ghostNote;
const noteGhosts = new Map<T.EntityId, Ghost<T.Note>>();

if (ghost) {
noteGhosts.set(noteId, { data: ghost } as Ghost<T.Note>);
}

return {
data: {
notes: new Map([[noteId, note]]),
},
simperium: {
ghosts: [new Map(), new Map([['note', noteGhosts]])],
},
} as unknown as S.State;
};

describe('noteHasPendingChanges', () => {
it('returns false when local and ghost notes match', () => {
const state = makeState({ ghost: localNote });

expect(noteHasPendingChanges(state, noteId)).toBe(false);
});

it('returns true when local and ghost notes differ', () => {
const state = makeState();

expect(noteHasPendingChanges(state, noteId)).toBe(true);
});
});
Loading