Skip to content

Commit

Permalink
feat: replace compile constants with ini files
Browse files Browse the repository at this point in the history
* BREAKING_CHANGE: add ini files for app config sources definitions instead of using compile time env variables

* feat: improved app and root components, updated to functional components

* fix: use react hooks for navigation and better UI

* fix: improve error and demo dialog UI/UX

* fix: better error for bad settings, remove netlify refs

* fix: limit demo UI to only work with config IDs
  • Loading branch information
dbudzins authored Dec 1, 2022
1 parent 1e41b0b commit 158079d
Show file tree
Hide file tree
Showing 55 changed files with 823 additions and 452 deletions.
1 change: 0 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=0
APP_API_BASE_URL=https://cdn.jwplayer.com
4 changes: 1 addition & 3 deletions .env.demo
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
APP_CONFIG_DEFAULT_SOURCE=
APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=1
APP_DEMO_DEFAULT_CONFIG_ID=225tvq1i
APP_DEMO_FALLBACK_CONFIG_ID=225tvq1i
3 changes: 0 additions & 3 deletions .env.dev

This file was deleted.

2 changes: 0 additions & 2 deletions .env.jwdev
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
APP_CONFIG_DEFAULT_SOURCE=uzcyv8xh
APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=1
APP_API_BASE_URL=https://content-portal.jwplatform.com
4 changes: 0 additions & 4 deletions .env.test

This file was deleted.

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ firebase-debug.log
.idea
.DS_Store
.vscode/
# Exclude ini files because they have customer specific data
ini/*.ini
32 changes: 13 additions & 19 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,28 @@
# Configuration

JW OTT Webapp uses a JSON configuration file to store all configuration parameters. This file can be located at the following location: [`./public/config.json`](public/config.json).
The JW OTT Webapp is designed to consume a json configuration file served by the [JWP Delivery API](https://docs.jwplayer.com/platform/reference/get_apps-configs-config-id-json).
The easiest way to maintain configuration files is to use the 'Apps' section in your [JWP Dashboard account](https://dashboard.jwplayer.com/).

## Dynamic Configuration File Sources
## Configuration File Source

Using environment variables for build parameters, you can adjust what config file the application loads at startup and which, if any, it will allow to be set using the `app-config=<config source>` query param. The location can be specified using either the 8-character ID of the config from the JW Player dashboard (i.e. `gnnuzabk`), in which case the file will be loaded from the JW Player App Config delivery endpoint, or a relative (i.e. `/config.json`) or absolute (i.e. `https://cdn.jwplayer.com/apps/configs/gnnuzabk.json`) path, in which case the file will be loaded using fetch to make a 'get' request.
Which app config file the application uses is determined by the [ini file](initialization-file.md).

As mentioned above, if you have 1 or more allowed sources (see [`APP_CONFIG_ALLOWED_SOURCES`](#configuration-file-source-build-params) below), you can switch between them using the `app-config` (or `c`) query parameter when you first navigate to the web app. The parameter is automatically evaluated, loaded, and stored in session storage and should remain part of the url as the user navigates around the site.
You can specify the default that the application starts with and also which config, if any, it will allow to be set using the [`app-config=<config source>` query param](#switching-between-app-configs).
The location is usually specified by the 8-character ID (i.e. `gnnuzabk`) of the App Config from your JWP account, in which case the file will be loaded from the JW Player App Config delivery endpoint (i.e. `https://cdn.jwplayer.com/apps/configs/gnnuzabk.json`).
You may also specify a relative or absolute URL.

Note: to clear the value from local storage and return to the default, you can navigate to the site with the query parameter but leaving the value blank (i.e. `https://<your domain>?app-config=`)
### Switching between app configs

You can also tell the application to allow any config source location (see [`APP_UNSAFE_ALLOW_DYNAMIC_CONFIG`](#configuration-file-source-build-params) below), but this is a potential vulnerability, since any user could then pass in any valid config file, completely changing the content of the application on your domain. It is recommended to limit this 'unsafe' option to dev, testing, demo environments, etc.
As mentioned above, if you have 1 or more additional allowed sources (see additionalAllowedConfigSources in [`initialization-file`](initialization-file.md)), you can switch between them by adding `app-config=<config source>` as a query parameter in the web app URL in your browser (i.e. `https://<your domain>/?app-config=gnnuzabk`.)

### Configuration File Source Build Params
The parameter is automatically evaluated, loaded, and stored in browser session storage and should remain part of the url as the user navigates around the site.

**APP_CONFIG_DEFAULT_SOURCE**
>*Note: Be aware that this mechanism only sets the config for the local machine, browser, and session that you are accessing the site with and it does not change the default hosted app for other users.*
The ID or url path for the config that the web app will initially load with. Be careful to ensure that this config is always available or your app will fail to load.
Even sharing URL's should work as long as the query parameter of the desired config is part of the URL. However, once the query parameter is removed and the stored value in the session is released, the application will revert to loading the default config source.

---

**APP_CONFIG_ALLOWED_SOURCES**

A **space** separated list of 8-character IDs and/or url paths for config files that can be set using the `app-config=<config source>` query param. You can mix ID's and paths as long as they are space separated. You do not need to add the value of `APP_CONFIG_DEFAULT_SOURCE` to this property.

---

**APP_UNSAFE_ALLOW_DYNAMIC_CONFIG** - boolean flag which if true, enables any config ID or path to be specified with the `app-config=<config source>` query param
>*Note: to clear the value from session storage and return to the default, you can navigate to the site with a blank query parameter value (i.e. `?app-config=`)*
**Warning** - Generally the `APP_UNSAFE_ALLOW_DYNAMIC_CONFIG` option should only be used for dev and test, because it opens up your application so that anyone can specify their own config to run on your domain

## Available Configuration Parameters

Expand Down
28 changes: 28 additions & 0 deletions docs/initialization-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Initialization (ini) File

The JW OTT Web App loads a small initialization (.ini) file at startup. This file provides a mechanism to set key parameters without modifying the source code.
Template ini files are included in the repo and with the pre-compiled production release builds ([.webapp.prod.ini](/ini/templates/.webapp.prod.ini)).
Make sure you include a copy of the ini file edited to include your account data at `/public/.webapp.ini` for the application to load correctly.

For all manual builds (`yarn start` or `yarn build`), the ini file is copied from `/ini/.webapp.<mode>.ini` to `build/public/.webapp.ini`, which the application fetches and parses at startup.
If a file doesn't exist in /ini/.webapp.<mode>.ini, then the template file will first be copied from [`/ini/templates`](/ini/templates).
All of the .ini files inside of /ini are ignored in git, so you can create your own files locally to run the application with your account parameters without creating conflicts with committed code or leaking your details into source control.

## Ini Parameters

### defaultConfigSource

The 8 character ID of the app config from your JWP account (or the url path) that the web app will use to load its content. Be careful to ensure that this config is always available or your app will fail to load.

> Note: you probably always want to include a default config source for any production deployment. If there are no valid config sources the application will throw an error at startup.
### additionalAllowedConfigSources[]

An array of of 8-character IDs (entered 1 per line) for app configs in your JWP account (or url paths) that can be used with the [`app-config=<config source>` query param](configuration.md#switching-between-app-configs).
This may be useful for example if you have a staging or experimental config that you want to be able to test on your site using the [`app-config` query parameter](configuration.md#switching-between-app-configs) without changing the default config that the application loads with for all of your users.
See [.webapp.test.ini](/ini/templates/.webapp.test.ini) for an example.

### UNSAFE_allowAnyConfigSource

Boolean flag which if true, enables **ANY** 8-character app config ID (or path) to be specified with the [`app-config=<config source>` query param](configuration.md#switching-between-app-configs).

>***Warning:** Generally the `UNSAFE_allowAnyConfigSource` option should only be used for dev, test, or demo deployments, because it will allow anyone to create URL's that specify any config to be displayed on your domain.*
1 change: 0 additions & 1 deletion firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"public": "build/public",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
Expand Down
4 changes: 4 additions & 0 deletions ini/templates/.webapp.demo.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
; The demo app should load with the config prompt screen
defaultConfigSource =
; Allow any config in demo mode (this should only really be run on JWP's demo site at https://app-preview.jwplayer.com/)
UNSAFE_allowAnyConfigSource = true
4 changes: 4 additions & 0 deletions ini/templates/.webapp.dev.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
; This is a basic Blender demo
defaultConfigSource = gnnuzabk
; When developing, switching between configs is useful for test and debug
UNSAFE_allowAnyConfigSource = true
4 changes: 4 additions & 0 deletions ini/templates/.webapp.jwdev.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
; jwdev mode runs in the JWP internal dev environment, so this is an app config hosted there
defaultConfigSource = uzcyv8xh
; When developing, switching between configs is useful for test and debug
UNSAFE_allowAnyConfigSource = true
11 changes: 11 additions & 0 deletions ini/templates/.webapp.prod.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
; This is the app config that your application will load at startup
defaultConfigSource = ; Enter your 8 digit config ID (i.e. gnnuzabk) here (case sensitive)

;********************************************************************************************
; If you want to support app configs other than the default with the app-config= query param
; add them individually one per line here.
; See .webapp.test.ini for an example.
;********************************************************************************************
;additionalAllowedConfigSources[] =
;additionalAllowedConfigSources[] =
;additionalAllowedConfigSources[] =
6 changes: 6 additions & 0 deletions ini/templates/.webapp.test.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
defaultConfigSource = gnnuzabk
additionalAllowedConfigSources[] = kujzeu1b
additionalAllowedConfigSources[] = ata6ucb8
additionalAllowedConfigSources[] = nvqkufhy
additionalAllowedConfigSources[] = 7weyqrua
additionalAllowedConfigSources[] = ozylzc5m
4 changes: 0 additions & 4 deletions netlify.toml

This file was deleted.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"dompurify": "^2.3.8",
"i18next": "^20.3.1",
"i18next-browser-languagedetector": "^6.1.1",
"ini": "^3.0.1",
"jwt-decode": "^3.1.2",
"lodash.merge": "^4.6.2",
"marked": "^4.1.1",
Expand All @@ -67,6 +68,7 @@
"@testing-library/react": "^11.2.6",
"@testing-library/react-hooks": "^8.0.1",
"@types/dompurify": "^2.3.4",
"@types/ini": "^1.3.31",
"@types/jwplayer": "^8.2.7",
"@types/lodash.merge": "^4.6.6",
"@types/luxon": "^3.0.2",
Expand Down Expand Up @@ -135,4 +137,4 @@
"codeceptjs/**/ansi-regex": "^4.1.1",
"codeceptjs/**/minimatch": "^3.0.5"
}
}
}
108 changes: 33 additions & 75 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,92 +1,50 @@
import React, { Component } from 'react';
import { getI18n, I18nextProvider } from 'react-i18next';
import React, { useEffect, useState } from 'react';
import { BrowserRouter, HashRouter } from 'react-router-dom';

import type { Config } from '#types/Config';
import Router from '#src/containers/Router/Router';
import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay';
import QueryProvider from '#src/providers/QueryProvider';
import { restoreWatchHistory } from '#src/stores/WatchHistoryController';
import { initializeAccount } from '#src/stores/AccountController';
import { initializeFavorites } from '#src/stores/FavoritesController';
import { logDev } from '#src/utils/common';
import { loadAndValidateConfig } from '#src/utils/configLoad';
import { clearStoredConfig } from '#src/utils/configOverride';
import { PersonalShelf } from '#src/enum/PersonalShelf';
import initI18n from '#src/i18n/config';

import '#src/screenMapping';
import '#src/styles/main.scss';
import initI18n from '#src/i18n/config';
import Root from '#components/Root/Root';
import { ErrorPageWithoutTranslation } from '#components/ErrorPage/ErrorPage';
import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay';

interface State {
error: Error | null;
isLoading: boolean;
error?: Error;
}

class App extends Component {
public state: State = {
error: null,
isLoading: true,
};
export default function App() {
const [i18nState, seti18nState] = useState<State>({ isLoading: true });

componentDidCatch(error: Error) {
this.setState({ error });
}

async initializeServices(config: Config) {
if (config?.integrations?.cleeng?.id) {
await initializeAccount();
}

// We only request favorites and continue_watching data if there is a corresponding item in the content section
// and a playlist in the features section.
// We first initialize the account otherwise if we have favorites saved as externalData and in a local storage the sections may blink
if (config.features?.continueWatchingList && config.content.some((el) => el.type === PersonalShelf.ContinueWatching)) {
await restoreWatchHistory();
}
useEffect(() => {
initI18n()
.then(() => seti18nState({ isLoading: false }))
.catch((e) => seti18nState({ isLoading: false, error: e as Error }));
}, []);

if (config.features?.favoritesList && config.content.some((el) => el.type === PersonalShelf.Favorites)) {
await initializeFavorites();
}
if (i18nState.isLoading) {
return <LoadingOverlay />;
}

configLoadingHandler = (isLoading: boolean) => {
this.setState({ isLoading });
logDev(`Loading config: ${isLoading}`);
};

configErrorHandler = (error: Error) => {
clearStoredConfig();

this.setState({ error });
this.setState({ isLoading: false });
logDev('Error while loading the config:', error);
};

configValidationCompletedHandler = async (config: Config) => {
await this.initializeServices(config);
this.setState({ isLoading: false });
};

async componentDidMount() {
await initI18n();
await loadAndValidateConfig(this.configLoadingHandler, this.configErrorHandler, this.configValidationCompletedHandler);
}

render() {
const { isLoading, error } = this.state;

if (isLoading) {
return <LoadingOverlay />;
}

if (i18nState.error) {
// Don't be tempted to translate these strings. If i18n fails to load, translations won't work anyhow
return (
<I18nextProvider i18n={getI18n()}>
<QueryProvider>
<Router error={error} />
</QueryProvider>
</I18nextProvider>
<ErrorPageWithoutTranslation
title={'Unable to load translations'}
message={'Check your language settings and try again later. If the problem persists contact technical support.'}
error={i18nState.error}
/>
);
}
}

export default App;
const Router = import.meta.env.APP_PUBLIC_GITHUB_PAGES ? HashRouter : BrowserRouter;

return (
<QueryProvider>
<Router>
<Root />
</Router>
</QueryProvider>
);
}
6 changes: 0 additions & 6 deletions src/assets/logo.svg

This file was deleted.

2 changes: 1 addition & 1 deletion src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { NavLink } from 'react-router-dom';

import styles from './Button.module.scss';

import Spinner from '#src/components/Spinner/Spinner';
import Spinner from '#components/Spinner/Spinner';

type Color = 'default' | 'primary';

Expand Down
11 changes: 8 additions & 3 deletions src/components/DemoConfigDialog/DemoConfigDialog.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
}
}

.maxWidth {
max-width: 500px;
}

.configModal {
position: absolute;
top: 0;
Expand All @@ -17,17 +21,18 @@
background: var(--body-background-color);

p {
max-width: 400px;
max-width: 500px;
margin-bottom: 0;
color: theme.$text-field-resting-color;
font-size: 14px;
line-height: 18px;
}

a {
color: theme.$text-field-resting-color;
a, a:visited, a:hover, a:active {
color: inherit;
font-weight: var(--body-font-weight-bold);
text-decoration: underline;
cursor: pointer;
}
}

Expand Down
8 changes: 5 additions & 3 deletions src/components/DemoConfigDialog/DemoConfigDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import React from 'react';
import type { UseQueryResult } from 'react-query';

import { renderWithRouter } from '#test/testUtils';
import DemoConfigDialog from '#components/DemoConfigDialog/DemoConfigDialog';
import type { Config } from '#types/Config';

describe('<DemoConfigDialog>', () => {
test('renders and matches snapshot', () => {
const { container } = renderWithRouter(<DemoConfigDialog configLocation={''} />);
const { container } = renderWithRouter(<DemoConfigDialog configQuery={{ isSuccess: true } as UseQueryResult<Config>} selectedConfigSource={'abcdefgh'} />);

expect(container).toMatchSnapshot();
});

test('renders and matches snapshot with ID', () => {
const { container } = renderWithRouter(<DemoConfigDialog configLocation={'gnnuzabk'} />);
test('renders and matches snapshot error dialog', () => {
const { container } = renderWithRouter(<DemoConfigDialog configQuery={{ isSuccess: false } as UseQueryResult<Config>} selectedConfigSource={'aaaaaaaa'} />);

expect(container).toMatchSnapshot();
});
Expand Down
Loading

0 comments on commit 158079d

Please sign in to comment.