These instructions should get you set up ready to work on New Expensify π
nvm
then node
& npm
: brew install nvm && nvm install
watchman
: brew install watchman
npm install
mkcert
: brew install mkcert
followed by npm run setup-https
. If you are not using macOS, follow the instructions here./etc/hosts
for dev.new.expensify.com pointing to localhost:
127.0.0.1 dev.new.expensify.com
You can use any IDE or code editing tool for developing on any platform. Use your favorite!
node
setupIn order to have more consistent builds, we use a strict node
and npm
version as defined in the package.json
engines
field and .nvmrc
file. npm install
will fail if you do not use the version defined, so it is recommended to install node
via nvm
for easy node version management. Automatic node
version switching can be installed for zsh
or bash
using nvm
.
The webpack development server now uses https. If you're using a mac, you can simply run npm run setup-https
.
If you're using another operating system, you will need to ensure mkcert
is installed, and then follow the instructions in the repository to generate certificates valid for dev.new.expensify.com
and localhost
. The certificate should be named certificate.pem
and the key should be named key.pem
. They should be placed in config/webpack
.
npm run web
webpack.dev.ts
For an M1 Mac, read this SO for installing cocoapods.
bundle install
Could not find 'bundler'
, install the bundler gem first: gem install bundler
and try again.Gem::FilePermissionError
when trying to install the bundler gem, you're likely using system Ruby, which requires administrator permission to modify. To get around this, install another version of Ruby with a version manager like rbenv.npm run configure-mapbox
and follow the instructions.
npm install && npm run pod-install
npm run ios
If you want to run the app on an actual physical iOS device, please follow the instructions here.
npm run configure-mapbox
and follow the instructions. If you already did this step for iOS, there is no need to repeat this step.npm run android
npm run desktop
, this will start a new Electron process running on your MacOS desktop in the dist/Mac
folder.To receive notifications on development build of the app while hitting the Staging or Production API, you need to use the production airship config.
Access to fetch at 'https://www.expensify.com/api/BeginSignIn' from origin 'http://localhost:8080' has been blocked by CORS policy
You probably have a misconfigured .env
file - remove it (rm .env
) and try again
Note: Expensify engineers that will be testing with the API in your local dev environment please refer to these additional instructions.
Creating an .env
file is not necessary. We advise external contributors against it. It can lead to errors when
variables referenced here get updated since your local .env
file is ignored.
NEW_EXPENSIFY_URL
- The root URL used for the websiteSECURE_EXPENSIFY_URL
- The URL used to hit the Expensify secure APIEXPENSIFY_URL
- The URL used to hit the Expensify APIEXPENSIFY_PARTNER_NAME
- Constant used for the app when authenticating.EXPENSIFY_PARTNER_PASSWORD
- Another constant used for the app when authenticating. (This is OK to be public)PUSHER_APP_KEY
- Key used to authenticate with Pusher.comSECURE_NGROK_URL
- Secure URL used for ngrok
when testingNGROK_URL
- URL used for ngrok
when testingUSE_NGROK
- Flag to turn ngrok
testing on or offUSE_WDYR
- Flag to turn Why Did You Render
testing on or offUSE_WEB_PROXY
β οΈ- Used in web/desktop development, it starts a server along the local development server to proxy
requests to the backend. External contributors should set this to true
otherwise they'll have CORS errors.
If you don't want to start the proxy server set this explicitly to false
CAPTURE_METRICS
(optional) - Set this to true
to capture performance metrics and see them in Flipper
see PERFORMANCE.md for more informationONYX_METRICS
(optional) - Set this to true
to capture even more performance metrics and see them in Flipper
see React-Native-Onyx#benchmarks for more informationE2E_TESTING
(optional) - This needs to be set to true
when running the e2e tests for performance regression testing.
This happens usually automatically, read this for more informationThe development server is reached through the HTTPS protocol, and any client that access the development server needs a certificate.
You create this certificate by following the instructions in Configuring HTTPS
of this readme. When accessing the website served from the development server on browsers in iOS simulator or Android emulator, these virtual devices need to have the same certificate installed. Follow the steps below to install them.
adb push "$(mkcert -CAROOT)/rootCA.pem" /storage/emulated/0/Download/
to push certificate to install in Download folder.Note: If you want to run app on https://127.0.0.1:8082
, then just install the certificate and use adb reverse tcp:8082 tcp:8082
on every startup.
npm run setupNewDotWebForEmulators android
π Done!
.Note: If you want to run app on https://dev.new.expensify.com:8082
, then just do the Android flow and use npm run startAndroidEmulator
to start the Android Emulator every time (It will configure the emulator).
Possible Scenario:
The flow may fail to root with error adbd cannot run as root in production builds
. In this case, please refer to https://stackoverflow.com/a/45668555. Or use https://127.0.0.1:8082
for less hassle.
npm run setupNewDotWebForEmulators ios
π Done!
.npm run setupNewDotWebForEmulators all
or npm run setupNewDotWebForEmulators
π Done!
.Unit tests are valuable when you want to test one component. They should be short, fast, and ideally only test one thing. Often times in order to write a unit test, you may need to mock data, a component, or library. We use the library Jest to help run our Unit tests.
npm run test
We use Reassure for monitoring performance regression. More detailed information can be found here:
βD
will open the debugging menu.Alternatively, you can also set up debugger using Flipper. After installation, press βD
and select "Open Debugger". This will open Flipper window. To view data stored by Onyx, go to Plugin Manager and install async-storage
plugin.
Our React Native Android app now uses the Hermes
JS engine which requires your browser for remote debugging. These instructions are specific to Chrome since that's what the Hermes documentation provided.
chrome://inspect
Configure...
button to add the Metro server address (typically localhost:8081
, check your Metro
output)To make it easier to test things in web, we expose the Onyx object to the window, so you can easily do Onyx.set('bla', 1)
.
Often, performance issue debugging occurs in debug builds, which can introduce errors from elements such as JS Garbage Collection, Hermes debug markers, or LLDB pauses.
react-native-release-profiler
facilitates profiling within release builds for accurate local problem-solving and broad performance analysis in production to spot regressions or collect extensive device data. Therefore, we will utilize the production build version
To accurately profile your application, generating source maps for Android and iOS is crucial. Here's how to enable them:
Enable source maps on Android
Ensure the following is set in your app'sΒ android/app/build.gradle
Β file.
project.ext.react = [
enableHermes: true,
hermesFlagsRelease: ["-O", "-output-source-map"], // <-- here, plus whichever flag was required to set this away from default
]
Enable source maps on IOS
Within Xcode head to the build phase - Bundle React Native code and images
.
export SOURCEMAP_FILE="$(pwd)/../main.jsbundle.map" // <-- here;
export NODE_BINARY=node
../node_modules/react-native/scripts/react-native-xcode.sh
Install the necessary packages and CocoaPods dependencies:
npm i && npm run pod-install
Depending on the platform you are targeting, run your Android/iOS app in production mode.
Upon completion, the generated source map can be found at:
Android: android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map
IOS: main.jsbundle.map
web: dist/merged-source-map.js.map
cmd+d
(on web) to open the menu and selecting Use Profiling
.cmd+d
again and select to stop profiling.Share
option to export the trace, which includes a trace file (Profile<app version>.cpuprofile
) and build info (AppInfo<app version>.json
).Build info:
{
appVersion: "1.0.0",
environment: "production",
platform: "IOS",
totalMemory: "3GB",
usedMemory: "300MB"
}
AppInfo<app version>.json
and Profile<app version>.cpuprofile
Profile<app version>.cpuprofile
file at the root of your project.AppInfo<app version>.json
switch to that branch and generate the source map as described.IMPORTANT:
You should generate the source map from the same branch as the trace was recorded.
npm run symbolicate-release:android
IOS: npm run symbolicate-release:ios
web: npm run symbolicate-release:web
Profile_trace_for_<app version>-converted.json
will appear in your project's root folder.This is a persistent storage solution wrapped in a Pub/Sub library. In general that means:
report_1234
, report_4567
, etc.). Store collections as individual keys when a component will bind directly to one of those keys. For example: reports are stored as individual keys because OptionRow.js
binds to the individual report keys for each link. However, report actions are stored as an array of objects because nothing binds directly to a single report action.withOnyx()
and non-React libs use Onyx.connect()
setState()
or triggering the callback
with the values currently on disk as part of the connection process)ONYXKEYS
. Each Onyx key represents either a collection of items or a specific entry in storage. For example, since all reports are stored as individual keys like report_1234
, if code needs to know about all the reports (eg. display a list of them in the nav menu), then it would subscribe to the key ONYXKEYS.COLLECTION.REPORT
.Actions are responsible for managing what is on disk. This is usually:
This layer is solely responsible for:
withOnyx()
to bind to Onyx data.As a convention, the UI layer should never interact with device storage directly or call Onyx.set()
or Onyx.merge()
. Use an action! For example, check out this action that is signing in the user here.
validateAndSubmitForm() {
// validate...
signIn(this.state.password, this.state.twoFactorAuthCode);
}
That action will then call Onyx.merge()
to set default data and a loading state, then make an API request, and set the response with another Onyx.merge()
.
function signIn(password, twoFactorAuthCode) {
Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true});
Authentication.Authenticate({
...defaultParams,
password,
twoFactorAuthCode,
})
.then((response) => {
Onyx.merge(ONYXKEYS.SESSION, {authToken: response.authToken});
})
.catch((error) => {
Onyx.merge(ONYXKEYS.ACCOUNT, {error: error.message});
})
.finally(() => {
Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: false});
});
}
Keeping our Onyx.merge()
out of the view layer and in actions helps organize things as all interactions with device storage and API handling happen in the same place. In addition, actions that are called from inside views should not ever use the .then()
method to set loading/error states, navigate or do any additional data processing. All of this stuff should ideally go into Onyx
and be fed back to the component via withOnyx()
. Design your actions so they clearly describe what they will do and encapsulate all their logic in that action.
// Bad
validateAndSubmitForm() {
// validate...
this.setState({isLoading: true});
signIn()
.then((response) => {
if (result.jsonCode === 200) {
return;
}
this.setState({error: response.message});
})
.finally(() => {
this.setState({isLoading: false});
});
}
// Good
validateAndSubmitForm() {
// validate...
signIn();
}
Almost all the code is located in the src
folder, inside it there's some organization, we chose to name directories that are
created to house a collection of items in plural form and using camelCase (eg: pages, libs, etc), the main ones we have for now are:
<pageName>Page
if there are components used only inside one page, they should live in its own directory named after the <pageName>
Note: There is also a directory called /docs
, which houses the Expensify Help site. It's a static site that's built with Jekyll and hosted on GitHub Pages.
Files should be named after the component/function/constants they export, respecting the casing used for it. ie:
CONST
, its file/directory should be named the CONST
.Text
, the file/directory should be named Text
.guid
, the file/directory should be named guid
.DateUtils
.withOnyx
.In most cases, the code written for this repo should be platform-independent. In such cases, each module should have a single file, index.js
, which defines the module's exports. There are, however, some cases in which a feature is intrinsically tied to the underlying platform. In such cases, the following file extensions can be used to export platform-specific code from a module:
index.native.js
index.ios.js
/index.android.js
index.website.js
index.desktop.js
Note: index.js
should be the default and only platform-specific implementations should be done in their respective files. i.e: If you have mobile-specific implementation in index.native.js
, then the desktop/web implementation can be contained in a shared index.js
.
index.ios.js
and index.android.js
are used when the app is running natively on respective platforms. These files are not used when users access the app through mobile browsers, but index.website.js
is used instead. index.native.js
are for both iOS and Android native apps. index.native.js
should not be included in the same module as index.ios.js
or index.android.js
.
When adding new API commands (and preferably when starting using a new one that was not yet used in this codebase) always
prefer to return the created/updated data in the command itself, instead of saving and reloading. ie: if we call CreateTransaction
,
we should prefer making CreateTransaction
return the data it just created instead of calling CreateTransaction
then Get
rvl=transactionList
Different platforms come with varying storage capacities and Onyx has a way to gracefully fail when those storage limits are encountered. When Onyx fails to set or modify a key the following steps are taken:
By default, Onyx will not evict anything from storage and will presume all keys are "unsafe" to remove unless explicitly told otherwise.
To flag a key as safe for removal:
safeEvictionKeys
option in Onyx.init(options)
canEvict
in the Onyx config for each component subscribing to a keytrue
for canEvict
e.g.
Onyx.init({
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
});
export default withOnyx({
reportActions: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
canEvict: props => !props.isActiveReport,
},
})(ReportActionsView);
render()
method. Everything else is exactly the same. Any React skills you have can be applied to React Native.react-navigation
for navigating between parts of the app.react-native-onyx
.This application is built with the following principles.
Data Flow - Ideally, this is how data flows through the app:
Note: Currently the code only does this with report comments. Until we make more server changes, this step is actually done by the client requesting data from the server via XHR and then storing the response in Onyx.
Offline first
UI Binds to data on disk
withOnyx()
) and any change to the Onyx data is published to the component by calling setState()
with the changed data.Onyx.connect()
) and any change to the Onyx data is published to the callback with the changed data.Onyx.connect()
. That is the job of Actions (see next section).report_1234 = {reportID: 1234, isUnread: true}
. Then there is code that monitors Onyx for reports with incomplete data, and calls openReport(1234)
to get the full data for that report. The UI should be able to gracefully handle the report object not being complete. In this example, the sidebar wouldn't display any report that does not have a report name.command=CreateChatReport
which returns a reportID which is used to call command=Get&rvl=reportStuff
.Actions manage Onyx Data
Onyx.merge()
over Onyx.set()
so that other values in an object aren't completely overwritten.Onyx.merge()
or Onyx.set()
directly and should call an action instead.Onyx.connect()
Cross Platform 99.9999%
Updated rules for managing members across all types of chats in New Expensify.
Nobody can leave or be removed from something they were automatically added to. For example:
Member | |
---|---|
Invite | β |
Remove | β |
Leave | β |
Can be removed | β |
Creator | Member(Employee/User) | Admin | Auditor? | |
---|---|---|---|---|
Invite | β | β | β | β |
Remove | β | β | β | β |
Leave | β | β | β | β |
Can be removed | β | β | β | β |
Member(Employee/User) | Admin | Auditor? | |
---|---|---|---|
Invite | β | β | β |
Remove | β | β | β |
Leave | β | β | β |
Can be removed | β | β | β |
Admin | |
---|---|
Invite | β |
Remove | β |
Leave | β |
Can be removed | β |
Creator | Member | Guest(outside of the workspace) | |
---|---|---|---|
Invite | β | β | β |
Remove | β | β | β |
Leave | β | β | β |
Can be removed | β | β | β |
Admin | Member(default) | Member(invited) | |
---|---|---|---|
Invite | β | β | β |
Remove | β | β | β |
Leave | β | β | β |
Can be removed | β | β | β |
Member | |
---|---|
Remove | β |
Leave | β |
Can be removed | β |
Submitter | Manager | |
---|---|---|
Remove | β | β |
Leave | β | β |
Can be removed | β | β |
This application is built with Internationalization (I18n) / Localization (L10n) support, so it's important to always localize the following types of data when presented to the user (even accessibility texts that are not rendered):
In most cases, you will be needing to localize data used in a component, if that's the case, there's a HOC withLocalize. It will abstract most of the logic you need (mostly subscribe to the NVP_PREFERRED_LOCALE Onyx key) and is the preferred way of localizing things inside components.
Some pointers:
User has sent $20.00 to you on Oct 25th at 10:05am
, add just one
key to the translation file and use the arrow function version, like so:
nameOfTheKey: ({amount, dateTime}) => "User has sent " + amount + " to you on " + dateTime,
.
This is because the order of the phrases might vary from one language to another.When working with translations that involve plural forms, it's important to handle different cases correctly.
For example:
Hereβs an example of how to implement plural translations:
messages: () => ({
zero: 'No messages',
one: 'One message',
two: 'Two messages',
few: (count) => ${count} messages
,
many: (count) => You have ${count} messages
,
other: (count) => You have ${count} unread messages
,
})
In your code, you can use the translation like this:
translate('common.messages', {count: 1});
We utilize a CI/CD deployment system built using GitHub Actions to ensure that new code is automatically deployed to our users as fast as possible. As part of this process, all code is first deployed to our staging environments, where it undergoes quality assurance (QA) testing before it is deployed to production. Typically, pull requests are deployed to staging immediately after they are merged.
Every time a PR is deployed to staging, it is added to a special tracking issue with the label StagingDeployCash
(there will only ever be one open at a time). This tracking issue contains information about the new application version, a list of recently deployed pull requests, and any issues found on staging that are not present on production. Every weekday at 9am PST, our QA team adds the πLockCashDeploysπ
label to that tracking issue, and that signifies that they are starting their daily QA cycle. They will perform both regular regression testing and the QA steps listed for every pull request on the StagingDeployCash
checklist.
Once the StagingDeployCash
is locked, we won't run any staging deploys until it is either unlocked, or we run a production deploy. If severe issues are found on staging that are not present on production, a new issue (or the PR that caused the issue) will be labeled with DeployBlockerCash
, and added to the StagingDeployCash
deploy checklist. If we want to resolve a deploy blocker by reverting a pull request or deploying a hotfix directly to the staging environment, we can merge a pull request with the CP Staging
label.
Once we have confirmed to the best of our ability that there are no deploy-blocking issues and that all our new features are working as expected on staging, we'll close the StagingDeployCash
. That will automatically trigger a production deployment, open a new StagingDeployCash
checklist, and deploy to staging any pull requests that were merged while the previous checklist was locked.
These are some of the most central GitHub Workflows. There is more detailed information in the README here.
The preDeploy workflow executes whenever a pull request is merged to main
, and at a high level does the following:
StagingDeployCash
is locked, comment on the merged PR that it will be deployed later.createNewVersion
workflowstaging
branch from main.CP Staging
label, it will execute the cherryPick
workflow to deploy the pull request directly to staging, even if the StagingDeployCash
is locked.The deploy
workflow is really quite simple. It runs when code is pushed to the staging
or production
branches, and:
staging
was updated, it creates a tag matching the new version, and pushes tags.production
was updated, it creates a GitHub Release for the new version.The platformDeploy
workflow is what actually runs the deployment on all four platforms (iOS, Android, Web, macOS Desktop). It runs a staging deploy whenever a new tag is pushed to GitHub, and runs a production deploy whenever a new release is created.
The lockDeploys
workflow executes when the StagingDeployCash
is locked, and it waits for any currently running staging deploys to finish, then gives Applause the :green_circle: to begin QA by commenting in the StagingDeployCash
checklist.
The finishReleaseCycle
workflow executes when the StagingDeployCash
is closed. It updates the production
branch from staging
(triggering a production deploy), deploys main
to staging (with a new PATCH
version), and creates a new StagingDeployCash
deploy checklist.
Sometimes it might be beneficial to generate a local production version instead of testing on production. Follow the steps below for each client:
In order to generate a production web build, run npm run build
, this will generate a production javascript build in the dist/
folder.
The commands used to compile a production or staging desktop build are npm run desktop-build
and npm run desktop-build-staging
, respectively. These will product an app in the dist/Mac
folder named NewExpensify.dmg that you can install like a normal app.
HOWEVER, by default those commands will try to notarize the build (signing it as Expensify) and publish it to the S3 bucket where it's hosted for users. In most cases you won't actually need or want to do that for your local testing. To get around that and disable those behaviors for your local build, apply the following diff:
diff --git a/config/electronBuilder.config.js b/config/electronBuilder.config.js
index e4ed685f65..4c7c1b3667 100644
--- a/config/electronBuilder.config.js
+++ b/config/electronBuilder.config.js
@@ -42,9 +42,6 @@ module.exports = {
entitlements: 'desktop/entitlements.mac.plist',
entitlementsInherit: 'desktop/entitlements.mac.plist',
type: 'distribution',
- notarize: {
- teamId: '368M544MTT',
- },
},
dmg: {
title: 'New Expensify',
diff --git a/scripts/build-desktop.sh b/scripts/build-desktop.sh
index 791f59d733..526306eec1 100755
--- a/scripts/build-desktop.sh
+++ b/scripts/build-desktop.sh
@@ -35,4 +35,4 @@ npx webpack --config config/webpack/webpack.desktop.ts --env file=$ENV_FILE
title "Building Desktop App Archive Using Electron"
info ""
shift 1
-npx electron-builder --config config/electronBuilder.config.js --publish always "$@"
+npx electron-builder --config config/electronBuilder.config.js --publish never "$@"
There may be some cases where you need to test a signed and published build, such as when testing the update flows. Instructions on setting that up can be found in Testing Electron Auto-Update. Good luck π
In order to compile a production iOS build, run npm run ios-build
, this will generate a Chat.ipa
in the root directory of this project.
To build an APK to share run (e.g. via Slack), run npm run android-build
, this will generate a new APK in the android/app
folder.