This is a Next.js project bootstrapped with create-next-app.
Table of Contents
- Environments
- Service architecture
- Developing locally
- Setting up Husky pre-commit hooks:
- Available scripts
- Headless CMS React Component (HCRC) -library implementation
- Application configuration
- Course Search -parameters
- Learn more
Dev server: https://harrastukset.dev.hel.ninja
Test server: https://harrastukset.test.hel.ninja
Staging server: https://harrastukset.stage.hel.ninja
Production server: https://harrastukset.hel.fi
The CI deployment process goes like this:
- When a pull request (PR) is created, the PR related checks are being ran and a new server instance will be created. A link to that environment should be published in the comments of the PR when the service is ready. For example the browser tests are being ran against that server.
- When a pull request (PR) is merged (to the "main" branch), the dev-environment will be redeployed with the latest changes.
- When a new release is made, the test-environment and staging-environment will be redeployed with the latest changes.
- When a new release is approved, a production-environment will be (re)deployed
The service consists of:
- Hobbies-Helsinki: The NextJS / React application. Integrates to a supergraph of Events Feration Router that provides subgraphs to get dynamic CMS content and courses.
- Events Graphql Federation: Apollo Router to provide a supergraph.
- Headless CMS: Content Management Service that provides dynamic pages and dynamic content for the teachers' UI. It also provides content for the header and the footer. A React component library can be found from https://github.com/City-of-Helsinki/react-helsinki-headless-cms.
- LinkedEvents API: A city of Helsinki centralized API for events.
- Unified Search: Provide search service for venues.
- Palvelukartta / Servicemap: Provides details for venues. (NOTE: https://palvelukartta.hel.fi is used to fetch a map component. https://www.hel.fi/palvelukarttaws is used to fetch all the details)
- Digia Iiris: Web analytics (a Matomo service).
- Sentry: A monitoring service.
- Askem: A website feedback collection and analysis tool.
---
title: Graph Service Diagram
---
flowchart LR
subgraph Supergraph
ROUTER[**Events Graphql Federation**: *Apollo Router service to provide a supergraph*]
subgraph Subgraphs
EventsProxy["**Events GraphQL Proxy**: *A GraphQL-proxy for the LinkedEvents*"]
VenuesProxy["**Venues GraphQL Proxy**: *A GraphQL-proxy for the Palvelukartta/Servicemap / 'TPREK' Toimipaikkarekisteri*"]
CMS["**Headless CMS**: *App specific datasource for the dynamic page and articles content*"]
US["**Unified-Search**: *Elasticsearch-service for search results scoring*"]
end
end
subgraph ExternalGraph["External services"]
LinkedEvents
Wordpress["Wordpress (Headless CMS)"]
Servicemap["Palvelukartta / Servicemap *(palvelukarttaws)*"]
Sentry
DigiaIiris["DigiaIiris / Matomo"]
Askem
end
Application --> ROUTER
Application --> Sentry
Application --> DigiaIiris
Application --> Askem
ROUTER --> EventsProxy
ROUTER --> VenuesProxy
ROUTER --> CMS
ROUTER --> US
EventsProxy --> LinkedEvents
VenuesProxy --> Servicemap
CMS --> Wordpress
US --> Servicemap
- Test / Staging: https://harrastus.app-staging.hkih.hion.dev/graphql (Wordpress-admin)
- Production: https://harrastus.content.api.hel.fi/wp-admin/graphql (Wordpress-admin)
The pages are served with some server side rendering (SSR) mechanism to offer better search engine optimization (SEO) and fast user-friendly UI. The pre-rendering process that we mostly use here is SSG - "automatically generated as static HTML + JSON (uses getStaticProps)". The server side rendered pages are under the pages -directory. More about NextJS's data fetching in https://nextjs.org/docs/basic-features/data-fetching/overview.
There are some rules that a content manager must follow while maintaining the CMS:
- / (The root path / the front page): The root path is set as a landing page and it acts as a home page or the front page. The slug for this page must be an empty string, so the uri is
/in every language. - /search (The search page): The search page gets no content from the CMS, except the SEO information. The uri for this page must be
/searchin every language. It is translated to different languages with the i18nRoutes.config.json configuration file. - /article-archive (The article archive page): The article archive page gets the SEO content from the CMS and also makes some hardcoded article search queries there. The page is mostly rendered with the components that the HCRC-lib offers. The uri must be
/article-archivein every language. It is translated to different languages with the i18nRoutes.config.json configuration file. - /pages (The root for the dynamic pages): All the dynamic pages must be children of the
/pages-root-page. This strategy must be followed so that the application can internally handle the dynamic CMS pages. The path is translated to different languages with the i18nRoutes.config.json configuration file.
Run the development server:
yarn dev
# or
docker compose up
Open http://localhost:3000 with your browser to see the result.
- Run
yarn installandyarn postinstallon project root. - Try
git commit -m foo. It does not commit anything for real but pre-commit hook should be triggered.
Runs the application in development mode. Open http://localhost:3000 to view in the browser.
The page will reload if you make changes.
Builds the production application in the .next folder.
Production build can be run locally with yarn start.
Launches the test runner in the interactive watch mode.
Launches the Testcafe test runner for browser tests in the interactive watch mode. yarn test:browser:ci runs the command line tool without the graphical interface.
Runs the eslinter, a static code analysis tool to flag programming errors, bugs, stylistic errors and suspicious constructs.
Running the yarn lint-fix also fixes the automatically fixamble issues.
Transpiles the TypeScript code and reports the errors.
Uses the codegen tool to generate Graphql Schema file out from the graphql files inside the app. Note that the graphql.tsx inside the packages/components -directory contains the common types and hooks, so it would most likely make sense to copy the generated result and override the graphql file in the common components-package.
Hobbies uses a lot of the components from the HCRC-lib. For example the article and event carousels are rendered with the HCRC-lib components.
Apollo Link is designed from day one to be easy to use middleware on your requests. Middlewares are used to inspect and modify every request made over the link, for example, adding authentication tokens to every query. In order to add middleware, you simply create a new link and join it with the HttpLink. - https://www.apollographql.com/docs/react/v2/networking/network-layer/#middleware
There are 2 Apollo-clients in implemented: an Apollo-client for Headless CMS to fetch articles and dynamic pages from the CMS and an Apollo-client for LinkedEvents to fetch events from the Event-proxy. They both contains URL-fields that are targeted to an external source. Since the content is wanted to be rendered inside the Hobbies app, the URLs needs to be transformed so that they are pointing to an internal path. An Apollo Link is a create place to do the transformation, when the URL context is known. Therefore, the URL should include a hint of the context, e.g a context path like /articles* or /pages* or a domain e.g linkedvents.hel.fi.
The transformation table is in the AppConfig.ts :
class AppConfig {
// ...
static get linkedEventsEventEndpoint() {
return getEnvOrError(
publicRuntimeConfig.linkedEvents,
"LINKEDEVENTS_EVENT_ENDPOINT"
);
}
static get cmsArticlesContextPath() {
return process.env.NEXT_PUBLIC_CMS_ARTICLES_CONTEXT_PATH ?? "/articles";
}
static get cmsPagesContextPath() {
return process.env.NEXT_PUBLIC_CMS_PAGES_CONTEXT_PATH ?? "/pages";
}
static get URLRewriteMapping() {
return {
[AppConfig.linkedEventsEventEndpoint]: ROUTES.COURSES.replace(
"/[eventId]",
""
),
[`${AppConfig.cmsOrigin}[/fi|/en|/sv]*${AppConfig.cmsArticlesContextPath}`]:
ROUTES.ARTICLES.replace("/[...slug]", ""),
[`${AppConfig.cmsOrigin}[/fi|/en|/sv]*${AppConfig.cmsPagesContextPath}`]:
"/",
};
}
// ...
}The application configuration is done via the AppConfig.ts as much as possible, so there would be a single point to configure it.
NOTE: This section easily runs out-of-date.
The event search is always queried from the LinkedEvents API, through the Events GraphQL Proxy.
The LinkedEvents does not provide a GraphQL API by itself, which is the reason for the Events GraphQL Proxy. The GraphQL API follows different kind of naming rules as the basic REST JSON API, so the parameters needs some conversion. The conversion is done by the queryBuilder of the Events GraphQL Proxy.
The conversion map looks (something) like this:
[
{ key: "event_type", value: params.eventType },
{ key: "internet_based", value: params.internetBased },
{ key: "combined_text", value: params.combinedText },
{ key: "local_ongoing_AND", value: params.localOngoingAnd },
{ key: "local_ongoing_OR", value: params.localOngoingOr },
{ key: "local_ongoing_OR_set1", value: params.localOngoingOrSet1 },
{ key: "local_ongoing_OR_set2", value: params.localOngoingOrSet2 },
{ key: "local_ongoing_OR_set3", value: params.localOngoingOrSet3 },
{ key: "internet_ongoing_AND", value: params.internetOngoingAnd },
{ key: "internet_ongoing_OR", value: params.internetOngoingOr },
{ key: "all_ongoing", value: params.allOngoing },
{ key: "all_ongoing_AND", value: params.allOngoingAnd },
{ key: "all_ongoing_OR", value: params.allOngoingOr },
{ key: "division", value: params.division },
{ key: "end", value: params.end },
{ key: "ends_after", value: params.endsAfter },
{ key: "ends_before", value: params.endsBefore },
{ key: "include", value: params.include },
{ key: "in_language", value: params.inLanguage },
{ key: "is_free", value: params.isFree },
{ key: "keyword", value: params.keyword },
{ key: "keyword_AND", value: params.keywordAnd },
{ key: "keyword_OR_set1", value: params.keywordOrSet1 },
{ key: "keyword_OR_set2", value: params.keywordOrSet2 },
{ key: "keyword_OR_set3", value: params.keywordOrSet3 },
{ key: "keyword!", value: params.keywordNot },
{ key: "language", value: params.language },
{ key: "location", value: params.location },
{ key: "page", value: params.page },
{ key: "page_size", value: params.pageSize },
{ key: "publisher", value: params.publisher },
{ key: "publisher_ancestor", value: params.publisherAncestor },
{ key: "sort", value: params.sort },
{ key: "start", value: params.start },
{ key: "starts_after", value: params.startsAfter },
{ key: "starts_before", value: params.startsBefore },
{ key: "super_event", value: params.superEvent },
{ key: "super_event_type", value: params.superEventType },
{ key: "text", value: params.text },
{ key: "translation", value: params.translation },
{ key: "audience_min_age_lt", value: params.audienceMinAgeLt },
{ key: "audience_min_age_gt", value: params.audienceMinAgeGt },
{ key: "audience_max_age_lt", value: params.audienceMaxAgeLt },
{ key: "audience_max_age_gt", value: params.audienceMaxAgeGt },
{ key: "suitable_for", value: params.suitableFor },
{ key: "ids", value: params.ids },
{ key: "full_text", value: params.fullText },
{ key: "full_text_language", value: params.fullTextLanguage },
{ key: "ongoing", value: params.ongoing },
];The "main events search" component is used in the search page at /search.
| parameter | default value | controllable | required | description |
|---|---|---|---|---|
| eventType | ["Course"] | - | x | Select only "general" type events |
| include | ['keywords', 'location', 'registration'] | - | x | Fetch related data from datasource |
| pageSize | 25 | - | x | How many items are fetched per page |
| division | "ocd-division/country:fi/kunta:helsinki" | - | x | Only city of Helsinki events |
| ongoing | true | - | x | include the ongoing events |
| fullText | "" | x | x | A text scoring mathcher (title, place, description, ...) |
| fullTextLanguage | ["fi", "en", "sv"] | - | - | Languages for full text search |
| sort | "end_time" | x | x | Sorting order of the result set. Default is "event end time as ascending" |
| start | "now" | x | x | Filter by event starting time |
| end | null | x | - | Filter by event ending time |
| startsAfter | "" | x | - | Used for "Only evening events", when it will be set to "16" |
| language | "" | - | - | NOTE: currently always empty to include events in all languages |
| location | [] | x | - | Filter by location / venue / place (source: LinkedEvents) |
| keywordAnd | [] | x | - | Used for "Only children events" when it will be set to "yso:p4354" |
| keywordOrSet2 | [] | x | - | A list of keywords (e.g categories or premapped categories) |
| keywordNot | [] | x (URL only) | - | Exclude events with these keywords. Converted to "keyword!" when querying LinkedEvents. |
| publisher | "" | x | - | Used to find events of 1 specific organisation |
| publisherAncestor | null | x | - | Filter events by publisherAncestor set to "ahjo:00001" if filtering city of Helsinki events |
| isFree | "" | x | - | Used for "Show only free events" (when it will be set to "true") |
| internetBased | "" | x | - | Used for "Show only remote events" (when it will be set to "true") |
| superEventType | "" | - | - | Filter by super event types. E.g ['umbrella', 'none'] excludes "recurring" |
| superEvent | "none" | - | - | Filter by a specific (super) event id or set to "none" for events without superevent. |
| audienceMaxAgeGt | "" | x | - | Filter by audience age: "max age greater than". Used by the age selector. |
| audienceMinAgeLt | "" | x | - | Filter by audience age: "min age less than". Used by the age selector. |
| suitableFor | "" | ? | - | **WARNING: ** suitableFor is fixed to "$audienceMinAgeLt, audienceMaxAgeGt" * |
*) TODO: Use suitableFor or audienceMaxAgeGt & audienceMinAgeLt, but not both together.
See the parameters conversion to understand how the LinkedEvenst queries are made.
INFO: To compare the above table to the code (e.g. in case it needs to be updated), see events app's SearchPage. The data is fetched with the
useEventListQueryhook.
The "similar events" component is used in the event details page to find related events.
The used search parameters are the same as in main event search, except
- the page size (
pageSize) is set to100. - category keywords are read from the event which details are shown and set to
keyword-parameter. - sorting is always by event's end time (ascending)
audienceMinAgeLtandaudienceMaxAgeGtare copied from the (related) event.
The "sub events" component is used in the event details page to find sub events if the event is super event for some other events.
The parameters being used are:
| parameter | default value | description |
|---|---|---|
| eventType | ["Course"] | Select only "general" type events |
| include | ["in_language", "keywords", "location", "audience"] | Fetch related data from datasource |
| sort | "end_time" | Sorting order of the result set. Default is "event end time as ascending" |
| start | "now" | Filter by event starting time |
| superEvent | the event id of the super event | Filter by super event id |
See the parameters conversion to understand how the LinkedEvenst queries are made.
You can learn more in the NextJs documentation.
To learn React, check out the React documentation.