Skip to content

Commit

Permalink
Merge pull request #90 from Tietokilta/feature/embeddability
Browse files Browse the repository at this point in the history
Embeddability changes for Tietokilta's website
  • Loading branch information
PurkkaKoodari authored Jan 17, 2023
2 parents 3e77e72 + 072a608 commit 13010e6
Show file tree
Hide file tree
Showing 128 changed files with 1,207 additions and 773 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.git/
node_modules/
packages/*/node_modules/
packages/*/build/
Expand Down
52 changes: 40 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,42 @@ SMTP_PASSWORD=''
# Mailgun API key and domain, if using it.
MAILGUN_API_KEY=''
MAILGUN_DOMAIN=''
# Mailgun server to use (defaults to api.eu.mailgun.net)
MAILGUN_HOST='api.eu.mailgun.net'


# URL settings

# URI prefix used for asset URLs and routing. Include initial / but not final /.
# Canonical base URL for the app. Used by the backend.
# Include $PATH_PREFIX, but NOT a final "/".
# e.g. "http://example.com" or "http://example.com/ilmo"
BASE_URL='http://localhost:3000'

# URI prefix for the app. Used for frontend URLs.
# Include initial "/", but NOT a final "/".
# e.g. "" or "/ilmo"
PATH_PREFIX=''
# URI prefix or full base URL for API. Leave blank to use $PATH_PREFIX/api. Include "/api" but not final /.

# URI prefix or full base URL to the API. Used by the frontend.
# Leave empty to use "$PATH_PREFIX/api".
# YOU SHOULD LEAVE THIS EMPTY unless you're building the frontend against a remote API.
# Include "/api" if applicable but NOT a final "/".
API_URL=''
# Full base URL for email links. Do not include $PATH_PREFIX or final / in this!
EMAIL_BASE_URL=http://localhost:3000

# URL template for an event details page. Used by the backend for iCalendar exports.
# Leave empty to use the default routes used by the frontend, i.e. "$BASE_URL/events/{id}".
# YOU SHOULD LEAVE THIS EMPTY unless you're using a customized frontend with different paths.
# Use the token {slug}, e.g. http://example.com/event/{slug}
EVENT_DETAILS_URL=''

# URL template for a signup edit page. Used by the backend for emails.
# Leave empty to use the default routes used by the frontend, i.e. "$BASE_URL/signup/{id}/{editToken}".
# YOU SHOULD LEAVE THIS EMPTY unless you're using a customized frontend with different paths.
# Use the tokens {id} and {editToken}, e.g. http://example.com/signup/{id}/{editToken}
EDIT_SIGNUP_URL=''

# Allowed origins for cross-site requests to API. Separate with commas or use * for all.
ALLOW_ORIGIN=''


# Sentry.io public DSN for error tracking (only used in production, leave empty to disable)
Expand All @@ -77,13 +103,15 @@ SENTRY_DSN=''
# Branding settings

# Website strings (requires website rebuild)
BRANDING_HEADER_TITLE_TEXT=Athenen ilmomasiina
BRANDING_FOOTER_GDPR_TEXT=Tietosuoja
BRANDING_FOOTER_GDPR_LINK=https://athene.fi/hallinto/materiaalit/
BRANDING_FOOTER_HOME_TEXT=Athene.fi
BRANDING_FOOTER_HOME_LINK=https://athene.fi
BRANDING_HEADER_TITLE_TEXT='Ilmomasiina'
BRANDING_FOOTER_GDPR_TEXT='Tietosuoja'
BRANDING_FOOTER_GDPR_LINK='http://example.com/privacy'
BRANDING_FOOTER_HOME_TEXT='Example.com'
BRANDING_FOOTER_HOME_LINK='http://example.com'

# Email strings
BRANDING_MAIL_FOOTER_TEXT=Rakkaudella, Tietskarijengi & Athene
BRANDING_MAIL_FOOTER_LINK=ilmo.athene.fi
BRANDING_MAIL_FOOTER_TEXT='Rakkaudella, Tietskarijengi & Athene'
BRANDING_MAIL_FOOTER_LINK='https://ilmo.athene.fi'

# iCalendar exported calendar name
BRANDING_ICAL_CALENDAR_NAME=Ilmomasiina
BRANDING_ICAL_CALENDAR_NAME='Ilmomasiina'
17 changes: 11 additions & 6 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,21 @@ jobs:
steps:
-
name: Check out the repo
uses: actions/checkout@v2
uses: actions/checkout@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
-
name: Login to GHCR
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: ghcr.io/tietokilta/ilmomasiina
flavor: |
Expand All @@ -44,7 +44,7 @@ jobs:
type=sha
-
name: Push to GitHub Packages
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
context: .
push: true
Expand All @@ -68,12 +68,17 @@ jobs:
-
name: Check out the repo
uses: actions/checkout@v3
-
uses: pnpm/action-setup@v2
with:
version: 7
-
name: Setup Node.js for NPM
uses: actions/setup-node@v3
with:
registry-url: 'https://registry.npmjs.org'
node-version: '14'
node-version: '16'
cache: 'pnpm'
-
name: Install dependencies
run: |
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ jobs:
-
name: Check out the repo
uses: actions/checkout@v3
-
uses: pnpm/action-setup@v2
with:
version: 7
-
name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '14'
node-version: '16'
cache: 'pnpm'
-
name: Install dependencies
run: |
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
16
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ FROM node:14-alpine as builder

# Build-time env variables
ARG SENTRY_DSN
ARG PREFIX_URL
ARG PATH_PREFIX
ARG API_URL
ARG BRANDING_HEADER_TITLE_TEXT
ARG BRANDING_FOOTER_GDPR_TEXT
Expand All @@ -26,7 +26,7 @@ ENV NODE_ENV=production
RUN npm run build

# Main stage:
FROM node:14-alpine
FROM node:16-alpine

# Default to production
ENV NODE_ENV=production
Expand All @@ -39,8 +39,8 @@ WORKDIR /opt/ilmomasiina
# Install dependencies for backend only
RUN npm install -g pnpm@7 && pnpm install --frozen-lockfile --prod --filter @tietokilta/ilmomasiina-backend

# Copy compiled ilmomasiina-models into src (TODO: figure out a better solution)
COPY --from=builder /opt/ilmomasiina/packages/ilmomasiina-models/dist /opt/ilmomasiina/packages/ilmomasiina-models/src
# Copy compiled ilmomasiina-models from build stage
COPY --from=builder /opt/ilmomasiina/packages/ilmomasiina-models/dist /opt/ilmomasiina/packages/ilmomasiina-models/dist

# Copy built backend from build stage
COPY --from=builder /opt/ilmomasiina/packages/ilmomasiina-backend/dist /opt/ilmomasiina/packages/ilmomasiina-backend/dist
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Node modules will be installed at the build phase and included in the container image.
# Sources and other files are intended to be provided using bind mounts.

FROM node:14-alpine
FROM node:16-alpine

WORKDIR /opt/ilmomasiina

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ services:
context: .
dockerfile: "Dockerfile"
args:
#- PREFIX_URL=
#- PATH_PREFIX=
#- API_URL=
- BRANDING_HEADER_TITLE_TEXT=Tietokillan ilmomasiina
- BRANDING_FOOTER_GDPR_TEXT=Tietosuoja
Expand Down
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ To start contributing to the project, read at least:
since the Athene version.

Other technical documentation:
- [`createStateContext` and `createReducerContext`](state-context.md), the `useReducer` wrapper used instead
of Redux in `ilmomasiina-components`. Relevant if you want to develop that package.
- [`createStateContext`](state-context.md), the `useContext` wrapper used in `ilmomasiina-components`.
Relevant if you want to develop that package.
- [Signup logic](signup-logic.md), documents the exact business logic behind signups, quotas and queueing
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ configure SMTP servers via env variables if you wish.

If you want to serve ilmomasiina from a subdirectory of your website, you need to set up reverse proxying.

**Note:** This will also require changing the `PREFIX_URL` env variable when building the frontend or container.
**Note:** This will also require changing the `PATH_PREFIX` env variable when building the frontend or container.

For example, in Apache `.htaccess`:

Expand Down
23 changes: 23 additions & 0 deletions docs/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ and package files.
To prepare for development, pnpm must bootstrap the cross-dependencies between projects. To do this, install pnpm and
then simply run `pnpm install` or `npm run bootstrap`.

## Package dependencies

The package dependencies are slighly complicated to manage properly, so that all tooling understands them:

- Each `package.json` refers to the packages it depends on. pnpm uses these for its symlinking.
- All files are imported from `@tietokilta/ilmomasiina-foo` or `@tietokilta/ilmomasiina-foo/dist`, just as in
non-Ilmomasiina packages depending on them.
- Each importable `package.json` specifies `exports`, including a root export and potentially other files in `./dist`.
These point to the compiled `.js` and `.d.ts` files under `./dist`.
`./src` is also exported and points to `.ts` files for TypeScript compilation.
- The `references` field in each `tsconfig.json` points to another `tsconfig`, so that the TypeScript compiler
can find the source files for these imports (since `dist` will not exist yet when building).
- `ts-node` (and by extension `ts-node-dev`), which we use for the backend, doesn't understand `references`.
Therefore, the cross-package imports are also defined in `paths` in `tsconfig.json`, which `ts-node` _does_ understand.
- ESBuild, which we use for the frontend, doesn't understand either of these natively. `paths` is used again here,
along with a small ESBuild plugin to handle the resolving using it.

Using `references` is the only way to dynamically compile missing imported dependencies automatically. This also requires
us to use `tsc --build` for both type checking and building.

To avoid mysterious errors from TypeScript compiler instances running simultaneously on the same folder, the root
project's `package.json` specifies `--workspace-concurrency=1` to prevent pnpm from running those tasks in parallel.

## Packages

The project is divided into four packages:
Expand Down
98 changes: 12 additions & 86 deletions docs/state-context.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,17 @@
# `create{State,Reducer}Context`
# `createStateContext`

The public side of the frontend has a slightly weird state management system built on `useReducer` and `useContext`.
In one sentence, it's a `useReducer` wrapper with some convenience added.
The public side of the frontend uses some state management utilities built on `useContext`.

It's mostly contained in one short file,
[stateContext.tsx](../packages/ilmomasiina-frontend/src/utils/stateContext.tsx). The system is pretty simple and
should be easy to learn for anyone familiar with reducers.

## Why?

TL;DR: I wasn't happy with any existing solutions, and the code is cleaner.

- The main reason for the change is **embeddability**. Redux, which we used previously, puts everything in a global,
shared store, which means that our public-side views would depend on our entire store typings (and in turn on the
dependencies of our admin stuff).
- Now, we could solve this by creating two root reducers/stores, one for the public side and one for the full app. The
public side would then use typings that only contain its own modules. However, this loses many of the benefits
Redux has over other solutions, like easy debugging tools.
- Redux also has a lot of boilerplate, especially when fetching data and tracking pending/error states and results
It also requires one to reset the state when unmounting.
- I considered using Recoil, which allows `<RecoilRoot override>` to create a nested context, but Recoil is ultimately
also pretty restrictive (state can only be changed via components or Recoil itself) and boilerplate-y in places
(e.g. explicit view state resets).
- The state we store in Redux doesn't change often. Therefore, we can use this simple context-based solution, and eat
the slight performance reduction from always re-rendering on any reducer update (which Redux could combat with
memoized selectors). We can add a `useContextSelector` hook if necessary.

## How?
[stateContext.tsx](../packages/ilmomasiina-frontend/src/utils/stateContext.tsx).

The state for each view is separated under `modules/{RouteName}/`.
## State

### Simple views
The state for each view is separated under `modules/{RouteName}/`.

Many views can keep all their mutable state local, and use `createStateContext`. All that is is a wrapper for the
common boilerplate of creating a context and wrapping `useContext` for it, along with disallowing usage outside the
`Context.Provider`.
Views keep most of their mutable state local and only use `createStateContext` for initial loading. All that is is a
wrapper for the common boilerplate of creating a context and wrapping `useContext` for it, along with disallowing usage
outside the `Context.Provider`.

In a typical view, the returned `useStateContext` hook is re-exported as `use{RouteName}Context`. Then, a
`use{RouteName}State` hook is created to perform initial data fetching, and the main view is wrapped in `Provider`.
Expand All @@ -60,64 +37,13 @@ export function SomeView({ children }) {
}
```

### Reducers

Views that need to modify state from the UI use reducers, just like with Redux. This is done via the
`createReducerContext` function.

`createReducerContext` takes the same arguments as `useReducer`: `reducer` and `initialState`. `initialState` is only
the reducer state. (Currently, there is no way to make the initial reducer state depend on props.)

In addition to _reducer state_, the design here also allows the view to augment the state with other data
(_external state_). The reducer and external states are merged, and the context provides the merged state along with
the reducer's dispatch function. If you add external state, you'll need to provide type arguments to
`createReducerContext`.

`createReducerContext` returns the following:
- `Context`: the raw React context that contains `[state, dispatch]`, where `state` is the merged state.
- `useStateAndDispatch`: a `useContext` wrapper for the context.
- `Provider`: a component that takes _external state_ and provides `Context`.
- `createThunk`: see below.

`createReducerContext` is used exactly like `createStateContext` above, but using the returned `Provider` instead of
`Context.Provider`. The `use{RouteName}State` hook may be omitted if only reducer-based state is used.

```tsx
type ReducerState = { /* ... */ };

type Actions = { type: 'SOME_ACTION' }; /* | ... */

const initialState: ReducerState = { /* ... */ };

type ExternalState = { /* ... */ };

function reducer(state: ReducerState, action: Actions): ReducerState {
/* ... */
}

const {
Provider, useStateAndDispatch, createThunk,
} = createReducerContext<ReducerState, Actions, ExternalState>(reducer, initialState);
export { Provider as SomeViewProvider, useStateAndDispatch as useSomeViewContext };

export function SomeView({ children }) {
const state = useSomeViewExternalState(); // just like useSomeViewState() above
return (
<SomeViewProvider state={state}>
<ActualViewComponent />{/* uses useSomeViewContext() */}
</SomeViewProvider>
);
}
```

### Thunk actions
## Thunk actions

For performing more complex state changes or e.g. API requests based on state, `createReducerContext` returns the
`createThunk` function. `createThunk` takes a function very similar to `redux-thunk` thunks, only the nested functions
are in reverse order:
For performing more e.g. API requests based on state, `createStateContext` returns the `createThunk` function.
`createThunk` takes a function similar to `redux-thunk` thunks, only the nested functions are in reverse order:

```ts
const useSomeAction = createThunk((state, dispatch) => (actionArg: string) => {
const useSomeAction = createThunk((state) => (actionArg: string) => {
// do stuff...
});
```
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
{
"name": "ilmomasiina",
"version": "2.0.0-alpha1",
"version": "2.0.0-alpha11",
"private": true,
"license": "MIT",
"engines": {
"node": "^14.19.0",
"npm": "^6.14.13"
"node": "^16",
"npm": "^7"
},
"scripts": {
"bootstrap": "pnpm install",
"clean": "pnpm run -r clean",
"build": "pnpm run -r build",
"build": "pnpm run -r --workspace-concurrency=1 build",
"start": "pnpm run -r --parallel start",
"lint": "eslint packages",
"lint:fix": "npm run lint -- --fix",
"typecheck": "pnpm run -r typecheck",
"typecheck": "pnpm run -r --workspace-concurrency=1 typecheck",
"test": "pnpm run -r test"
},
"repository": {
Expand Down
Loading

0 comments on commit 13010e6

Please sign in to comment.