status-desktop/CONTRIBUTING.md

436 lines
18 KiB
Markdown
Raw Permalink Normal View History

# Status Desktop Architecture Guide
## Introduction
This document contains a series of architectural decisions, principles, and
best practices. They are largely derived from
[SOLID](https://en.wikipedia.org/wiki/SOLID), adapted to the needs of
the project. Many of them are not applied in the current code, but there is
a great need to change this and establish a clear application architecture and
improve the quality of the code. The goal is to follow these guideline in new
code and gradually fix existing codebase.
The diagram depicting top-level architecture can be found
[here](https://miro.com/app/board/uXjVKS3chcc=/).
## General architecture good practices
- Singletons should be stateless.
Any state in a singleton is a flaw from the application architecture point
of view. This creates a number of problems. First, it causes unit tests to be
interdependent, introducing hard to detect bugs. Another, even more important
problem is the order of initialization. In code like this:
```qml
Component.onCompleted: {
Global.applicationWindow = this // BAD
}
```
there is no guarantee that other components will not call `Global.applicationWindow`
before this variable is initialized. The order of initialization of QML
components and therefore calls to `Component.onCompleted` is undefined.
- Singletons should not refer to the backend.
The only layer that has access to the backend is the layer of stores.
Components relying on singletons accessing the backend are hard to test.
Backend references used in the singleton must be mocked, which is problematic
and requires additional exposure of context properties from the singleton only
for testing and storybook purposes, as in the example below.
```qml
// Utils.qml (singleton)
QtObject {
property var mainModuleInst: typeof mainModule !== "undefined" ? mainModule : null
property var sharedUrlsModuleInst: typeof sharedUrlsModule !== "undefined" ? sharedUrlsModule : null
property var globalUtilsInst: typeof globalUtils !== "undefined" ? globalUtils : null
}
```
The testing/storybook code also becomes complicated because it is important to
ensure that a singleton is mocked before the tested component is instantiated.
Stateless singleton also means not using components like `Settings` inside singletons.
- The API of components should be well thought out. Expose dependencies,
hide internal details.
Ideally, it should be enough for a developer to read the public API of a given
component to understand what is needed to use it correctly.
- Examples of hidden dependencies:
- using stateful singletons - components communicate to each other
implicitly, dependency is not visible in their public API
- communicating by calling signals on global objects
- accessing backend via singletons
- taking via public API much more than needed (e.g. component needs two
properties from store, but the whole store is provided with tens of
properties and methods). It is violation of `ISP` from `SOLID`
- Examples of not hidden details:
- component contains searchable list and exposes search string assuming that
filtering will be done externally
- Favor composition over parameterization and inheritance.
Overly parameterized components are fragile and not easy to maintain. Big set
of switches, altering the appearance and behavior of the component, introduces
a lot of conditional statements in the internal implementation. Finally,
it is difficult to assure that all combinations are valid or specify which ones
are valid and desired.
- Do not make assumptions about the context in which the component will be used.
```qml
Item {
anchors.fill: parent // Bad, will lead to warnings when used in a Layout
// ...
}
```
In most cases, it is a good idea to use `Control` (or more specialized component)
as the base component.
```qml
Control {
contentItem: ColumnLayout {
// ...
}
}
```
- Action signals (e.g. click on delegate) should not provide metadata from the
model. Only `index` or unique `key` should be an argument. The necessary
metadata should be fetched on the signal receiver side.
```qml
signal collectibleClicked(int chainId, string contractAddress,
string tokenId, string uid, int tokenType) // BAD
signal collectibleClicked(var collectible) // BAD
signal collectibleClicked(string key) // OK
```
Otherwise, the component unnecessarily bypasses some values from the model.
Callers may also want to access roles which are not used for displaying.
It leads to creating unnecessary requirements for the input model.
- use consistently `key` as a unique identifier of the model, even if there are
other roles with unique values.
On the UI side, `key` should be used only for identification, with no other
assumptions and usage. Thanks to that, content of that role can be freely
changed on the backend with no implications to the UI side. Even if `address`
is unique, a separate `key` role should be used (providing the same content as
`address` role).
Other good name for that role - `id` - does not fit well in qml environment as
it is reserved keyword. Defining such role with `ListElement` in tests or
Storybook would be not possible.
- `model.rowCount()` should not be used in bindings.
This call provides count only once when it is called, and the expression will not
be re-evaluated when count is changed. Solution is to use the attached property
`ModelCount` (`model.ModelCount.count`) context property instead. However,
in signal handling, using `rowCount()` is fully correct and preferred over `ModelCount`
attached property (because it does not create any additional attached object unnecesarily).
- `model.count` should not be used on models taken from outside as a dependency.
`count` property is not a part of the `QAbstractItemModel` interface. Proxy
models and backend models may not have that property defined.
- Objects holding the whole model's row should not be used in bindings.
```qml
readonly property var selectedAccount: ModelUtils.get(store.accounts, 0) // BAD
```
Such objects may be deleted at any time, e.g. because of model reset. The
expression will not be automatically re-evaluated to take the current first item.
- Avoid `QML`'s dynamic scoping. Refer for details
[here](https://doc.qt.io/qt-5/qtqml-documents-scope.html#component-instance-hierarchy).
- Follow [C++ Core Guidelines](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines)
in C++ code.
## Stores
Store are an intermediate layer between the backend and the UI components.
They expose backend's functionality grouped by domain (e.g. `TransactionStore`, `AssetsStore`,
`CommunitiesStore`), without following the UI layouts, where one view can interact with multiple
domains (e.g. communities and wallet's tokens). In other words, changes in the UI should
not result in changes in the store layer.
> [!NOTE]
> The store layer will undergo significant structural changes, including removing singletons,
> moving some code elsewhere, deduplication to expose a given backend functionality in only one
> store, and leaving at most one `RootStore`. For this reason, the following points are more
> about the target state than the current state.
- Stores are a thin wrapper over context properties exposed from the backend, no
additional logic should be there like e.g. data transformations done by proxy models.
Stores are cut-off line in tests and `Storybook pages`. When additional logic
is put there, it is not available in unit tests and `Storybook`. It leads to
more complicated mocking and code duplication (same logic repeated in real
stores and mocked ones).
```qml
// WalletAssetsStore.qml
QtObject {
property LeftJoinModel groupedAccountAssetsModel: LeftJoinModel {
leftModel: root.baseGroupedAccountAssetModel
rightModel: _jointTokensBySymbolModel
joinRole: "tokensKey"
} // Bad - transformation should be kept in adaptor object,
// only basic models should be exposed from store
}
```
- Stores completely hide the backend's context properties and not expose them
directly.
It makes the contract between backend and frontend clear already from the
perspective of the store. It should be enough for a UI developer to rely on
the store's API without inspection of the nim code in order to discover what
methods and properties are exposed from objects directly exposed to the UI
from the backend via store.
```qml
// WalletStore.qml
QtObject {
property var networksModuleInst: networksModule // BAD
}
```
```qml
// WalletStore.qml
QtObject {
id: root
readonly property bool isGoerliEnabled: networksModule.isGoerliEnabled
signal chainIdFetchedForUrl(string url, int chainId)
function foo() {
return networksModule.foo()
}
Component.onCompleted: {
networksModule.chainIdFetchedForUrl.connect(root.chainIdFetchedForUrl)
}
}
```
This additional layer causes that backend changes affect only this part of
the UI code, which can be adjusted here even without changing the API of the
store itself. It is also clear what exactly is exposed.
- A single context property injected from the backend should be exposed only
once in a single store.
Bad:
```qml
// SwapStore.qml
QtObject {
readonly property var flatNetworks: networksModule.flatNetworks
}
// CommunityTokensStore.qml
QtObject {
readonly property var flatNetworks: networksModule.flatNetworks
}
```
Stores represent the state of the application but they are not aware how that
state is rendered and interacted by UI components. There is no rule that a
single component must take a single store with everything exposed what is
needed for that component. On the contrary, the UI component should take all
the stores (assuming that stores do not duplicate exposition) it needs as a
dependency. Moving on, in most cases a UI component should only take
the models/properties it needs and not the entire store.
- Stores are not singletons (if an existing singleton store needs to be used,
still can be taken by a component as an explicit dependency).
When stores are singleton, UI components can access backend from arbitrary
places, in an implicit way. Dependencies are harder to track, APIs are not
clear as they could be and it causes other problems covered in the `general` section.
```qml
isGoerliEnabled: root.rootStore.isGoerliEnabled // OK
isGoerliEnabled: WalletStores.RootStore.isGoerliEnabled // Bad
```
- Exposed properties are read-only, state modification is done by methods.
This approach makes the data flow unidirectional. UI always transforms and
renders read-only data. UI requests any changes via methods (also called actions).
Those actions may result in updates of one or more read-only properties/models.
- Stores should be always typed, no need to use `var`.
Thanks to overriding proper import paths, typed stores are not a problem for
tests and Storybook pages. They can be freely mocked, with the type preserved.
```qml
required property TransactionStore store // OK
required property var store // Bad
```
## Adaptors
Adaptors are special type of `QML` components responsible for transforming backend's data.
Thanks to isolating data (especially models) transformations in adaptors, views
take possibly simple, ready to display data (models and other read-only properties).
Adaptors are usually a composition of proxy models (from external library
`SortFilterProxyModel` and from own library of proxy models: `ObjectProxyModel`,
`LeftJoinModel`, `GroupingModel`, `ConcatModel`, `RenamingProxyModel`,
`MovableModel`, `WritableModel`).
Diagram depicting custom proxy models can be found [here](https://miro.com/app/board/uXjVKTuFYOU=/)
- Adaptors are data-oriented and not tightly coupled to a specific view. Adaptors' names
should reflect the transformation they do instead of following the naming of the UI component
consuming them.
- API of adaptors, similarly as for UI components, should be possibly explicit
and simple. Passing the whole store is usually not a good idea because it is
not clear which part of the store's API is really used by the adaptor. Taking
plain models with well-specified expected roles is usually a better idea.
- Output of one adaptor (usually model) can be an input of another adaptor. This
structure should intuitively reflect the branches in the data flow in the application.
## Storybook
### Features
Storybook is an internal tool that supports rapid development of components in
isolation, outside the application. It is also a kind of catalog of components from
which the application is built.
It offers a number of functionalities that improve development:
- hot reloading
- import paths overloading and `stubs` to isolate from the backend
- integrated test runner
- figma integration
- built-in visual components inspector
- pages organized in groups
- bunch of dedicated components like `GenericListView` to create pages quickly
> [!NOTE]
> The `StatusQ` library also contains this type of utility application, called `Sandbox`,
> which is currently deprecated as it offers less functionality.
### Running
`Storybook` is a `cmake` project and can be easily opened, compiled and run in
`QtCreator` from the `storybook/CMakeLists.txt` file.
### Good practices
- Single `page` instantiates a single component and only necessary mocks/test
data and auxiliary controls interacting with component's API.
It makes the pages simple, easy to maintain and directly presenting the API of
a given component.
- Storybook pages are valuable for all types of components - both basic ones
(delegates, buttons) and more complex ones, doing complex flows like e.g.
funds transfer.
- Storybook (and unit tests) has a mechanism of so-called `stubs` to replace
real stores with empty objects, but in a way preserving types. It is needed
because in Storybook and unit tests backend's context properties used in real
stores are not available. Store's stubs are intended to be empty QtObjects,
actual mocking should be done within a page.
This approach allows making the dependencies of the component truly visible
and explicit in a Storybook page. An alternative approach would be to
implement single, shared mocks for every store and share the among pages. But
it leads to situation that given mock provides much more than is needed by a
single UI component. As a consequence, it is not clear on which part of the
store given component depends on. A separate thing is that UI components
should not depend on the store at all unless it is really necessary (only top
level components covering complex flows).
- A component in Storybook should be functional.
The state of a component's Storybook page quite accurately reflects the state
of the component. Components with a well-designed API and no unnecessary
dependencies are easy to instantiate in isolation in Storybook. Conversely,
overly complex components are a nightmare in Storybook.
- Use identified modules to import stores (using unquoted identifiers). It is
required for the stubs mechanism to work properly.
In practice imports like `import AppLayouts.Wallet.stores 1.0 as WalletStores`
should be used instead of `import "./stores" as WalletStores`. Second version is
relative import from the file system. As a consequence, the mechanism for
overriding import paths for tests and Storybook's pages will not work.
## Code Style
- QML code should be in-line with
[QML Coding Conventions](https://doc.qt.io/qt-5/qml-codingconventions.html).
- Top level component `id` should be always `root`.
- Private properties should be hidden in a `d` object (`QtObject { id: d }`, or
`QObject { id: d }`).
- `objectName` property should not be specified for the top level component in
a given file.
Component may be used in various context and usually should have different
name in every context to disambiguate when doing lookup in unit tests, squish
tests or monitoring tool.
- `qmldir`: entries should be sorted (`Alt+Shift+S` on selection in `QtCreator`).
- Comments and documentation should cover parts which are not obvious, can be
skipped where intention is clear.
- A declarative implementation should be favoured over imperative code in QML
in most cases.
- Use curly brackets to make complex expressions easier to read:
```qml
readonly property bool errorMode: popup.isLoading || !recipientInputLoader.ready ?
false : errorType !== Constants.NoError
|| networkSelector.errorMode
|| !(amountToSendInput.inputNumberValid
|| d.isCollectiblesTransfer) // Hard to read
```
```qml
readonly property bool errorMode: { // Easier to read
if (popup.isLoading || !recipientInputLoader.ready)
return false
return errorType !== Constants.NoError
|| networkSelector.errorMode
|| !(amountToSend.ready || d.isCollectiblesTransfer)
}
```
## Other
- Cryptocurrency balances should be always handled as a big integer strings and
converted to localized human-friendly form only for displaying. Some basic rationale is
provided in [this ticket](https://github.com/status-im/status-desktop/issues/11376).
## Useful links
- [Qt Quick Layouts Overview](https://doc.qt.io/qt-6/qtquicklayouts-overview.html)
- [Customizing Qt Quick Controls](https://doc.qt.io/qt-6/qtquickcontrols-customize.html)
- [Keyboard Focus in Qt Quick](https://doc.qt.io/qt-6/qtquick-input-focus.html)
- [Important Concepts In Qt Quick - Positioning
](https://doc.qt.io/qt-6/qtquick-positioning-topic.html)