Skip to content

Commit

Permalink
Merge pull request #273 from open-formulieren/issue/2253-language-sel…
Browse files Browse the repository at this point in the history
…ection-component

[#2253] Language Selection Component
  • Loading branch information
sergei-maertens authored Nov 10, 2022
2 parents 961e987 + ca0727e commit 33c6e82
Show file tree
Hide file tree
Showing 28 changed files with 856 additions and 57 deletions.
3 changes: 2 additions & 1 deletion .storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"storybook-addon-themes",
"storybook-addon-mock",
"storybook-react-intl"
],
"framework": "@storybook/react",
Expand Down Expand Up @@ -47,7 +48,7 @@ module.exports = {
const oneOfRule = craConfig.module.rules.find(rule => rule.oneOf != null);
const sassRule = oneOfRule.oneOf.find(rule => String(rule.test) === String(sassRegex));
const ejsLoader = oneOfRule.oneOf.find(rule => rule.loader === 'ejs-loader');
const mergedRules = [sassRule,
const mergedRules = [sassRule,
{
...ejsLoader,
// Exclude Storybook internal .ejs templates
Expand Down
8 changes: 6 additions & 2 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { Formio, Templates } from 'react-formio';

import OpenFormsModule from 'formio/module';
import OFLibrary from 'formio/templates';

import '@gemeente-denhaag/design-tokens-components/dist/theme/index.css';
import '@utrecht/design-tokens/dist/index.css';
import 'styles.scss';

// Include NL Design System component in Storybook only, until migration is complete
import 'scss/nl-design-system-community.scss';

// load these AFTER the community styles, which is closer in simulating the CSS loading
// order of our own components
import 'styles.scss';

import {reactIntl} from './reactIntl.js';

export const parameters = {
Expand All @@ -34,7 +38,7 @@ export const parameters = {
{ name: 'Gemeente Utrecht', class: 'utrecht-theme', color: '#cc0000' }
],
},
}
};

// Use our custom Form.io components
Formio.use(OpenFormsModule);
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"dependencies": {
"@formio/protected-eval": "^1.2.1",
"@fortawesome/fontawesome-free": "^6.1.1",
"@open-formulieren/design-tokens": "^0.12.0",
"@open-formulieren/design-tokens": "^0.13.0",
"@sentry/react": "^6.13.2",
"@sentry/tracing": "^6.13.2",
"classnames": "^2.3.1",
Expand Down Expand Up @@ -110,6 +110,7 @@
"@storybook/addon-interactions": "^6.5.9",
"@storybook/addon-links": "^6.5.9",
"@storybook/builder-webpack5": "^6.5.9",
"@storybook/jest": "^0.0.10",
"@storybook/manager-webpack5": "^6.5.9",
"@storybook/react": "^6.5.9",
"@storybook/testing-library": "^0.0.13",
Expand All @@ -118,7 +119,7 @@
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@utrecht/component-library-css": "^1.0.0-alpha.343",
"@utrecht/component-library-css": "^1.0.0-alpha.373",
"@utrecht/component-library-react": "^1.0.0-alpha.152",
"@utrecht/components": "^1.0.0-alpha.304",
"@utrecht/design-tokens": "^1.0.0-alpha.336",
Expand Down Expand Up @@ -168,6 +169,7 @@
"sass-loader": "^12.3.0",
"semver": "^7.3.5",
"source-map-loader": "^3.0.0",
"storybook-addon-mock": "^3.2.0",
"storybook-addon-themes": "^6.1.0",
"storybook-react-intl": "^1.1.1",
"style-loader": "^3.3.1",
Expand Down
26 changes: 23 additions & 3 deletions src/components/App.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,55 @@
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import {Switch, Route} from 'react-router-dom';

import Form from 'components/Form';
import { Layout, LayoutRow } from 'components/Layout';
import ManageAppointment from 'components/appointments/ManageAppointment';
import LanguageSelection from 'components/LanguageSelection';


const LanguageSwitcher = ({ target = null }) => (
target ? (
ReactDOM.createPortal(<LanguageSelection />, target)
) : (
<LayoutRow>
<LanguageSelection />
</LayoutRow>
)
);

LanguageSwitcher.propTypes = {
target: PropTypes.instanceOf(Element),
};

/*
Top level router - routing between an actual form or supporting screens.
*/
const App = (props) => {
const App = ({ languageSelectorTarget, ...props }) => {
return (
<Layout>
<LanguageSwitcher target={languageSelectorTarget} />

<LayoutRow>

<Switch>

{/* Anything dealing with appointments gets routed to it's own sub-router */}
<Route path="/afspraak*" component={ManageAppointment} />

{/* All the rest goes to the actual form flow */}
<Route path="/">
<Form {...props} />
</Route>

</Switch>

</LayoutRow>
</Layout>
);
};

App.propTypes = {
languageSelectorTarget: PropTypes.instanceOf(Element),
};

export default App;
34 changes: 21 additions & 13 deletions src/components/ErrorBoundary.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import { Link } from 'react-router-dom';

import Anchor from 'components/Anchor';
Expand Down Expand Up @@ -42,7 +42,7 @@ class ErrorBoundary extends React.Component {
}

const ErrorComponent = ERROR_TYPE_MAP[error.name] || GenericError;
const Wrapper = useCard ? Card : React.Fragment;
const Wrapper = useCard ? Card : 'div';
return (
<ErrorComponent wrapper={Wrapper} error={error} />
);
Expand All @@ -54,17 +54,25 @@ ErrorBoundary.propTypes = {
};


const GenericError = ({ wrapper: Wrapper, error }) => (
<Wrapper title={<FormattedMessage description="Error boundary title" defaultMessage="Oops!" />}>
<ErrorMessage>
<FormattedMessage
description="Generic error message"
defaultMessage="Unfortunately something went wrong!"
/>
</ErrorMessage>
{error.detail && <Body>{error.detail}</Body>}
</Wrapper>
);
const GenericError = ({ wrapper: Wrapper, error }) => {
const intl = useIntl();
// Wrapper may be a DOM element, which can't handle <FormattedMessage />
const title = intl.formatMessage({
description: 'Error boundary title',
defaultMessage: 'Oops!',
});
return (
<Wrapper title={title}>
<ErrorMessage>
<FormattedMessage
description="Generic error message"
defaultMessage="Unfortunately something went wrong!"
/>
</ErrorMessage>
{error.detail && <Body>{error.detail}</Body>}
</Wrapper>
);
};

GenericError.propTypes = {
wrapper: PropTypes.elementType.isRequired,
Expand Down
2 changes: 1 addition & 1 deletion src/components/FormStep.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import isEmpty from 'lodash/isEmpty';
import omit from 'lodash/omit';
import { useImmerReducer } from 'use-immer';
import { Form } from 'react-formio';
import useAsync from 'react-use/esm/useAsync';
import { useAsync } from 'react-use';

import hooks from '../formio/hooks';

Expand Down
112 changes: 112 additions & 0 deletions src/components/LanguageSelection/LanguageSelection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import PropTypes from 'prop-types';
import React, { useContext, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { useAsync } from 'react-use';

import { get, put } from 'api';
import { ConfigContext } from 'Context';
import Loader from 'components/Loader';

import LanguageSelectionDisplay from './LanguageSelectionDisplay';


const DEFAULT_HEADING = (
<FormattedMessage
description="Language selection heading"
defaultMessage="Choose language"
/>
);


const LanguageSelection = ({
heading=DEFAULT_HEADING,
headingLevel=2,
onLanguageChanged=console.log,
}) => {
// Hook uses
const { baseUrl } = useContext(ConfigContext);
const { locale } = useIntl();
const [ updatingLanguage, setUpdatingLanguage ] = useState(false);
const [ err, setErr ] = useState(null);

// fetch language information from API
const {
loading,
value: languageInfo,
error,
} = useAsync(
async () => {
const result = await get(`${baseUrl}i18n/info`);
// the browser preferences may have activated a different language than the
// client-side default language. In that case, we need to inform the parent
// components that the UI language needs to update.
//
// This will trigger the value of `locale` to change from the `useIntl()` hook.
if (result.current !== locale) {
onLanguageChanged(result.current);
}
return result;
},
[baseUrl, locale]
);

const anyError = err || error ;
if (anyError) {
throw anyError; // bubble up to boundary
}
if (loading || updatingLanguage) {
return <Loader modifiers={["small"]} />;
}

const { languages } = languageInfo;
// transform language information for display
const items = languages.map( ({ code, name }) => ({
lang: code,
textContent: code.toUpperCase(),
label: name,
current: code === locale,
}));

/**
* Event handler for user interaction to change the language.
* @param {String} languageCode The code of the (new) language to activate.
* @return {Void}
*/
const onLanguageChange = async (languageCode) => {
// do nothing if this is already the active language
// or if an update is being processed.
if (updatingLanguage || languageCode === locale) return;

setUpdatingLanguage(true);
// activate other language in backend
try {
await put(`${baseUrl}i18n/language`, { code: languageCode });
} catch (err) {
// set error in state, which gets re-thrown in render and bubbles up
// to error bounary
setUpdatingLanguage(false);
setErr(err);
}
// update UI language
setUpdatingLanguage(false);
onLanguageChanged(languageCode);
};

return (
<LanguageSelectionDisplay
onLanguageChange={onLanguageChange}
items={items}
headingId="of-language-selection"
heading={heading}
headingLevel={headingLevel}
/>
);
};

LanguageSelection.propTypes = {
heading: PropTypes.node,
headingLevel: PropTypes.number,
onLanguageChanged: PropTypes.func,
};

export default LanguageSelection;
Loading

0 comments on commit 33c6e82

Please sign in to comment.