Skip to content

Commit

Permalink
Merge pull request #1338 from entur/sandbox-features
Browse files Browse the repository at this point in the history
Sandbox features
  • Loading branch information
testower authored May 7, 2024
2 parents a52cb01 + 38deaf8 commit 5f250ce
Show file tree
Hide file tree
Showing 12 changed files with 397 additions and 93 deletions.
77 changes: 1 addition & 76 deletions src/config/ConfigContext.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,5 @@
import { FlexibleLineType } from 'model/FlexibleLine';
import { OidcClientSettings } from 'oidc-client-ts';
import { createContext, useContext } from 'react';

import { Locale } from '../i18n/locale';

export interface Config {
/**
* Base URL for backend GraphQL API (a.k.a. uttu)
*
* Note that uttu has multiple GraphQL endpoints, one for each
* provider and additionally one for the providers API.
*/
uttuApiUrl?: string;

/**
* Configure OpenID Connect for authenticating users with a compliant authentication provider
*
* {@see https://authts.github.io/oidc-client-ts/interfaces/OidcClientSettings.html}
*/
oidcConfig?: OidcClientSettings;

/**
* Prefix used in XML namespace for providers in exported datasets
*/
xmlnsUrlPrefix?: string;

/**
* Enables Entur specific legacy bevaior for filtering authorities and operators.
* {@see ../model/Organisation.ts}
*/
enableLegacyOrganisationsFilter?: boolean;

/**
* Optionally restrict available flexible line types available for users to choose from when
* creating flexible lines.
*/
supportedFlexibleLineTypes?: FlexibleLineType[];

/**
* The exact shape of the admin role used to match against role claims in token, toggles
* visibility of providers admin menu option.
*
* This is technical debt, and will be moved to the backend
*/
adminRole?: string;

/**
* Namespace for preferred name in tokens
*
* This is technical debt, and will be moved to the backend
*/
preferredNameNamespace?: string;

/**
* Namespace for claims in tokens
*
* This is technical debt, and will be moved to the backend
*/
claimsNamespace?: string;

/**
* Optional DSN for sentry configuration. If not present, Sentry will not be configured
* {@see https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/}
*/
sentryDSN?: string;

/**
* Default locale to use for translations and formatting
*/
defaultLocale?: Locale;

/**
* Optionally restrict the choice of locales to the user
*/
supportedLocales?: Locale[];
}
import { Config } from './config';

export const ConfigContext = createContext<Config>({});

Expand Down
86 changes: 86 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { OidcClientSettings } from 'oidc-client-ts';
import { FlexibleLineType } from '../model/FlexibleLine';
import { Locale } from '../i18n';

/**
* All sandbox features should be added to this interface like this:
* - featureName: boolean;
*
* For multi-level features, only the top-level featureName should be
* toggled.
*/
export interface SandboxFeatures {}

export interface Config {
/**
* Base URL for backend GraphQL API (a.k.a. uttu)
*
* Note that uttu has multiple GraphQL endpoints, one for each
* provider and additionally one for the providers API.
*/
uttuApiUrl?: string;

/**
* Configure OpenID Connect for authenticating users with a compliant authentication provider
*
* {@see https://authts.github.io/oidc-client-ts/interfaces/OidcClientSettings.html}
*/
oidcConfig?: OidcClientSettings;

/**
* Prefix used in XML namespace for providers in exported datasets
*/
xmlnsUrlPrefix?: string;

/**
* Enables Entur specific legacy bevaior for filtering authorities and operators.
* {@see ../model/Organisation.ts}
*/
enableLegacyOrganisationsFilter?: boolean;

/**
* Optionally restrict available flexible line types available for users to choose from when
* creating flexible lines.
*/
supportedFlexibleLineTypes?: FlexibleLineType[];

/**
* The exact shape of the admin role used to match against role claims in token, toggles
* visibility of providers admin menu option.
*
* This is technical debt, and will be moved to the backend
*/
adminRole?: string;

/**
* Namespace for preferred name in tokens
*
* This is technical debt, and will be moved to the backend
*/
preferredNameNamespace?: string;

/**
* Namespace for claims in tokens
*
* This is technical debt, and will be moved to the backend
*/
claimsNamespace?: string;

/**
* Optional DSN for sentry configuration. If not present, Sentry will not be configured
* {@see https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/}
*/
sentryDSN?: string;

/**
* Default locale to use for translations and formatting
*/
defaultLocale?: Locale;

/**
* Optionally restrict the choice of locales to the user
*/
supportedLocales?: Locale[];

sandboxFeatures?: SandboxFeatures;
}
3 changes: 2 additions & 1 deletion src/config/configSlice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from 'store/store';
import { Config } from 'config/ConfigContext';

import { Config } from './config';

export interface ConfigState extends Config {
loaded: boolean;
Expand Down
3 changes: 2 additions & 1 deletion src/config/fetchConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FlexibleLineType } from 'model/FlexibleLine';
import { Config } from './ConfigContext';

import { Config } from './config';

const defaultConfig: Config = {
supportedFlexibleLineTypes: Object.values(FlexibleLineType),
Expand Down
114 changes: 114 additions & 0 deletions src/ext/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Sandbox features in enki

Sandbox features allows development of new functionality with less risk and without affecting
other installations. Sandbox features are controlled by feature flags. The SandboxFeature component
is designed to support code splitting, so that each feature will be compiled into separate chunks. React
will then postpone the downloading of any given chunk until it decides it's time to render the component
inside.

## How to develop a sandbox feature

Sandbox features are placed in a folder with the same name as the feature. The feature name should be added
to the SandboxFeatures interface.

The folder should have an index.ts, with a default export. The default
export should be the main entry (React) component of your sandbox feature.

Example with a feature called `foobar`:

// ext/foobar/index.ts
const Foobar: SandboxComponent<FoobarProps> = (props) => {
return (
<h1>{props.foo}</h1>
)
};

export default Foobar;

The folder must also have
a types.d.ts file which exports the props type declaration for your component.

// ext/foobar/types.d.ts
export type FoobarProps extends SandboxFeatureProps = {
foo: string;
}

This ensures type safety across the SandboxFeature wrapper without having an explicit dependency
to your component's runtime code.

To use your sandbox feature in the main code, you'll use the SandboxFeature component
to wrap it:

<SandboxFeature<FoobarProps>
feature="foobar"
foo="bar"
/>

If "foobar" is `false` in your feature flags configuration, this will not render anything.
If "foobar" is `true` it will render:

<h1>bar</1>

A `renderFallback` function prop is also available to give the option to render something else
if the feature is not enabled:

<SandboxFeature<FoobarProps>
feature="foobar"
foo="bar"
renderFallback={() => <h1>foo</h1>}
/>

will render

<h1>foo</h1>

if feature `foobar` is not enabled.

## How features are controlled by configuration

First of all, you must add each feature to the `SandboxFeatures` interface in `../config/config.ts`:

interface SandboxFeatures {
foobar: boolean;
}

The `sandboxFeatures` property of the bootstrap configuration controls each individual feature. By default,
all features are turned off, and must be explicitly set to be enabled:

{
"sandboxFeatures": {
"foobar": true
}
}

## Nested features

`SandboxFeature` supports nesting features 2 levels deep. Meaning, you can group several features into one
mega-feature, and configure them as one. They will also be chunked together as one file.

Example, given the following folder structure:

// ext/foobar/foo/
// ext/foobar/bar/

And the following feature definition:

foobar: boolean;

and configuration setting:

foobar: true

You can reference each sub-level feature as follows:

<SandboxFeature<FoobarProps>
feature="foobar/foo"
foo="bar"
/>

and

<SandboxFeature<FoobarProps>
feature="foobar/bar"
bar="foo"
/>
77 changes: 77 additions & 0 deletions src/ext/SandboxFeature.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { FC, lazy, memo, ReactNode, Suspense, useMemo } from 'react';
import { useConfig } from '../config/ConfigContext';
import { SandboxFeatures } from '../config/config';

/**
* The base props interface for the SandboxFeature component. It is a generic interface that
* takes a single type parameter `Features`, and has a single property `feature` which can
* have the value of any key of `Features`.
*/
export interface SandboxFeatureProps<Features> {
feature: keyof Features;
renderFallback?: () => ReactNode;
}

/**
* A type that describes a sandbox component which can be wrapped with the SandboxFeature component.
* It is a generic type which takes two type parameters: `Features` and `Props`. The `Props` type
* should implement the SandboxFeatureProps interface. The resulting type is a Functional Component
* with `Props` without "feature".
*/
export type SandboxComponent<
Features,
Props extends SandboxFeatureProps<Features>,
> = FC<Omit<Props, 'feature' | 'renderFallback'>>;

/**
* A component that can load a sandbox Component. It is a generic component with the same type
* parameters as the SandboxComponent. It optionally and lazily renders the SandboxComponent
* identified by through the `feature` prop, which identifies the path of the component to load.
*/
const SandboxFeature = <Features, Props extends SandboxFeatureProps<Features>>({
feature,
renderFallback,
...props
}: Props) => {
const { sandboxFeatures } = useConfig();

const splitFeature = (feature as string).split('/');

let Component: SandboxComponent<Features, Props>;

if (splitFeature.length > 2) {
throw new Error('Max feature depth is 2');
} else if (splitFeature.length === 2) {
Component = memo(
lazy(() => import(`./${splitFeature[0]}/${splitFeature[1]}/index.ts`)),
);
} else {
Component = memo(lazy(() => import(`./${splitFeature[0]}/index.ts`)));
}

const featureEnabled = useMemo(
() =>
sandboxFeatures &&
Object.entries(sandboxFeatures).some(([key, value]) => {
return key.split('/')[0] === splitFeature[0] && value;
}),
[sandboxFeatures, splitFeature],
);

return (
<Suspense>
{featureEnabled ? (
<Component {...props} />
) : renderFallback ? (
renderFallback()
) : null}
</Suspense>
);
};

export default memo(SandboxFeature) as typeof SandboxFeature<
SandboxFeatures,
any
>;

export const TestSandboxFeature = memo(SandboxFeature) as typeof SandboxFeature;
Loading

0 comments on commit 5f250ce

Please sign in to comment.