Metrolist
Last updated
Last updated
Metrolist allows Boston residents to search for affordable housing. The Search and AMI Estimator experiences are built in React (this repository). The rest of the app is built in Drupal, with the underlying data layer provided by Salesforce. The core UX is composed of the following:
Homepage Links to Search, AMI Estimator, and introductory information. Route: /metrolist/ Controlled by: Drupal Search Lists housing opportunities in a paginated fashion and allows user to filter according to various criteria. Route: /metrolist/search Controlled by: React APIs in use: Developments API AMI Estimator Takes user’s household income and household size, and calculates a recommendation for which housing opportunities to look at. URL: /metrolist/ami-estimator/ Sub-routes:
/metrolist/ami-estimator/household-income
/metrolist/ami-estimator/disclosure
/metrolist/ami-estimator/result
Controlled by: React APIs in use: AMI API Property Pages Route: /metrolist/search/housing/[property]?[parameters] Controlled by: Drupal Developments API Lists housing opportunities as a JSON object. URL: /metrolist/api/v1/developments?_format=json AMI API Lists income qualification brackets as a JSON object, taken from HUD (Department of Housing and Urban Development) data. URL: /metrolist/api/v1/ami/hud/base?_format=json
Prerequisites:
Node.js
Yarn or NPM (These docs use yarn
but it can be substituted for npm
if you prefer.)
Git
Read/write access to CityOfBoston
GitHub
⚠️ Warning: These docs were written for a standalone installation of the Metrolist React codebase, which outputs JavaScript files that can be committed to the Drupal monorepo separately. However, the React codebase has since been subsumed into the monorepo, rendering certain build instructions herein out-of-date. Please refer to the Boston.gov documentation for further instruction.
yarn start
runs:
ipconfig getifaddr en6
(or ipconfig getifaddr en0
if en6
isn’t found), which determines which LAN IP to bind to. This allows testing on mobile devices connected to the same network.
webpack-dev-server
. This compiles the ES6+ JavaScript and starts an HTTP server on port 8080 at the address found in the previous step.
Note: The ipconfig
command has only been tested on a Mac, and it also may not work if your connection isn’t located at en6
or en0
.
This runs webpack-dev-server
without launching a new browser window automatically.
There are Node.js scripts available under _scripts/
to aid development efforts.
Located at _scripts/component.js
, this facilitates CRUD-style operations on components.
This copies everything under _templates/components/Component
to src/components/Widget
and does a case-sensitive find-and-replace on the term “component”, replacing it with your new component’s name. For instance, this index.js
template:
…becomes this:
Subcomponents can also be added. These are useful if you want to encapsulate some functionality inside of a larger component, but this smaller component isn’t useful elsewhere in the app.
This creates the directory src/components/Widget/_WidgetGadget
containing this index.js
:
As you can see, the hierarchical relationship between Widget and Gadget is reflected in the naming. The React display name is WidgetGadget
, and the CSS class name uses a BEM element gadget
belonging to the widget
block, i.e. widget__gadget
.
This renames the directory and does a find-and-replace on its contents.
⚠️ Known issue: The component renaming algorithm does not fully find/replace on subcomponents.
Due to compatibility issues with Google Translate, the AMI API is not fetched live from the AMI Estimator. Instead, it is fetched at compile time using this script, which caches it as a local JSON file at src/components/AmiEstimator/ami-definitions.json
.
The domain from which this data is fetched can be specified with the following environment IDs:
www
or prod
→ https://www.boston.gov
Acquia environment
dev2
→ https://d8-dev2.boston.gov
etc.
The default value is ci
, as that should have the most recent data set in most cases.
Sets the version number for Metrolist in Drupal’s libraries.yml
file and this project’s package.json
file.
Option
Description
-m
, --major
Sets the left version part, e.g. 2.x.x. If omitted, major will be taken from existing Metrolist version.
-n
, --minor
Sets the middle version part, e.g. x.5.x. If omitted, minor will be a hash of index.bundle.js for cache-busting.
-p
, --patch
Sets the right version part, e.g. x.x.3289. If omitted while minor is set, patch will be a hash of index.bundle.js for cache-busting. If omitted while minor is not set, patch will not be set.
-f
, --force
Allow downgrading of Metrolist version.
--help
This screen.
Prefer readability for other developers over less typing for yourself.
HTML/CSS:
JavaScript:
Consistent and readable JavaScript formatting is enforced by eslint-config-hughx
+ an ESLint auto-formatter of your choice, such as ESLint for VS Code.
Use Functional Programming principals as often as possible to aid maintainability and predictability. The basic idea is for every function to produce the same output for a given set of inputs regardless of when/where/how often they are called. This means a preference for functions taking their values from explicit parameters as opposed to reading variables from the surrounding scope. Additionally, a function should not produce side-effects by e.g. changing the value of a variable in the surrounding scope.
metrolist/
__mocks__/
: Mocked functions for unit/integration tests.
_scripts/
: CLI tools
_templates/
: Stubbed files for project scaffolding. Used by CLI tools.
coverage/
: Code coverage report. Auto-generated. (.gitignore
’d)
dist/
: Build output. Auto-generated. (.gitignore
’d)
public/
: Static files such as images, favicon, etc. These files are not used by Drupal, which uses its own tempalting; only in development. Thus, images have to be copied to the appropriate directory prior to deployment.
src/
: React source.
components/
: React components.
globals/
: SASS variables, mixins, etc. which are used cross-component.
util/
: Utility functions.
index.js
: React entrypoint.
index.scss
: App-wide styles. (Use sparinginly; prefer component-scoped.)
serviceWorker.js
: Service Worker code from Create React App; not currently used.
setupTests.js
: Jest configuration.
_redirects
: Netlify redirects.
.env
, .env.development
, .env.production
: Dotenv configuration (environment variables).
.eslintrc.js
: ESLint configuration.
.travis.yml
: Travis CI configuration.
babel.config.js
: Babel configuration.
DEVNOTES.md
: Notes taken during development.
package.json
: Project metadata/NPM dependencies.
postcss.config.js
: PostCSS configuration. Used to postprocess CSS output.
README.md
: Project documentation (this file).
webpack.config.js
, webpack.production.js
, webpack.staging.js
: Webpack configurations for different environments.
yarn.lock
/package-lock.json
: Yarn/NPM dependency lock file.
Every React component consists of the following structure:
Component/
__tests__
: Integration tests (optional)
Component.scss
: SASS styling
Component.test.js
: Unit test
index.js
: React component
methods.js
: Any methods that don’t need to go in the render function, for tidiness. (optional)
All classes namespaced as ml-
for Metrolist to avoid collisions with main Boston.gov site and/or third-party libraries.
Vanilla BEM (Block-Element-Modifier):
Blocks: Lowercase name (block
)
Elements: two underscores appended to block (block__element
)
Modifiers: two dashes appended to block or element (block--modifier
, block__element--modifier
).
When writing modifiers, ensure the base class is also present; modifiers should not mean anything on their own. This also gives modifiers higher specificity than regular classes, which helps ensure that they actually get applied.
An exception to this would be for mixin classes that are intended to be used broadly. For example, responsive utilities to show/hide elements at different breakpoints:
Don’t reflect the expected DOM structure in class names, as this expectation is likely to break as projects evolve. Only indicate which block owns the element. This allows components to be transposable and avoids extremely long class names.
Avoid parent selectors when constructing BEM classes. This allows the full selector to be searchable in IDEs. (Though there is a VS Code extension, CSS Navigation, that solves this problem, we can’t assume everyone will have it or VS Code installed.)
Always include parentheses when calling mixins, even if they have no arguments.
Don’t declare margins directly on components, only in wrappers.
Rucksack is installed to enable the same CSS helper functions that are used on Patterns, such as font-size: responsive 16px 24px
.
Currently this is used for previewing on Netlify, to get a live URL up without going through the lengthy Travis and Acquia build process.
This first runs a production Webpack build (referencing webpack.config.js
), then copies the result of that build to ../boston.gov-d8/docroot/modules/custom/bos_components/modules/bos_web_app/apps/metrolist/
, replacing whatever was there beforehand. This requires you to have the boston.gov-d8
repo checked out and up-to-date one directory up from the project root.
To make asset URLs work both locally and on Drupal, all references to /images/
get find-and-replaced to https://assets.boston.gov/icons/metrolist/
when building for production. Note that this requires assets to be uploaded to assets.boston.gov
first, by someone with appropriate access. If you want to look at a production build without uploading to assets.boston.gov
first, you can run a staging build instead.
This is identical to the production build, except Webpack replaces references to /images/
with /modules/custom/bos_components/modules/bos_web_app/apps/metrolist/images/
. This is where images normally wind up when running yarn copy:drupal
.
Aliases exist to avoid long pathnames, e.g. import '@components/Foo'
instead of import '../../../components/Foo'
. Any time an alias is added or removed, three configuration files have to be updated: webpack.config.js
for compilation, jest.config.js
for testing, and .eslintrc.js
for linting. Each one has a slightly different syntax but they all boil down to JSON key-value pairs of the form [alias] → [full path]. Here are the same aliases defined across all three configs:
webpack.config.js
:
jest.config.js
:
.eslintrc.js
:
All mailto:
links require the class hide-form
to be set, otherwise they will trigger the generic feedback form.
We’re using Jest + React Testing Library to ensure that future development doesn’t break existing functionality.
Every component should have its own unit test in the same directory. This is enforced by the Component test stub (_templates/components/Component/Component.test.js
), which contains the following:
So when running yarn component add
, you automatically generate a test that fails by default. You have to manually uncomment the call to render
(and ideally write more specific tests) in order to pass. This is designed to be annoying so it isn’t neglected.
When testing interactions between two or more components, or for utility functions (src/util
), put tests in a nested __tests__
directory.
One example of this is the Search
component, which contains a separate test file for every FiltersPanel
+ ResultsPanel
interaction,:
You have to run a browser without CORS restrictions enabled. For Chrome on macOS, you can add this to your ~/.bash_profile
, ~/.zshrc
, or equivalent for convenience:
This will prevent you from running your normal Chrome profile. To run both simultaneously, install an alternate Chrome such as Canary or Chromium. For Canary you would use this command instead:
Then in a terminal, just type chrome-insecure
and you will get a separate window with no security and no user profile attached. Sometimes Google changes the necessary commands to disable security, so check around online if this command doesn’t work for you. Unfortunately no extensions will be installed for this profile, and if you install any they will only exist for that session since your data directory is under /tmp/
.
We’re using React Router for routing, which provides a Link
component to use in place of a
. Link
uses history.pushState
under the hood, but this will fail inside the Google Translate iframe due to cross-domain security features in the browser. (For an in-depth technical explanation of why this happens, see DEVNOTES). So in order to make app navigation work again, we have to hack around the issue like so:
Change base.href
to the Google Translate iframe domain,
Perform the navigation,
Change base.href
back to boston.gov immediately afterward to make sure normal links and assets don’t break.
To do this automatically, there is a custom Metrolist Link
which wraps the React Router Link
and attaches a click handler with the workaround logic. So, anytime you want to use React Router’s Link
, you need to import and use @components/Link
instead. This is the technique used by the Search component to link to the different pages of results.
If instead you want to use React Router’s history.push
(or the browser-native history.pushState
) manually, you can import these helper functions individually:
This is the technique used by the AMI Estimator component to navigate between the different steps in the form.