diff --git a/.eslintrc.js b/.eslintrc.js index cfff665808..b25c1fd719 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,9 @@ module.exports = { 'default-case': 'off', 'no-mixed-operators': 'off', 'no-negated-condition': 'off', - 'complexity': 'off' + 'complexity': 'off', + + // This rule would prevent us from implementing meaningful value objects + 'no-useless-constructor': 'off' }, } diff --git a/.yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip b/.yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip new file mode 100644 index 0000000000..bddbef91a2 Binary files /dev/null and b/.yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip differ diff --git a/.yarn/cache/node-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip b/.yarn/cache/node-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip new file mode 100644 index 0000000000..a067dc7b1c Binary files /dev/null and b/.yarn/cache/node-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip differ diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index a70a803b48..4afe1478c1 100644 --- a/Classes/Controller/BackendController.php +++ b/Classes/Controller/BackendController.php @@ -27,13 +27,13 @@ use Neos\Neos\FrontendRouting\NodeAddressFactory; use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; -use Neos\Neos\Service\UserService; use Neos\Neos\Ui\Domain\InitialData\ConfigurationProviderInterface; use Neos\Neos\Ui\Domain\InitialData\FrontendConfigurationProviderInterface; use Neos\Neos\Ui\Domain\InitialData\InitialStateProviderInterface; use Neos\Neos\Ui\Domain\InitialData\MenuProviderInterface; use Neos\Neos\Ui\Domain\InitialData\NodeTypeGroupsAndRolesProviderInterface; use Neos\Neos\Ui\Domain\InitialData\RoutesProviderInterface; +use Neos\Neos\Ui\Domain\InitialData\UserProviderInterface; use Neos\Neos\Ui\Presentation\ApplicationView; /** @@ -48,12 +48,6 @@ class BackendController extends ActionController protected $defaultViewObjectName = ApplicationView::class; - /** - * @Flow\Inject - * @var UserService - */ - protected $userService; - /** * @Flow\Inject * @var DomainRepository @@ -114,6 +108,12 @@ class BackendController extends ActionController */ protected $menuProvider; + /** + * @Flow\Inject + * @var UserProviderInterface + */ + protected $userProvider; + /** * @Flow\Inject * @var InitialStateProviderInterface @@ -139,11 +139,6 @@ public function indexAction(string $node = null) $nodeAddress = $node !== null ? NodeAddressFactory::create($contentRepository)->createFromUriString($node) : null; unset($node); - $user = $this->userService->getBackendUser(); - - if ($user === null) { - $this->redirectToUri($this->uriBuilder->uriFor('index', [], 'Login', 'Neos.Neos')); - } $currentAccount = $this->securityContext->getAccount(); assert($currentAccount !== null); @@ -185,6 +180,11 @@ public function indexAction(string $node = null) $node = $subgraph->findNodeById($nodeAddress->nodeAggregateId); } + $user = $this->userProvider->getUser(); + if (!$user) { + $this->redirectToUri($this->uriBuilder->uriFor('index', [], 'Login', 'Neos.Neos')); + } + $this->view->setOption('title', 'Neos CMS'); $this->view->assign('initialData', [ 'configuration' => @@ -206,12 +206,12 @@ public function indexAction(string $node = null) $this->menuProvider->getMenu( actionRequest: $this->request, ), + 'user' => $user, 'initialState' => $this->initialStateProvider->getInitialState( actionRequest: $this->request, documentNode: $node, site: $siteNode, - user: $user, ), ]); } diff --git a/Classes/Domain/InitialData/InitialStateProviderInterface.php b/Classes/Domain/InitialData/InitialStateProviderInterface.php index 40b7c0b5af..ed433b4f1d 100644 --- a/Classes/Domain/InitialData/InitialStateProviderInterface.php +++ b/Classes/Domain/InitialData/InitialStateProviderInterface.php @@ -32,6 +32,5 @@ public function getInitialState( ActionRequest $actionRequest, ?Node $documentNode, ?Node $site, - User $user, ): array; } diff --git a/Classes/Domain/InitialData/UserProviderInterface.php b/Classes/Domain/InitialData/UserProviderInterface.php new file mode 100644 index 0000000000..ae3f3c19ff --- /dev/null +++ b/Classes/Domain/InitialData/UserProviderInterface.php @@ -0,0 +1,29 @@ +configurationRenderingService->computeConfiguration( $this->initialStateBeforeProcessing, @@ -51,7 +50,6 @@ public function getInitialState( 'request' => $actionRequest, 'documentNode' => $documentNode, 'site' => $site, - 'user' => $user, 'clipboardNodes' => $this->clipboard->getSerializedNodeAddresses(), 'clipboardMode' => $this->clipboard->getMode(), ] diff --git a/Classes/Infrastructure/Neos/MenuProvider.php b/Classes/Infrastructure/Neos/MenuProvider.php index ae8273cfcb..f5a7c2c961 100644 --- a/Classes/Infrastructure/Neos/MenuProvider.php +++ b/Classes/Infrastructure/Neos/MenuProvider.php @@ -55,7 +55,6 @@ public function getMenu(ActionRequest $actionRequest): array $result[$moduleName]['label'] = $module['label']; $result[$moduleName]['icon'] = $module['icon']; $result[$moduleName]['uri'] = $module['uri']; - $result[$moduleName]['target'] = 'Window'; $result[$moduleName]['children'] = match ($module['module']) { 'content' => $this->buildChildrenForSites($controllerContext), @@ -89,7 +88,6 @@ private function buildChildrenForSites(ControllerContext $controllerContext): ar $result[$index]['icon'] = 'globe'; $result[$index]['label'] = $name; $result[$index]['uri'] = $uri; - $result[$index]['target'] = 'Window'; $result[$index]['isActive'] = $active; $result[$index]['skipI18n'] = true; } @@ -114,7 +112,6 @@ private function buildChildrenForBackendModule(array $module): array $result[$submoduleName]['uri'] = $submodule['uri']; $result[$submoduleName]['position'] = $submodule['position']; $result[$submoduleName]['isActive'] = true; - $result[$submoduleName]['target'] = 'Window'; $result[$submoduleName]['skipI18n'] = false; } diff --git a/Classes/Infrastructure/Neos/UserProvider.php b/Classes/Infrastructure/Neos/UserProvider.php new file mode 100644 index 0000000000..7ec0ac0668 --- /dev/null +++ b/Classes/Infrastructure/Neos/UserProvider.php @@ -0,0 +1,55 @@ +userService->getBackendUser(); + if ($user === null) { + return null; + } + + return [ + 'name' => [ + 'title' => $user->getName()->getTitle(), + 'firstName' => $user->getName()->getFirstName(), + 'middleName' => $user->getName()->getMiddleName(), + 'lastName' => $user->getName()->getLastName(), + 'otherName' => $user->getName()->getOtherName(), + 'fullName' => $user->getName()->getFullName(), + ], + 'preferences' => [ + 'interfaceLanguage' => $user->getPreferences() + ->getInterfaceLanguage(), + ], + ]; + } +} diff --git a/Classes/Presentation/ApplicationView.php b/Classes/Presentation/ApplicationView.php index c54f1d7c5c..21c64fa715 100644 --- a/Classes/Presentation/ApplicationView.php +++ b/Classes/Presentation/ApplicationView.php @@ -16,6 +16,8 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Core\Bootstrap; +use Neos\Flow\I18n\Cldr\Reader\PluralsReader; +use Neos\Flow\I18n\Locale; use Neos\Flow\Mvc\View\AbstractView; use Neos\Flow\ResourceManagement\ResourceManager; use Neos\Flow\Security\Context as SecurityContext; @@ -49,6 +51,9 @@ final class ApplicationView extends AbstractView #[Flow\Inject] protected Bootstrap $bootstrap; + #[Flow\Inject] + protected PluralsReader $pluralsReader; + /** * This contains the supported options, their default values, descriptions and types. * @@ -113,6 +118,15 @@ private function renderHead(): string ) ); + $locale = new Locale($this->userService->getInterfaceLanguage()); + // @TODO: All endpoints should be treated this way and be isolated from + // initial data. + $result .= sprintf( + '', + $this->variables['initialData']['configuration']['endpoints']['translations'], + (string) $locale, + implode(',', $this->pluralsReader->getPluralForms($locale)), + ); $result .= sprintf( '', json_encode($this->variables['initialData']), diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml index 9c055c11f9..93b1bb6f01 100644 --- a/Configuration/Objects.yaml +++ b/Configuration/Objects.yaml @@ -16,6 +16,9 @@ Neos\Neos\Ui\Domain\InitialData\InitialStateProviderInterface: Neos\Neos\Ui\Domain\InitialData\MenuProviderInterface: className: Neos\Neos\Ui\Infrastructure\Neos\MenuProvider +Neos\Neos\Ui\Domain\InitialData\UserProviderInterface: + className: Neos\Neos\Ui\Infrastructure\Neos\UserProvider + Neos\Neos\Ui\Domain\InitialData\NodeTypeGroupsAndRolesProviderInterface: className: Neos\Neos\Ui\Infrastructure\ContentRepository\NodeTypeGroupsAndRolesProvider diff --git a/package.json b/package.json index 18f96d449a..b21d7a91e1 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@neos-project/eslint-config-neos": "^2.6.1", "@typescript-eslint/eslint-plugin": "^5.44.0", "@typescript-eslint/parser": "^5.44.0", + "cross-fetch": "^4.0.0", "editorconfig-checker": "^4.0.2", "esbuild": "~0.17.0", "eslint": "^8.27.0", diff --git a/packages/framework-observable-react/README.md b/packages/framework-observable-react/README.md new file mode 100644 index 0000000000..82b3625e2c --- /dev/null +++ b/packages/framework-observable-react/README.md @@ -0,0 +1,120 @@ +# @neos-project/framework-observable-react + +> React bindings for @neos-project/framework-observable + +This package provides a set of React [hooks](https://react.dev/reference/react/hooks) to let components interact with `Observable`s. + +## API + +### `useLatestValueFrom` + +```typescript +// Without default value: +function useLatestValueFrom(observable$: Observable): null | V; + +// With default value: +function useLatestValueFrom( + observable$: Observable, + defaultValue: D +): D | V; +``` + +`useLatestValueFrom` is a way to bind a react component the latest value emitted from an `Observable`. + +#### Parameters + +| Name | Description | +| ------------------------- | ---------------------------------------------------------------------------------------------- | +| `observable$` | The `Observable` to subscribe to | +| `defaultValue` (optional) | The value to default for when `observable$` hasn't emitted any values yet (defaults to `null`) | + +#### Return Value + +This hook returns the latest value from the provided `observable$`. If no value has been emitted from the observable yet, it returns `defaultValue` which itself defaults to `null`. + +#### Example + +This component will display the amount of seconds that have passed since it was first mounted: + +```typescript +const clock$ = createObservable((next) => { + let i = 1; + const interval = setInterval(() => { + next(i++); + }, 1000); + + return () => clearInterval(interval); +}); + +const MyComponent = () => { + const seconds = useLatestValueFrom(clock$, 0); + + return
{seconds} seconds passed
; +}; +``` + +You can combine this with `React.useMemo`, if you wish to create an ad-hoc observable: + +```typescript +const MyComponent = (props) => { + const beats = useLatestValueFrom( + React.useMemo( + () => + createObservable((next) => { + let i = 1; + const interval = setInterval(() => { + next(i++); + }, props.millisecondsPerBeat); + + return () => clearInterval(interval); + }), + [props.millisecondsPerBeat] + ), + 0 + ); + + return
{beats} beats passed
; +}; +``` + +### `useLatestState` + +```typescript +function useLatestState(state$: State): V; +``` + +`useLatestState` subscribes to a given state observable and keeps track of its latest value. + +#### Parameters + +| Name | Description | +| -------- | --------------------------------------- | +| `state$` | The `State` observable to keep track of | + +#### Return Value + +This hook returns the latest value from the given `State` observable. Initially it contains the current value of the `State` at the moment the component was first mounted. + +#### Example + +```typescript +const count$ = createState(0); + +const MyComponent = () => { + const count = useLatestState(count$); + const handleInc = React.useCallback(() => { + count$.update((count) => count + 1); + }, []); + const handleDec = React.useCallback(() => { + count$.update((count) => count - 1); + }, []); + + return ( +
+
Count {count}
+ + +
+ ); +}; +``` diff --git a/packages/framework-observable-react/package.json b/packages/framework-observable-react/package.json new file mode 100644 index 0000000000..ae114ecebb --- /dev/null +++ b/packages/framework-observable-react/package.json @@ -0,0 +1,12 @@ +{ + "name": "@neos-project/framework-observable-react", + "version": "", + "description": "React bindings for @neos-project/framework-observable", + "private": true, + "main": "./src/index.ts", + "dependencies": { + "@neos-project/framework-observable": "workspace:*", + "react": "^16.12.0" + }, + "license": "GNU GPLv3" +} diff --git a/packages/framework-observable-react/src/index.ts b/packages/framework-observable-react/src/index.ts new file mode 100644 index 0000000000..1ddd85b034 --- /dev/null +++ b/packages/framework-observable-react/src/index.ts @@ -0,0 +1,11 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {useLatestState} from './useLatestState'; +export {useLatestValueFrom} from './useLatestValueFrom'; diff --git a/packages/framework-observable-react/src/useLatestState.ts b/packages/framework-observable-react/src/useLatestState.ts new file mode 100644 index 0000000000..d7b23bad5c --- /dev/null +++ b/packages/framework-observable-react/src/useLatestState.ts @@ -0,0 +1,16 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import type {State} from '@neos-project/framework-observable'; + +import {useLatestValueFrom} from './useLatestValueFrom'; + +export function useLatestState(state$: State) { + return useLatestValueFrom(state$, state$.current); +} diff --git a/packages/framework-observable-react/src/useLatestValueFrom.ts b/packages/framework-observable-react/src/useLatestValueFrom.ts new file mode 100644 index 0000000000..9158face7d --- /dev/null +++ b/packages/framework-observable-react/src/useLatestValueFrom.ts @@ -0,0 +1,41 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import type {Observable} from '@neos-project/framework-observable'; + +export function useLatestValueFrom(observable$: Observable): null | V; +export function useLatestValueFrom( + observable$: Observable, + defaultValue: D +): D | V; + +export function useLatestValueFrom( + observable$: Observable, + defaultValue?: D +) { + const [value, setValue] = React.useState( + defaultValue ?? null + ); + + React.useEffect(() => { + const subscription = observable$.subscribe({ + next: (incomingValue) => { + if (incomingValue !== value) { + setValue(incomingValue); + } + } + }); + + return () => subscription.unsubscribe(); + }, [observable$]); + + return value; +} diff --git a/packages/framework-observable/README.md b/packages/framework-observable/README.md new file mode 100644 index 0000000000..c350482578 --- /dev/null +++ b/packages/framework-observable/README.md @@ -0,0 +1,152 @@ +# @neos-project/framework-observable + +> Observable pattern implementation for the Neos UI + +> [!NOTE] +> This package implements a pattern for which there is a WICG proposal: +> https://github.com/WICG/observable +> +> It is therefore likely that future versions of this package will use the web-native `Observable` primitive under the hood. + +## API + +### Observables + +An `Observable` represents a sequence of values that can be *observed* from the outside. This is a powerful abstraction that allows to encapsule all kinds of value streams like: + +- (DOM) Events +- Timeouts & Intervals +- Async operations & Promises +- Websockets +- etc. + +An `Observable` can be created using the `createObservable` function like this: + +```typescript +const numbers$ = createObservable((next) => { + next(1); + next(2); + next(3); +}); +``` + +> [!NOTE] +> Suffixing variable names with `$` is a common naming convention to signify that a variable represents an observable. + +Here, the `numbers$` observable represents the sequence of the numbers 1, 2 and 3. This observable can be subscribed to thusly: + +```typescript +numbers$.subscribe((value) => { + console.log(value); +}); +``` + +Because the `numbers$` observable emits its values immediately, the above subscription will immediately log: +``` +1 +2 +3 +``` + +An additional subscription would also immediately receive all 3 values. By default, oberservables are *lazy* and *single-cast*. This means, values are generated exclusively for each subscription, and the generation starts exactly when a subscriber is registered. + +The usefulness of observables becomes more apparent when we introduce some asynchrony: +```typescript +const timedNumbers$ = createObservable((next) => { + let i = 1; + const interval = setInterval(() => { + next(i++); + }, 2000); + + return () => clearInterval(interval); +}); +``` + +This `timedNumbers$` observable will emit a new value every two seconds. This time, the callback used to facilitate the observable returns a function: +```typescript +// .. +return () => clearInterval(interval); +// .. +``` + +This function will be called when a subscription is cancelled. This is a way for observables to clean up after themselves. + +If we now subscribe to `timedNumbers$` like this: +```typescript +const subscription = timedNumbers$.subscribe((value) => { + console.log(value); +}); +``` + +The following values will be logged to the console: +``` +1 (After 2 seconds) +2 (After 4 seconds) +3 (After 6 seconds) +4 (After 8 seconds) +... +``` + +This will go on forever, unless we call the `unsubscribe` on our `subscription` which has been the return value we've saved from `timedNumber$.subscribe(...)`. When we call `unsubscribe`, the cleanup function of the `timedNumbers$` observable will be called and so the interval will be cleared: +```typescript +subscription.unsubscribe(); +``` + +That's all there is to it. With this small set of tools, `Observable`s can be used to encapsule all kinds of synchronous or asynchronous value streams. + +They can be created from a Promise: +```typescript +async function someLongRunningOperation() { + // ... +} + +const fromPromise$ = createObservable((next) => { + someLongRunningOperation().then(next); +}); +``` + +Or DOM events: +```typescript +const clicks$ = createObservable((next) => { + const button = document.querySelector('button'); + button.addEventListener('click', next); + return () => button.removeEventListener('click', next); +}); +``` + +And there are many, many more possibilities. + +### State + +A `State` is a special `Observable` that can track a value over time. `State`s can be created using the `createState` function like this: + +```typescript +const count$ = createState(0); +``` + +The `count$` state is now set to `0`. Unlike regular observables, a `State` instance can be queried for its current value: +```typescript +console.log(count$.current); // output: 0 +``` + +Each `State` instance has an `update` method that can be used to push new values to the state observable. It takes a callback that receives the current value as its first paramater and returns the new value: + +```typescript +count$.update((value) => value + 1); + +console.log(count$.current); // output: 1 +``` + +When a new subscriber is registered to a `State` instance, that subscriber immediately receives the current value: +```typescript +const count$ = createState(0); +count$.update((value) => value + 1); // nothing is logged, nobody has subscribed yet +count$.update((value) => value + 1); // nothing is logged, nobody has subscribed yet +count$.update((value) => value + 1); // nothing is logged, nobody has subscribed yet + +count$.subscribe((value) => console.log(value)); // immediately logs: 3 + +count$.update((value) => value + 1); // logs: 4 +``` + +Unlike regular `Observable`s, `State`s are multi-cast. This means that all subscribers receive updates at the same time, and every subscriber only receives updates that are published after the subscription has been registered. diff --git a/packages/framework-observable/package.json b/packages/framework-observable/package.json new file mode 100644 index 0000000000..d79d8c5fd9 --- /dev/null +++ b/packages/framework-observable/package.json @@ -0,0 +1,8 @@ +{ + "name": "@neos-project/framework-observable", + "version": "", + "description": "Observable pattern implementation for the Neos UI", + "private": true, + "main": "./src/index.ts", + "license": "GNU GPLv3" +} diff --git a/packages/framework-observable/src/Observable.spec.ts b/packages/framework-observable/src/Observable.spec.ts new file mode 100644 index 0000000000..04dfff09cd --- /dev/null +++ b/packages/framework-observable/src/Observable.spec.ts @@ -0,0 +1,94 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {createObservable} from './Observable'; + +describe('Observable', () => { + test('emit some values and subscribe', () => { + const observable$ = createObservable((next) => { + next(1); + next(2); + next(3); + }); + const subscriber = { + next: jest.fn() + }; + + observable$.subscribe(subscriber); + + expect(subscriber.next).toHaveBeenCalledTimes(3); + expect(subscriber.next).toHaveBeenNthCalledWith(1, 1); + expect(subscriber.next).toHaveBeenNthCalledWith(2, 2); + expect(subscriber.next).toHaveBeenNthCalledWith(3, 3); + }); + + test('emit some values and subscribe a couple of times', () => { + const observable$ = createObservable((next) => { + next(1); + next(2); + next(3); + }); + const subscriber1 = { + next: jest.fn() + }; + const subscriber2 = { + next: jest.fn() + }; + const subscriber3 = { + next: jest.fn() + }; + + observable$.subscribe(subscriber1); + observable$.subscribe(subscriber2); + observable$.subscribe(subscriber3); + + expect(subscriber1.next).toHaveBeenCalledTimes(3); + expect(subscriber1.next).toHaveBeenNthCalledWith(1, 1); + expect(subscriber1.next).toHaveBeenNthCalledWith(2, 2); + expect(subscriber1.next).toHaveBeenNthCalledWith(3, 3); + + expect(subscriber2.next).toHaveBeenCalledTimes(3); + expect(subscriber2.next).toHaveBeenNthCalledWith(1, 1); + expect(subscriber2.next).toHaveBeenNthCalledWith(2, 2); + expect(subscriber2.next).toHaveBeenNthCalledWith(3, 3); + + expect(subscriber3.next).toHaveBeenCalledTimes(3); + expect(subscriber3.next).toHaveBeenNthCalledWith(1, 1); + expect(subscriber3.next).toHaveBeenNthCalledWith(2, 2); + expect(subscriber3.next).toHaveBeenNthCalledWith(3, 3); + }); + + test('emit no values, subscribe and unsubscribe', () => { + const unsubscribe = jest.fn(); + const observable$ = createObservable(() => { + return unsubscribe; + }); + const subscriber = { + next: jest.fn() + }; + + const subscription = observable$.subscribe(subscriber); + subscription.unsubscribe(); + + expect(subscriber.next).toHaveBeenCalledTimes(0); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + test('emit no values, subscribe and unsubscribe with void observer', () => { + const observable$ = createObservable(() => {}); + const subscriber = { + next: jest.fn() + }; + + const subscription = observable$.subscribe(subscriber); + + expect(() => subscription.unsubscribe()).not.toThrow(); + expect(subscriber.next).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/framework-observable/src/Observable.ts b/packages/framework-observable/src/Observable.ts new file mode 100644 index 0000000000..482fb87a9d --- /dev/null +++ b/packages/framework-observable/src/Observable.ts @@ -0,0 +1,51 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import type {Subscriber} from './Subscriber'; +import type {Subscription} from './Subscription'; +import type {Observer} from './Observer'; + +/** + * An Observable emits values over time. You can attach a subscriber to it + * using the Observable's `subscribe` method, or you can perform operations + * producing new Observables via its `pipe` method. + */ +export interface Observable { + subscribe: (subscriber: Subscriber) => Subscription; +} + +/** + * An ObservablePipeOperation is a function that takes an observable and + * returns a new observable. It can be passed to any Observable's `pipe` + * method. + */ +export interface ObservablePipeOperation { + (observable: Observable): Observable; +} + +/** + * Creates an Observable from the given Observer. + */ +export function createObservable(observer: Observer): Observable { + const observable: Observable = { + subscribe(subscriber) { + return Object.freeze({ + unsubscribe: observer( + subscriber.next, + subscriber.error ?? noop + ) ?? noop + }); + } + }; + + return Object.freeze(observable); +} + +function noop() { +} diff --git a/packages/framework-observable/src/Observer.ts b/packages/framework-observable/src/Observer.ts new file mode 100644 index 0000000000..b9c145b0ac --- /dev/null +++ b/packages/framework-observable/src/Observer.ts @@ -0,0 +1,19 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +/** + * An Observer is a function that emits values via its `next` callback. It can + * return a function that handles all logic that must be performed when a + * Subscription is cancelled (e.g. clearTimeout or similar cancellation + * effects). + */ +export interface Observer { + (next: (value: V) => void, fail: (error: any) => void): void | (() => void); +} diff --git a/packages/framework-observable/src/State.spec.ts b/packages/framework-observable/src/State.spec.ts new file mode 100644 index 0000000000..b573a6c880 --- /dev/null +++ b/packages/framework-observable/src/State.spec.ts @@ -0,0 +1,67 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {createState} from './State'; + +describe('State', () => { + test('get current value', () => { + const state$ = createState(0); + + expect(state$.current).toBe(0); + + state$.update((value) => value + 1); + expect(state$.current).toBe(1); + + state$.update((value) => value + 1); + expect(state$.current).toBe(2); + + state$.update((value) => value + 1); + expect(state$.current).toBe(3); + }); + + test('subscribe to state updates: subscriber receives current value immediately', () => { + const state$ = createState(0); + const subscriber1 = { + next: jest.fn() + }; + const subscriber2 = { + next: jest.fn() + }; + + state$.subscribe(subscriber1); + expect(subscriber1.next).toHaveBeenCalledTimes(1); + expect(subscriber1.next).toHaveBeenNthCalledWith(1, 0); + + state$.update((value) => value + 1); + state$.update((value) => value + 1); + state$.update((value) => value + 1); + + state$.subscribe(subscriber2); + expect(subscriber2.next).toHaveBeenCalledTimes(1); + expect(subscriber2.next).toHaveBeenNthCalledWith(1, 3); + }); + + test('subscribe to state updates: subscriber receives all updates', () => { + const state$ = createState(0); + const subscriber = { + next: jest.fn() + }; + + state$.subscribe(subscriber); + state$.update((value) => value + 1); + state$.update((value) => value + 1); + state$.update((value) => value + 1); + + expect(subscriber.next).toHaveBeenCalledTimes(4); + expect(subscriber.next).toHaveBeenNthCalledWith(1, 0); + expect(subscriber.next).toHaveBeenNthCalledWith(2, 1); + expect(subscriber.next).toHaveBeenNthCalledWith(3, 2); + expect(subscriber.next).toHaveBeenNthCalledWith(4, 3); + }); +}); diff --git a/packages/framework-observable/src/State.ts b/packages/framework-observable/src/State.ts new file mode 100644 index 0000000000..c0d63a9f2f --- /dev/null +++ b/packages/framework-observable/src/State.ts @@ -0,0 +1,61 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {createObservable, Observable} from './Observable'; + +/** + * A State is a special kind of Observable that keeps track of a value over + * time. + * + * It has a public readonly `current` property that allows you to ask for + * its current value at any point in time. A new subscriber to the State + * Observable will also immediately receive the current value at the time of + * subscription. + * + * Via the `update` method, a State's value can be modified. When called, + * Subscribers to the state are immediately informed about the new value. + */ +export interface State extends Observable { + readonly current: V; + update: (updateFn: (current: V) => V) => void; +} + +/** + * Creates a new State with the given initial value. + */ +export function createState(initialValue: V): State { + let currentState = initialValue; + const listeners = new Set<(value: V) => void>(); + const state: State = { + ...createObservable((next) => { + listeners.add(next); + next(currentState); + + return () => listeners.delete(next); + }), + + get current() { + return currentState; + }, + + update(updateFn) { + const nextState = updateFn(currentState); + + if (currentState !== nextState) { + currentState = nextState; + + for (const next of listeners) { + next(currentState); + } + } + } + }; + + return Object.freeze(state); +} diff --git a/packages/framework-observable/src/Subscriber.ts b/packages/framework-observable/src/Subscriber.ts new file mode 100644 index 0000000000..ac8f5c963e --- /dev/null +++ b/packages/framework-observable/src/Subscriber.ts @@ -0,0 +1,19 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +/** + * A Subscriber can be attached to an Observable. It receives values from the + * Observable in its `next` callback function. It may also provide an optional + * `error` callback, that will only be called if the Observable emits an Error. + */ +export interface Subscriber { + next: (value: V) => void; + error?: (error: Error) => void; +} diff --git a/packages/framework-observable/src/Subscription.ts b/packages/framework-observable/src/Subscription.ts new file mode 100644 index 0000000000..3821d0d04a --- /dev/null +++ b/packages/framework-observable/src/Subscription.ts @@ -0,0 +1,19 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +/** + * When attaching a Subscriber to an Observable, a Subscription is returned. + * The `unsubscribe` method of the Subscription allows you to detach the + * Subscriber from the Observable again, after which the Subscriber no longer + * receives any values emitted from the Observable. + */ +export interface Subscription { + unsubscribe: () => void; +} diff --git a/packages/framework-observable/src/index.ts b/packages/framework-observable/src/index.ts new file mode 100644 index 0000000000..b84ad0927e --- /dev/null +++ b/packages/framework-observable/src/index.ts @@ -0,0 +1,14 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export type {Observable} from './Observable'; +export {createObservable} from './Observable'; + +export type {State} from './State'; +export {createState} from './State'; diff --git a/packages/jest-preset-neos-ui/src/setupBrowserEnv.js b/packages/jest-preset-neos-ui/src/setupBrowserEnv.js index 792b4ae476..c1d08efc5c 100644 --- a/packages/jest-preset-neos-ui/src/setupBrowserEnv.js +++ b/packages/jest-preset-neos-ui/src/setupBrowserEnv.js @@ -1,6 +1,5 @@ import 'regenerator-runtime/runtime'; import browserEnv from 'browser-env'; +import 'cross-fetch/polyfill'; browserEnv(); - -window.fetch = () => Promise.resolve(null); diff --git a/packages/neos-ts-interfaces/package.json b/packages/neos-ts-interfaces/package.json index d6e993fee1..4d24fcb673 100644 --- a/packages/neos-ts-interfaces/package.json +++ b/packages/neos-ts-interfaces/package.json @@ -4,6 +4,9 @@ "description": "Neos domain-related TypeScript interfaces", "private": true, "main": "src/index.ts", + "dependencies": { + "@neos-project/neos-ui-i18n": "workspace:*" + }, "devDependencies": { "@neos-project/jest-preset-neos-ui": "workspace:*", "typescript": "^4.6.4" diff --git a/packages/neos-ts-interfaces/src/index.ts b/packages/neos-ts-interfaces/src/index.ts index 7689692b18..aaaeac447d 100644 --- a/packages/neos-ts-interfaces/src/index.ts +++ b/packages/neos-ts-interfaces/src/index.ts @@ -1,3 +1,5 @@ +import type {I18nRegistry} from '@neos-project/neos-ui-i18n'; + export type NodeContextPath = string; export type FusionPath = string; export type NodeTypeName = string; @@ -268,9 +270,6 @@ export interface ValidatorRegistry { get: (validatorName: string) => Validator | null; set: (validatorName: string, validator: Validator) => void; } -export interface I18nRegistry { - translate: (id?: string, fallback?: string, params?: {}, packageKey?: string, sourceName?: string) => string; -} export interface GlobalRegistry { get: (key: K) => K extends 'i18n' ? I18nRegistry : K extends 'validators' ? ValidatorRegistry : null; diff --git a/packages/neos-ui-backend-connector/src/index.ts b/packages/neos-ui-backend-connector/src/index.ts index 66fe06618a..958258bb2b 100644 --- a/packages/neos-ui-backend-connector/src/index.ts +++ b/packages/neos-ui-backend-connector/src/index.ts @@ -21,7 +21,7 @@ export const define = (parent: {[propName: string]: any}) => (name: string, valu // // Initializes the Neos API // -export const initializeJsAPI = (parent: {[propName: string]: any}, {alias = 'neos', systemEnv = 'Development', routes}: {alias: string, systemEnv: string, routes: Routes}) => { +export const initializeJsAPI = (parent: {[propName: string]: any}, {alias = 'neos', systemEnv = 'Development', routes}: {alias?: string, systemEnv: string, routes: Routes}) => { if (parent[alias] !== undefined) { throw new Error(`Could not initialize Neos API, because ${alias} is already defined.`); } diff --git a/packages/neos-ui-error/package.json b/packages/neos-ui-error/package.json index b7f210a44c..20ce5842d7 100644 --- a/packages/neos-ui-error/package.json +++ b/packages/neos-ui-error/package.json @@ -5,7 +5,10 @@ "private": true, "main": "./src/index.ts", "dependencies": { + "@neos-project/framework-observable": "workspace:*", + "@neos-project/framework-observable-react": "workspace:*", "@neos-project/neos-ui-i18n": "workspace:*", + "@neos-project/react-ui-components": "workspace:*", "classnames": "^2.2.3", "react": "^16.12.0" }, diff --git a/packages/neos-ui/src/Containers/ErrorBoundary/index.tsx b/packages/neos-ui-error/src/container/ErrorBoundary/ErrorBoundary.tsx similarity index 89% rename from packages/neos-ui/src/Containers/ErrorBoundary/index.tsx rename to packages/neos-ui-error/src/container/ErrorBoundary/ErrorBoundary.tsx index 3eda9448ba..aaf20ca94b 100644 --- a/packages/neos-ui/src/Containers/ErrorBoundary/index.tsx +++ b/packages/neos-ui-error/src/container/ErrorBoundary/ErrorBoundary.tsx @@ -1,13 +1,23 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ import React from 'react'; -import styles from './style.module.css'; // @ts-ignore import Logo from '@neos-project/react-ui-components/src/Logo'; import Button from '@neos-project/react-ui-components/src/Button'; import Icon from '@neos-project/react-ui-components/src/Icon'; -import {I18nRegistry} from '@neos-project/neos-ts-interfaces'; +import type {I18nRegistry} from '@neos-project/neos-ui-i18n'; + +import styles from './style.module.css'; -class ErrorBoundary extends React.Component< +export class ErrorBoundary extends React.Component< { children: React.ReactNode, i18nRegistry: I18nRegistry }, { error: any } > { @@ -97,4 +107,3 @@ const ErrorFallback = (props: { error: any, i18nRegistry: I18nRegistry }) => { ; }; -export default ErrorBoundary; diff --git a/packages/neos-ui-error/src/container/ErrorBoundary/index.ts b/packages/neos-ui-error/src/container/ErrorBoundary/index.ts new file mode 100644 index 0000000000..e8149df1a8 --- /dev/null +++ b/packages/neos-ui-error/src/container/ErrorBoundary/index.ts @@ -0,0 +1,11 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {ErrorBoundary} from './ErrorBoundary'; +export {terminateDueToFatalInitializationError} from './terminateDueToFatalInitializationError'; diff --git a/packages/neos-ui/src/Containers/ErrorBoundary/style.module.css b/packages/neos-ui-error/src/container/ErrorBoundary/style.module.css similarity index 100% rename from packages/neos-ui/src/Containers/ErrorBoundary/style.module.css rename to packages/neos-ui-error/src/container/ErrorBoundary/style.module.css diff --git a/packages/neos-ui/src/System/terminateDueToFatalInitializationError.js b/packages/neos-ui-error/src/container/ErrorBoundary/terminateDueToFatalInitializationError.ts similarity index 60% rename from packages/neos-ui/src/System/terminateDueToFatalInitializationError.js rename to packages/neos-ui-error/src/container/ErrorBoundary/terminateDueToFatalInitializationError.ts index 5edaecc5ac..ff9c4de0b8 100644 --- a/packages/neos-ui/src/System/terminateDueToFatalInitializationError.js +++ b/packages/neos-ui-error/src/container/ErrorBoundary/terminateDueToFatalInitializationError.ts @@ -1,8 +1,17 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ import logo from '@neos-project/react-ui-components/src/Logo/logo.svg'; -import styles from '../Containers/ErrorBoundary/style.module.css'; +import styles from './style.module.css'; -export function terminateDueToFatalInitializationError(reason) { +export function terminateDueToFatalInitializationError(reason: string): never { if (!document.body) { throw new Error(reason); } diff --git a/packages/neos-ui-error/src/ErrorView.tsx b/packages/neos-ui-error/src/container/ErrorView/ErrorView.tsx similarity index 99% rename from packages/neos-ui-error/src/ErrorView.tsx rename to packages/neos-ui-error/src/container/ErrorView/ErrorView.tsx index d6549dee8e..99243c3507 100644 --- a/packages/neos-ui-error/src/ErrorView.tsx +++ b/packages/neos-ui-error/src/container/ErrorView/ErrorView.tsx @@ -11,7 +11,7 @@ import React from 'react'; import I18n from '@neos-project/neos-ui-i18n'; -import {AnyError, isECMAScriptError, isServerSideError, isStringError} from './types'; +import {AnyError, isECMAScriptError, isServerSideError, isStringError} from '../../types'; import style from './style.module.css'; diff --git a/packages/neos-ui-error/src/container/ErrorView/index.ts b/packages/neos-ui-error/src/container/ErrorView/index.ts new file mode 100644 index 0000000000..e343e72ed9 --- /dev/null +++ b/packages/neos-ui-error/src/container/ErrorView/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {ErrorView} from './ErrorView'; diff --git a/packages/neos-ui-error/src/style.module.css b/packages/neos-ui-error/src/container/ErrorView/style.module.css similarity index 100% rename from packages/neos-ui-error/src/style.module.css rename to packages/neos-ui-error/src/container/ErrorView/style.module.css diff --git a/packages/neos-ui-error/src/container/FlashMessages/FlashMessage.tsx b/packages/neos-ui-error/src/container/FlashMessages/FlashMessage.tsx new file mode 100644 index 0000000000..cfaa8ce7f4 --- /dev/null +++ b/packages/neos-ui-error/src/container/FlashMessages/FlashMessage.tsx @@ -0,0 +1,82 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +import mergeClassNames from 'classnames'; + +import {IconButton, Icon} from '@neos-project/react-ui-components'; + +import {Severity} from '../../types'; + +import style from './style.module.css'; + +export const FlashMessage: React.FC<{ + id: string; + message: string; + severity: Severity; + timeout?: number; + + onClose: (id: string) => void; +}> = (props) => { + const {message, severity} = props; + const isSuccess = severity === 'success'; + const isError = severity === 'error'; + const isInfo = severity === 'info'; + const isClosing = React.useRef(false); + const handleClose = React.useCallback(() => { + const {onClose, id} = props; + + if (!isClosing.current) { + isClosing.current = true; + setTimeout(() => onClose(id), 100); + } + }, [props.id]); + + const flashMessageClasses = mergeClassNames({ + [style.flashMessage]: true, + [style['flashMessage--success']]: isSuccess, + [style['flashMessage--error']]: isError, + [style['flashMessage--info']]: isInfo + }); + + const iconName = mergeClassNames({ + check: isSuccess, + ban: isError, + info: isInfo + }) || 'info'; + + React.useEffect( + () => { + if (props.timeout) { + const timeout = setTimeout(handleClose, props.timeout); + return () => { + clearTimeout(timeout); + }; + } + + return () => {}; + }, + [handleClose, props.timeout] + ); + + return ( +
+ +
{message}
+ +
+ ); +} diff --git a/packages/neos-ui-error/src/container/FlashMessages/FlashMessages.tsx b/packages/neos-ui-error/src/container/FlashMessages/FlashMessages.tsx new file mode 100644 index 0000000000..85fdd3a9b2 --- /dev/null +++ b/packages/neos-ui-error/src/container/FlashMessages/FlashMessages.tsx @@ -0,0 +1,80 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import {createState} from '@neos-project/framework-observable'; +import {useLatestState} from '@neos-project/framework-observable-react'; + +import {FlashMessage} from './FlashMessage'; + +import {Severity} from '../../types'; + +import style from './style.module.css'; + +const flashMessages$ = createState>({}); + +export function showFlashMessage(flashMessage: { + id: string; + message: string; + severity?: Severity; + timeout?: number; +}) { + const flashMessageWithDefaults = { + id: flashMessage.id, + message: flashMessage.message, + severity: flashMessage.severity ?? 'info', + timeout: flashMessage.timeout + }; + + flashMessages$.update((flashMessages) => ({ + ...flashMessages, + [flashMessageWithDefaults.id]: flashMessageWithDefaults + })); +} + +function removeFlashMessage(id: string) { + flashMessages$.update((flashMessages) => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [id]: _, + ...remainingFlashMessages + } = flashMessages; + return remainingFlashMessages; + }); +} + +export const FlashMessages: React.FC = () => { + const flashMessages = useLatestState(flashMessages$); + + return ( +
+ {Object.keys(flashMessages).map(flashMessageId => { + const flashMessage = flashMessages[flashMessageId]; + const {id, message, severity, timeout} = flashMessage; + + return ( + + ); + })} +
+ ); +} diff --git a/packages/neos-ui-error/src/container/FlashMessages/index.ts b/packages/neos-ui-error/src/container/FlashMessages/index.ts new file mode 100644 index 0000000000..f2279f27a1 --- /dev/null +++ b/packages/neos-ui-error/src/container/FlashMessages/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {FlashMessages, showFlashMessage} from './FlashMessages'; diff --git a/packages/neos-ui/src/Containers/FlashMessages/FlashMessage/style.module.css b/packages/neos-ui-error/src/container/FlashMessages/style.module.css similarity index 77% rename from packages/neos-ui/src/Containers/FlashMessages/FlashMessage/style.module.css rename to packages/neos-ui-error/src/container/FlashMessages/style.module.css index a5a6bc1f2e..2c3b0378a8 100644 --- a/packages/neos-ui/src/Containers/FlashMessages/FlashMessage/style.module.css +++ b/packages/neos-ui-error/src/container/FlashMessages/style.module.css @@ -1,3 +1,19 @@ +.flashMessageContainer { + position: fixed; + z-index: var(--zIndex-FlashMessageContainer); + top: 0; + left: 50%; + width: 516px; + margin-top: 8px; + transform: translate(-50%, 0); + max-height: calc(100% - 16px); + overflow: auto; + + &:empty { + display: none; + } +} + .flashMessage { color: white; font-size: 14px; @@ -30,7 +46,7 @@ position: absolute; left: 0; top: 0; - height: 100%; + height: 100% !important; line-height: var(--spacing-GoldenUnit); width: var(--spacing-GoldenUnit) !important; text-align: center; diff --git a/packages/neos-ui-error/src/container/index.ts b/packages/neos-ui-error/src/container/index.ts new file mode 100644 index 0000000000..507ef19d3c --- /dev/null +++ b/packages/neos-ui-error/src/container/index.ts @@ -0,0 +1,12 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {ErrorBoundary, terminateDueToFatalInitializationError} from './ErrorBoundary'; +export {ErrorView} from './ErrorView'; +export {FlashMessages, showFlashMessage} from './FlashMessages'; diff --git a/packages/neos-ui-error/src/index.ts b/packages/neos-ui-error/src/index.ts index cbe1cc7ea1..82fa0fedab 100644 --- a/packages/neos-ui-error/src/index.ts +++ b/packages/neos-ui-error/src/index.ts @@ -11,7 +11,14 @@ export type { ECMAScriptError as ClientSideError, ServerSideError, StringError, - AnyError + AnyError, + Severity } from './types'; -export {ErrorView} from './ErrorView'; +export { + ErrorBoundary, + ErrorView, + FlashMessages, + showFlashMessage, + terminateDueToFatalInitializationError +} from './container'; diff --git a/packages/neos-ui-error/src/types.ts b/packages/neos-ui-error/src/types.ts index dc529db4d5..6228781e68 100644 --- a/packages/neos-ui-error/src/types.ts +++ b/packages/neos-ui-error/src/types.ts @@ -17,6 +17,7 @@ export type ServerSideError = { }; export type StringError = string; export type AnyError = ECMAScriptError | ServerSideError | StringError; +export type Severity = 'success' | 'error' | 'info'; export function isECMAScriptError(candidate: unknown): candidate is ECMAScriptError { return candidate instanceof Error; diff --git a/packages/neos-ui-i18n/README.md b/packages/neos-ui-i18n/README.md new file mode 100644 index 0000000000..0fd7260786 --- /dev/null +++ b/packages/neos-ui-i18n/README.md @@ -0,0 +1,244 @@ +# @neos-project/neos-ui-i18n + +> I18n utilities for Neos CMS UI. + +This package connects Flow's Internationalization (I18n) framework with the Neos UI. + +In Flow, translations are organized in [XLIFF](http://en.wikipedia.org/wiki/XLIFF) files that are stored in the `Resources/Private/Translations/`-folder of each Flow package. + +The Neos UI does not load all translation files at once, but only those that have been made discoverable explicitly via settings: +```yaml +Neos: + Neos: + userInterface: + translation: + autoInclude: + 'Neos.Neos.Ui': + - Error + - Main + // ... + 'Vendor.Package': + - Main + // ... +``` + +At the beginning of the UI bootstrapping process, translations are loaded from an enpoint (see: [`\Neos\Neos\Controller\Backend\BackendController->xliffAsJsonAction()`](https://neos.github.io/neos/9.0/Neos/Neos/Controller/Backend/BackendController.html#method_xliffAsJsonAction)) and are available afterwards via the `translate` function exposed by this package. + +## API + +### `translate` + +```typescript +function translate( + fullyQualifiedTranslationAddressAsString: string, + fallback: string | [string, string], + parameters: Parameters = [], + quantity: number = 0 +): string; +``` + +`translate` will use the given translation address to look up a translation from the ones that are currently available (see: [`initializeI18n`](#initializeI18n)). + +To understand how the translation address maps onto the translations stored in XLIFF files, let's take a look at the structure of the address: +``` +"Neos.Neos.Ui:Main:errorBoundary.title" + └────┬─────┘ └─┬┘ └───────────┬─────┘ + Package Key Source Name trans-unit ID +``` + +Each translation address consists of three Parts, one identifying the package (Package Key), one identifying the XLIFF file (Source Name), and one identifying the translation itself within the XLIFF file (trans-unit ID). + +Together with the currently set `Locale`, Package Key and Source Name identify the exact XLIFF file for translation thusly: +``` +resource://{Package Key}/Private/Translations/{Locale}/{Source Name}.xlf +``` + +So, the address `Neos.Neos.Ui:Main:errorBoundary.title` would lead us to: +``` +resource://Neos.Neos.Ui/Private/Translations/de/Main.xlf +``` + +Within the XLIFF-file, the trans-unit ID identifies the exact translation to be used: +```xml + + + + + + + + Sorry, but the Neos UI could not recover from this error. + Es tut uns leid, aber die Neos Benutzeroberfläche konnte von diesem Fehler nicht wiederhergestellt werden. + + + + + +``` + +If no translation can be found, `translate` will return the given `fallback` string. + +Translations (and fallbacks) may contain placeholders, like: +``` +All changes from workspace "{0}" have been discarded. +``` + +Placeholders may be numerically indexed (like the one above), or indexed by name, like: +``` +Copy {source} to {target} +``` + +For numerically indexed placeholders, you can pass an array of strings to the `parameters` argument of `translate`. For named parameters, you can pass an object with string values and keys identifying the parameters. + +Translations may also have plural forms. `translate` uses the [`Intl` Web API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) to pick the currect plural form for the current `Locale` based on the given `quantity`. + +Fallbacks can also provide plural forms, but will always treated as if we're in locale `en-US`, so you can only provide two different plural forms. + +#### Arguments + +| Name | Description | +|-|-| +| `fullyQualifiedTranslationAddressAsString` | The translation address for the translation to use, e.g.: `"Neos.Neos.Ui:Main:errorBoundary.title"` | +| `fallback` | The string to return, if no translation can be found under the given address. If a tuple of two strings is passed here, these will be treated as singular and plural forms of the translation. | +| `parameters` | Values to replace placeholders in the translation with. This can be passed as an array of strings (to replace numerically indexed placeholders) or as a `Record` (to replace named placeholders) | +| `quantity` | The quantity is used to determine which plural form (if any) to use for the translation | + +#### Examples + +##### Translation without placeholders or plural forms + +```typescript +translate('Neos.Neos.Ui:Main:insert', 'insert'); +// output (en): "insert" +``` + +##### Translation with a numerically indexed placeholder + +```typescript +translate( + 'Neos.Neos:Main:workspaces.allChangesInWorkspaceHaveBeenDiscarded', + 'All changes from workspace "{0}" have been discarded.', + ['user-admin'] +); + +// output (en): All changes from workspace "user-admin" have been discarded. +``` + +##### Translation with a named placeholder + +```typescript +translate( + 'Neos.Neos.Ui:Main:deleteXNodes', + 'Delete {amount} nodes', + {amount: 12} +); + +// output (en): "Delete 12 nodes" +``` + +##### Translations with placeholders and plural forms + +```typescript +translate( + 'Neos.Neos.Ui:Main:changesPublished', + ['Published {0} change to "{1}".', 'Published {0} changes to "{1}".'] + [1, "live"], + 1 +); +// output (en): "Published 1 change to "live"." + +translate( + 'Neos.Neos.Ui:Main:changesPublished', + ['Published {0} change to "{1}".', 'Published {0} changes to "{1}".'] + [20], + 20 +); +// output (en): "Published 20 changes to "live"." +``` + +### `initializeI18n` + +```typescript +async function initializeI18n(): Promise; +``` + +> [!NOTE] +> Usually you won't have to call this function yourself. The Neos UI will +> set up I18n automatically. + +This function loads the translations from the translations endpoint and makes them available globally. It must be run exactly once before any call to `translate`. + +The exact URL of the translations endpoint is discoverd via the DOM. The document needs to have a link tag with the id `neos-ui-uri:/neos/xliff.json`, with the following attributes: +```html + +``` + +The `ApplicationView` PHP class takes care of rendering this tag. + +### `setupI18n` + +```typescript +function setupI18n( + localeIdentifier: string, + pluralRulesAsString: string, + translations: TranslationsDTO +): void; +``` + +This function can be used in unit tests to set up I18n. + +#### Arguments + +| Name | Description | +|-|-| +| `localeIdentifier` | A valid [Unicode Language Identifier](https://www.unicode.org/reports/tr35/#unicode-language-identifier), e.g.: `de-DE`, `en-US`, `ar-EG`, ... | +| `pluralRulesAsString` | A comma-separated list of [Language Plural Rules](http://www.unicode.org/reports/tr35/#Language_Plural_Rules) matching the locale specified by `localeIdentifier`. Here, the output of [`\Neos\Flow\I18n\Cldr\Reader\PluralsReader->getPluralForms()`](https://neos.github.io/flow/9.0/Neos/Flow/I18n/Cldr/Reader/PluralsReader.html#method_getPluralForms) is expected, e.g.: `one,other` for `de-DE`, or `zero,one,two,few,many` for `ar-EG` | +| `translations` | The XLIFF translations in their JSON-serialized form | + +##### `TranslationsDTO` + +```typescript +type TranslationsDTO = { + [serializedPackageKey: string]: { + [serializedSourceName: string]: { + [serializedTransUnitId: string]: string | string[] + } + } +} +``` + +The `TranslationDTO` is the payload of the response from the translations endpoint (see: [`\Neos\Neos\Controller\Backend\BackendController->xliffAsJsonAction()`](https://neos.github.io/neos/9.0/Neos/Neos/Controller/Backend/BackendController.html#method_xliffAsJsonAction)). + +###### Example: + +```jsonc +{ + "Neos_Neos_Ui": { // <- Package Key with "_" instead of "." + "Main": { // <- Source name with "_" instead of "." + + // Example without plural forms + "errorBoundary_title": // <- trans-unit ID with "_" instead of "." + "Sorry, but the Neos UI could not recover from this error.", + + // Example with plural forms + "changesDiscarded": [ // <- trans-unit ID with "_" instead of "." + "Discarded {0} change.", + "Discarded {0} changes." + ] + } + } +} +``` + +### `teardownI18n` + +```typescript +function teardownI18n(): void; +``` + +This function must be used in unit tests to clean up when `setupI18n` has been used. diff --git a/packages/neos-ui-i18n/package.json b/packages/neos-ui-i18n/package.json index 76232a5cb2..19255b5256 100644 --- a/packages/neos-ui-i18n/package.json +++ b/packages/neos-ui-i18n/package.json @@ -3,15 +3,13 @@ "version": "", "description": "I18n utilities and components for Neos CMS UI.", "private": true, - "main": "./src/index.tsx", + "main": "./src/index.ts", "devDependencies": { "@neos-project/jest-preset-neos-ui": "workspace:*", "enzyme": "^3.8.0", "typescript": "^4.6.4" }, "dependencies": { - "@neos-project/neos-ts-interfaces": "workspace:*", - "@neos-project/neos-ui-decorators": "workspace:*", "@neos-project/neos-ui-extensibility": "workspace:*", "@neos-project/utils-logger": "workspace:*" }, diff --git a/packages/neos-ui-i18n/src/component/I18n.spec.tsx b/packages/neos-ui-i18n/src/component/I18n.spec.tsx new file mode 100644 index 0000000000..3d17410c21 --- /dev/null +++ b/packages/neos-ui-i18n/src/component/I18n.spec.tsx @@ -0,0 +1,41 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +import {mount} from 'enzyme'; + +import {i18nRegistry} from '../registry'; + +import {I18n} from './I18n'; + +describe('', () => { + beforeEach(() => { + jest.spyOn(i18nRegistry, 'translate'); + (jest as any) + .mocked(i18nRegistry.translate) + .mockImplementation((key: string) => { + return key; + }); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it(`should render a node.`, () => { + const original = mount(); + + expect(original.html()).toBe(''); + }); + + it(`should call translation service with key.`, () => { + const original = mount(); + + expect(original.html()).toBe('My key'); + }); +}); diff --git a/packages/neos-ui-i18n/src/component/I18n.tsx b/packages/neos-ui-i18n/src/component/I18n.tsx new file mode 100644 index 0000000000..e14b6d353a --- /dev/null +++ b/packages/neos-ui-i18n/src/component/I18n.tsx @@ -0,0 +1,43 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import {LegacyParameters, i18nRegistry} from '../registry'; + +interface I18nProps { + // Fallback key which gets rendered once the i18n service doesn't return a translation. + fallback?: string; + + // The target id which the i18n service accepts. + id?: string; + + // The destination paths for the package and source of the translation. + packageKey?: string; + sourceName?: string; + + // Additional parameters which are passed to the i18n service. + params?: LegacyParameters; + + // Optional className which gets added to the translation span. + className?: string; +} + +/** + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + */ +export class I18n extends React.PureComponent { + public render(): JSX.Element { + const {packageKey, sourceName, params, id, fallback} = this.props; + + return ( + {i18nRegistry.translate(id ?? '', fallback, params, packageKey ?? 'Neos.Neos', sourceName ?? 'Main')} + ); + } +} diff --git a/packages/neos-ui-i18n/src/component/index.ts b/packages/neos-ui-i18n/src/component/index.ts new file mode 100644 index 0000000000..94f93367d8 --- /dev/null +++ b/packages/neos-ui-i18n/src/component/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {I18n} from './I18n'; diff --git a/packages/neos-ui-i18n/src/global/globals.spec.ts b/packages/neos-ui-i18n/src/global/globals.spec.ts new file mode 100644 index 0000000000..8623ed1bb0 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/globals.spec.ts @@ -0,0 +1,45 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {GlobalsRuntimeContraintViolation, requireGlobals, setGlobals, unsetGlobals} from './globals'; + +describe('globals', () => { + afterEach(() => { + unsetGlobals(); + }); + + test('requireGlobals throws when globals are not initialized yet', () => { + expect(() => requireGlobals()) + .toThrow( + GlobalsRuntimeContraintViolation + .becauseGlobalsWereRequiredButHaveNotBeenSetYet() + ); + }); + + test('setGlobals sets the current globals ', () => { + setGlobals('foo' as any); + expect(requireGlobals()).toBe('foo'); + }); + + test('setGlobals throws if run multiple times', () => { + setGlobals('foo' as any); + expect(() => setGlobals('bar' as any)) + .toThrow( + GlobalsRuntimeContraintViolation + .becauseGlobalsWereAttemptedToBeSetMoreThanOnce() + ); + }); + + test('unsetGlobals allows to run setGlobals again', () => { + setGlobals('foo' as any); + unsetGlobals(); + setGlobals('bar' as any); + expect(requireGlobals()).toBe('bar'); + }); +}); diff --git a/packages/neos-ui-i18n/src/global/globals.ts b/packages/neos-ui-i18n/src/global/globals.ts new file mode 100644 index 0000000000..d926933f6e --- /dev/null +++ b/packages/neos-ui-i18n/src/global/globals.ts @@ -0,0 +1,64 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {Locale, TranslationRepository} from '../model'; + +export const globals = { + current: null as null | { + locale: Locale; + translationRepository: TranslationRepository; + } +}; + +export function requireGlobals(): NonNullable<(typeof globals)['current']> { + if (globals.current === null) { + throw GlobalsRuntimeContraintViolation + .becauseGlobalsWereRequiredButHaveNotBeenSetYet(); + } + + return globals.current; +} + +export function setGlobals(value: NonNullable<(typeof globals)['current']>) { + if (globals.current === null) { + globals.current = value; + return; + } + + throw GlobalsRuntimeContraintViolation + .becauseGlobalsWereAttemptedToBeSetMoreThanOnce(); +} + +export function unsetGlobals() { + globals.current = null; +} + +export class GlobalsRuntimeContraintViolation extends Error { + private constructor(message: string) { + super(message); + } + + public static becauseGlobalsWereRequiredButHaveNotBeenSetYet = () => + new GlobalsRuntimeContraintViolation( + 'Globals for "@neos-project/neos-ui-i18n" are not available,' + + ' because they have not been initialized yet. Make sure to run' + + ' `loadI18n` or `setupI18n` (for testing).' + ); + + public static becauseGlobalsWereAttemptedToBeSetMoreThanOnce = () => + new GlobalsRuntimeContraintViolation( + 'Globals for "@neos-project/neos-ui-i18n" have already been set. ' + + ' Make sure to only run one of `loadI18n` or `setupI18n` (for' + + ' testing). Neither function must ever be called more than' + + ' once, unless you are in a testing scenario. Then you are' + + ' allowed to run `teardownI18n` to reset the globals, after' + + ' which you can run `setupI18n` to test for a different set of' + + ' translations.' + ); +} diff --git a/packages/neos-ui-i18n/src/global/index.ts b/packages/neos-ui-i18n/src/global/index.ts new file mode 100644 index 0000000000..6250e8b255 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/index.ts @@ -0,0 +1,13 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {initializeI18n} from './initializeI18n'; +export {requireGlobals} from './globals'; +export {setupI18n} from './setupI18n'; +export {teardownI18n} from './teardownI18n'; diff --git a/packages/neos-ui-i18n/src/global/initializeI18n.spec.ts b/packages/neos-ui-i18n/src/global/initializeI18n.spec.ts new file mode 100644 index 0000000000..5c1a634de4 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/initializeI18n.spec.ts @@ -0,0 +1,196 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {translate} from '../translate'; + +import {I18nCouldNotBeInitialized, initializeI18n} from './initializeI18n'; +import {teardownI18n} from './teardownI18n'; + +describe('initializeI18n', () => { + beforeEach(() => { + const server: typeof fetch = (input, init) => { + expect(init?.credentials).toBe('include'); + + const request = new Request(input, init); + const url = new URL(request.url); + + switch (url.pathname) { + case '/neos/xliff.json': + return Promise.resolve( + new Response(JSON.stringify({ + Neos_Neos_Ui: { + Main: { + 'some_trans-unit_id': + 'This is the translation' + } + } + }), {headers: {'Content-Type': 'application/json'}}) + ); + default: + return Promise.resolve(Response.error()); + } + }; + jest.spyOn(global, 'fetch' as any).mockImplementation(server as any); + }); + + afterEach(() => { + jest.resetAllMocks(); + teardownI18n(); + }); + + it('loads the translation from the location specified in the current HTML document', async () => { + document.head.innerHTML = ` + + `; + + await initializeI18n(); + + expect(translate('Neos.Neos.Ui:Main:some.trans-unit.id', 'This is the fallback')) + .toBe('This is the translation'); + }); + + it('rejects when i18n route link cannot be found', () => { + // no tag at all + document.head.innerHTML = ''; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkCouldNotBeFound() + ); + + // link tag, but id is missing + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkCouldNotBeFound() + ); + + // metag tag instead of link tag + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkCouldNotBeFound() + ); + }); + + it('rejects when i18n route link has no "href" attribute', () => { + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkHasNoHref() + ); + }); + + it('rejects when i18n route link does not provide a valid URL has "href"', () => { + // empty + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized + .becauseRouteLinkHrefIsNotAValidURL('') + ); + + // not a URL at all + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized + .becauseRouteLinkHrefIsNotAValidURL('something something') + ); + + // relative URL instead of absolute + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized + .becauseRouteLinkHrefIsNotAValidURL('/neos/xliff.json?locale=en-US') + ); + }); + + it('rejects when i18n route link has no "data-locale" attribute', () => { + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkHasNoLocale() + ); + }); + + it('rejects when i18n route link has no "data-locale-plural-rules" attribute', () => { + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkHasNoPluralRules() + ); + }); +}); diff --git a/packages/neos-ui-i18n/src/global/initializeI18n.ts b/packages/neos-ui-i18n/src/global/initializeI18n.ts new file mode 100644 index 0000000000..eb428c68ba --- /dev/null +++ b/packages/neos-ui-i18n/src/global/initializeI18n.ts @@ -0,0 +1,113 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {setupI18n} from './setupI18n'; + +const LINK_ID_FOR_I18N_ROUTE = 'neos-ui-uri:/neos/xliff.json'; + +/** + * @summary Initializes the Neos UI i18n mechanism globally + * @description + * Given a prepared HTML document that contains a -tag with the id + * "neos-ui-uri:/neos/xliff.json", this function will load translations from + * the server endpoint specified in that tag's "href"-attribute. + * + * It will then set up the Neos UI i18n mechanism globally, with the locale + * provided in the -tag's "data-locale"-attribute, and the plural rule in + * the order specified in the "data-locale-plural-rules"-attribute. + */ +export async function initializeI18n(): Promise { + const link = getLinkTag(); + const href = getHrefFromLinkTag(link); + const locale = getLocaleFromLinkTag(link); + const pluralRules = getPluralRulesFromLinkTag(link); + + const response = await fetch(href.toString(), {credentials: 'include'}); + const translations = await response.json(); + + setupI18n(locale, pluralRules, translations); +} + +function getPluralRulesFromLinkTag(link: HTMLLinkElement) { + const pluralRules = link?.dataset.localePluralRules; + if (pluralRules === undefined) { + throw I18nCouldNotBeInitialized + .becauseRouteLinkHasNoPluralRules(); + } + return pluralRules; +} + +function getLocaleFromLinkTag(link: HTMLLinkElement) { + const locale = link?.dataset.locale; + if (locale === undefined) { + throw I18nCouldNotBeInitialized + .becauseRouteLinkHasNoLocale(); + } + return locale; +} + +function getLinkTag() { + const link = document.getElementById(LINK_ID_FOR_I18N_ROUTE); + if (link === null || !(link instanceof HTMLLinkElement)) { + throw I18nCouldNotBeInitialized + .becauseRouteLinkCouldNotBeFound(); + } + return link; +} + +function getHrefFromLinkTag(link: HTMLLinkElement): URL { + const href = link?.getAttribute('href'); + if (href === null) { + throw I18nCouldNotBeInitialized + .becauseRouteLinkHasNoHref(); + } + + try { + return new URL(href); + } catch { + throw I18nCouldNotBeInitialized + .becauseRouteLinkHrefIsNotAValidURL(href); + } +} + +export class I18nCouldNotBeInitialized extends Error { + private constructor(message: string) { + super(`I18n could not be initialized, because ${message}`); + } + + public static becauseRouteLinkCouldNotBeFound = () => + new I18nCouldNotBeInitialized( + `this document has no -Tag with id "${LINK_ID_FOR_I18N_ROUTE}".` + ); + + public static becauseRouteLinkHasNoHref = () => + new I18nCouldNotBeInitialized( + `the found -Tag with id "${LINK_ID_FOR_I18N_ROUTE}" is` + + ` missing an "href"-attribute.` + ); + + public static becauseRouteLinkHrefIsNotAValidURL = (attemptedValue: string) => + new I18nCouldNotBeInitialized( + `the "href"-attribute of the -Tag with id "${LINK_ID_FOR_I18N_ROUTE}"` + + ` must be a valid, absolute URL, but was "${attemptedValue}".` + ); + + public static becauseRouteLinkHasNoLocale = () => + new I18nCouldNotBeInitialized( + `the found -Tag with id "${LINK_ID_FOR_I18N_ROUTE}" is` + + ` missing a "data-locale"-attribute.` + ); + + public static becauseRouteLinkHasNoPluralRules = () => + new I18nCouldNotBeInitialized( + `the found -Tag with id "${LINK_ID_FOR_I18N_ROUTE}" is` + + ` missing a "data-locale-plural-rules"-attribute.` + ); +} + diff --git a/packages/neos-ui-i18n/src/global/setupI18n.spec.ts b/packages/neos-ui-i18n/src/global/setupI18n.spec.ts new file mode 100644 index 0000000000..c1b4efe2f9 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/setupI18n.spec.ts @@ -0,0 +1,45 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {Locale, TranslationRepository} from '../model'; +import {requireGlobals, unsetGlobals} from './globals'; +import {setupI18n} from './setupI18n'; + +describe('setupI18n', () => { + afterEach(() => { + unsetGlobals(); + }); + + it('registers a global locale and sets up a global translation repository', () => { + setupI18n('en-US', 'one,other', { + 'Neos_Neos_Ui': { + 'Main': { + 'trans-unit_id': 'Some Translation' + } + } + }); + + const {locale, translationRepository} = requireGlobals(); + + expect(locale).toStrictEqual(Locale.create('en-US', 'one,other')); + expect(translationRepository) + .toStrictEqual( + TranslationRepository.fromDTO( + Locale.create('en-US', 'one,other'), + { + 'Neos_Neos_Ui': { + 'Main': { + 'trans-unit_id': 'Some Translation' + } + } + } + ) + ); + }); +}); diff --git a/packages/neos-ui-i18n/src/global/setupI18n.ts b/packages/neos-ui-i18n/src/global/setupI18n.ts new file mode 100644 index 0000000000..ac390a8317 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/setupI18n.ts @@ -0,0 +1,37 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {Locale, TranslationRepository, type TranslationsDTO} from '../model'; + +import {setGlobals} from './globals'; + +/** + * Sets up the application-wide globals for translation. + * + * You may use this function for setting up translations in a testing scenario. + * Make sure to run teardownI18n to clean up the globals after your testing + * scenario is finished. + * + * @param {string} localeIdentifier The locale identifier (e.g. "en-US") + * @param {string} pluralRulesAsString Comma-separated list of plural rules (each one of: "zero", "one", "two", "few", "many" or "other") + * @param {TranslationsDTO} translations The translations as provided by the /neos/xliff.json endpoint + */ +export function setupI18n( + localeIdentifier: string, + pluralRulesAsString: string, + translations: TranslationsDTO +): void { + const locale = Locale.create(localeIdentifier, pluralRulesAsString); + const translationRepository = TranslationRepository.fromDTO( + locale, + translations + ); + + setGlobals({locale, translationRepository}); +} diff --git a/packages/neos-ui-i18n/src/global/teardownI18n.spec.ts b/packages/neos-ui-i18n/src/global/teardownI18n.spec.ts new file mode 100644 index 0000000000..a0cc21b88d --- /dev/null +++ b/packages/neos-ui-i18n/src/global/teardownI18n.spec.ts @@ -0,0 +1,34 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {globals, unsetGlobals} from './globals'; +import {setupI18n} from './setupI18n'; +import {teardownI18n} from './teardownI18n'; + +describe('teardownI18n', () => { + afterEach(() => { + unsetGlobals(); + }); + + it('unsets the previously registered locale and translation repository', () => { + setupI18n('en-US', 'one,other', { + 'Neos_Neos_Ui': { + 'Main': { + 'trans-unit_id': 'Some Translation' + } + } + }); + + expect(globals.current).not.toBeNull(); + + teardownI18n(); + + expect(globals.current).toBeNull(); + }); +}); diff --git a/packages/neos-ui-i18n/src/global/teardownI18n.ts b/packages/neos-ui-i18n/src/global/teardownI18n.ts new file mode 100644 index 0000000000..cc3c557a96 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/teardownI18n.ts @@ -0,0 +1,20 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {unsetGlobals} from './globals'; + +/** + * Unsets the previously registered locale and translations + * + * You may use this function for cleaning up after running setupI18n in a + * testing scenario. + */ +export function teardownI18n(): void { + unsetGlobals(); +} diff --git a/packages/neos-ui-i18n/src/index.spec.js b/packages/neos-ui-i18n/src/index.spec.js deleted file mode 100644 index a749a39785..0000000000 --- a/packages/neos-ui-i18n/src/index.spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import {mount} from 'enzyme'; - -import I18n from './index'; - -const FakeRegistry = { - translate(key) { - return key; - } -}; - -test(` should render a node.`, () => { - const original = mount(); - - expect(original.html()).toBe(''); -}); - -test(` should call translation service with key.`, () => { - const original = mount(); - - expect(original.html()).toBe('My key'); -}); diff --git a/packages/neos-ui-i18n/src/index.ts b/packages/neos-ui-i18n/src/index.ts new file mode 100644 index 0000000000..ed57b10ba3 --- /dev/null +++ b/packages/neos-ui-i18n/src/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {I18n as default} from './component'; + +export {initializeI18n, setupI18n, teardownI18n} from './global'; + +export type {I18nRegistry} from './registry'; + +export {translate} from './translate'; diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx deleted file mode 100644 index e0ca2540af..0000000000 --- a/packages/neos-ui-i18n/src/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import {neos} from '@neos-project/neos-ui-decorators'; -import {GlobalRegistry} from '@neos-project/neos-ts-interfaces'; -import {NeosInjectedProps} from '@neos-project/neos-ui-decorators/src/neos'; - -const regsToProps = (globalRegistry: GlobalRegistry) => ({ - i18nRegistry: globalRegistry.get('i18n') -}); -type InjectedProps = NeosInjectedProps; - -interface I18nProps { - // Fallback key which gets rendered once the i18n service doesn't return a translation. - fallback?: string; - - // The target id which the i18n service accepts. - id?: string; - - // The destination paths for the package and source of the translation. - packageKey?: string; - sourceName?: string; - - // Additional parameters which are passed to the i18n service. - params?: object; - - // Optional className which gets added to the translation span. - className?: string; -} - -class I18n extends React.PureComponent { - public render(): JSX.Element { - const {i18nRegistry, packageKey, sourceName, params, id, fallback} = this.props; - - return ( - {i18nRegistry.translate(id, fallback, params, packageKey, sourceName)} - ); - } -} - -export default neos(regsToProps)(I18n); diff --git a/packages/neos-ui-i18n/src/manifest.js b/packages/neos-ui-i18n/src/manifest.js index c7e0d91b44..920ed97519 100644 --- a/packages/neos-ui-i18n/src/manifest.js +++ b/packages/neos-ui-i18n/src/manifest.js @@ -1,14 +1,7 @@ import manifest from '@neos-project/neos-ui-extensibility'; -import {I18nRegistry} from './registry/index'; +import {i18nRegistry} from './registry'; manifest('@neos-project/neos-ui-i18n', {}, globalRegistry => { - globalRegistry.set( - 'i18n', - new I18nRegistry(` - # Registry for Internationalization / Localization - - Has one public method "translate()" which can be used to translate strings. - `) - ); + globalRegistry.set('i18n', i18nRegistry); }); diff --git a/packages/neos-ui-i18n/src/model/Locale.spec.ts b/packages/neos-ui-i18n/src/model/Locale.spec.ts new file mode 100644 index 0000000000..be42536117 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Locale.spec.ts @@ -0,0 +1,50 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {InvalidLocale, Locale} from './Locale'; +import {InvalidPluralRules} from './PluralRules'; + +describe('Locale', () => { + it('throws when attempted to be created with invalid locale identifier', () => { + expect(() => Locale.create('an invalid identifier', 'one,other')) + .toThrow(InvalidLocale.becauseOfInvalidIdentifier('an invalid identifier')); + }); + + it('throws when attempted to be created with invalid plural forms', () => { + expect(() => Locale.create('en-US', '')) + .toThrow(InvalidLocale.becauseOfInvalidPluralRules('en-US', InvalidPluralRules.becauseTheyAreEmpty())); + }); + + describe('#getPluralFormIndexForQuantity', () => { + it('provides the index for lookup of the correct plural form given a quantity', () => { + const locale_en_US = Locale.create('en-US', 'one,other'); + const locale_ar_EG = Locale.create('ar-EG', 'zero,one,two,few,many'); + + expect(locale_en_US.getPluralFormIndexForQuantity(0)) + .toBe(1); + expect(locale_en_US.getPluralFormIndexForQuantity(1)) + .toBe(0); + expect(locale_en_US.getPluralFormIndexForQuantity(2)) + .toBe(1); + expect(locale_en_US.getPluralFormIndexForQuantity(3)) + .toBe(1); + + expect(locale_ar_EG.getPluralFormIndexForQuantity(0)) + .toBe(0); + expect(locale_ar_EG.getPluralFormIndexForQuantity(1)) + .toBe(1); + expect(locale_ar_EG.getPluralFormIndexForQuantity(2)) + .toBe(2); + expect(locale_ar_EG.getPluralFormIndexForQuantity(6)) + .toBe(3); + expect(locale_ar_EG.getPluralFormIndexForQuantity(18)) + .toBe(4); + }); + }); +}); diff --git a/packages/neos-ui-i18n/src/model/Locale.ts b/packages/neos-ui-i18n/src/model/Locale.ts new file mode 100644 index 0000000000..72a7a910ed --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Locale.ts @@ -0,0 +1,66 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {PluralRule} from './PluralRule'; +import {InvalidPluralRules, PluralRules} from './PluralRules'; + +export class Locale { + private readonly intlPluralRules: Intl.PluralRules; + + private constructor( + private readonly intlLocale: Intl.Locale, + private readonly pluralRules: PluralRules + ) { + this.intlPluralRules = new Intl.PluralRules(this.intlLocale.toString()); + } + + public static create = (identifier: string, pluralRulesAsString: string): Locale => { + let intlLocale: Intl.Locale; + try { + intlLocale = new Intl.Locale(identifier) + } catch { + throw InvalidLocale.becauseOfInvalidIdentifier(identifier); + } + + let pluralRules: PluralRules; + try { + pluralRules = PluralRules.fromString(pluralRulesAsString); + } catch (error) { + throw InvalidLocale.becauseOfInvalidPluralRules( + identifier, + error as InvalidPluralRules + ); + } + + return new Locale(intlLocale, pluralRules); + } + + public getPluralFormIndexForQuantity(quantity: number): number { + return this.pluralRules.getIndexOf( + PluralRule.fromString( + this.intlPluralRules.select(quantity) + ) + ); + } +} + +export class InvalidLocale extends Error { + private constructor( + message: string, + public readonly cause?: InvalidPluralRules + ) { + super(message); + } + + public static becauseOfInvalidIdentifier = (attemptedIdentifier: string): InvalidLocale => + new InvalidLocale(`"${attemptedIdentifier}" is not a valid locale identifier. It must pass as a sole argument to new Intl.Locale(...). Please consult https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale for further information.`); + + public static becauseOfInvalidPluralRules = (identifier: string, cause: InvalidPluralRules): InvalidLocale => + new InvalidLocale(`Locale "${identifier}" could not be initialized because of invalid plural forms: ${cause.message}`, cause); +} diff --git a/packages/neos-ui-i18n/src/model/Parameters.ts b/packages/neos-ui-i18n/src/model/Parameters.ts new file mode 100644 index 0000000000..ac8f2618cb --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Parameters.ts @@ -0,0 +1,14 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export type Parameters = + | ParameterValue[] + | Record; + +type ParameterValue = number | string; diff --git a/packages/neos-ui-i18n/src/model/PluralRule.spec.ts b/packages/neos-ui-i18n/src/model/PluralRule.spec.ts new file mode 100644 index 0000000000..76cca063f2 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/PluralRule.spec.ts @@ -0,0 +1,39 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {PluralRule, InvalidPluralRule} from './PluralRule'; + +describe('PluralRule', () => { + it('can be created from string', () => { + expect(PluralRule.fromString('zero')) + .toBe(PluralRule.ZERO); + expect(PluralRule.fromString('one')) + .toBe(PluralRule.ONE); + expect(PluralRule.fromString('two')) + .toBe(PluralRule.TWO); + expect(PluralRule.fromString('few')) + .toBe(PluralRule.FEW); + expect(PluralRule.fromString('many')) + .toBe(PluralRule.MANY); + expect(PluralRule.fromString('other')) + .toBe(PluralRule.OTHER); + }); + + it('throws when attempted to be created from an empty string', () => { + expect(() => PluralRule.fromString('')) + .toThrow(InvalidPluralRule.becauseItIsEmpty()); + }); + + it('throws when attempted to be created from an invalid string', () => { + expect(() => PluralRule.fromString('does-not-exist')) + .toThrow(InvalidPluralRule.becauseItIsUnknown('does-not-exist')); + expect(() => PluralRule.fromString('ZeRo')) + .toThrow(InvalidPluralRule.becauseItIsUnknown('ZeRo')); + }); +}); diff --git a/packages/neos-ui-i18n/src/model/PluralRule.ts b/packages/neos-ui-i18n/src/model/PluralRule.ts new file mode 100644 index 0000000000..20bdab60c8 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/PluralRule.ts @@ -0,0 +1,66 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +/** + * Plural case as per Unicode CLDR: + * https://cldr.unicode.org/index/cldr-spec/plural-rules + */ +export class PluralRule { + private constructor(public readonly value: string) {} + + public static readonly ZERO = new PluralRule('zero'); + + public static readonly ONE = new PluralRule('one'); + + public static readonly TWO = new PluralRule('two'); + + public static readonly FEW = new PluralRule('few'); + + public static readonly MANY = new PluralRule('many'); + + public static readonly OTHER = new PluralRule('other'); + + public static fromString = (string: string): PluralRule => { + if (string === '') { + throw InvalidPluralRule.becauseItIsEmpty(); + } + + switch (string) { + case 'zero': + return PluralRule.ZERO; + case 'one': + return PluralRule.ONE; + case 'two': + return PluralRule.TWO; + case 'few': + return PluralRule.FEW; + case 'many': + return PluralRule.MANY; + case 'other': + return PluralRule.OTHER; + default: + throw InvalidPluralRule.becauseItIsUnknown(string); + } + } +} + +export class InvalidPluralRule extends Error { + private constructor(message: string) { + super(message); + } + + public static becauseItIsEmpty = (): InvalidPluralRule => + new InvalidPluralRule(`PluralRule must be one of "zero", "one", "two", "few", "many" +or "other", but was empty.`); + + public static becauseItIsUnknown = (attemptedString: string): InvalidPluralRule => + new InvalidPluralRule(`PluralRule must be one of "zero", "one", "two", "few", "many" +or "other". Got "${attemptedString}" instead.`); +} diff --git a/packages/neos-ui-i18n/src/model/PluralRules.spec.ts b/packages/neos-ui-i18n/src/model/PluralRules.spec.ts new file mode 100644 index 0000000000..bcf0c0e6b0 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/PluralRules.spec.ts @@ -0,0 +1,50 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {InvalidPluralRule, PluralRule} from './PluralRule'; +import {InvalidPluralRules, PluralRules} from './PluralRules'; + +describe('PluralRules', () => { + it('can be created from string', () => { + expect(PluralRules.fromString('one,other')) + .toStrictEqual(PluralRules.of(PluralRule.ONE, PluralRule.OTHER)); + + expect(PluralRules.fromString('one,two,few,many,other')) + .toStrictEqual(PluralRules.of(PluralRule.ONE, PluralRule.TWO, PluralRule.FEW, PluralRule.MANY, PluralRule.OTHER)); + }); + + it('throws when attempted to be created from an empty string', () => { + expect(() => PluralRules.fromString('')) + .toThrow(InvalidPluralRules.becauseTheyAreEmpty()); + }); + + it('throws when attempted to be created from an invalid string', () => { + expect(() => PluralRules.fromString(',,,')) + .toThrow(InvalidPluralRules.becauseOfInvalidPluralRule(0, InvalidPluralRule.becauseItIsEmpty())); + expect(() => PluralRules.fromString('one,two,twenty,other')) + .toThrow(InvalidPluralRules.becauseOfInvalidPluralRule(2, InvalidPluralRule.becauseItIsUnknown('twenty'))); + }); + + describe('#getIndexOf', () => { + it('returns the index of the given plural case', () => { + const pluralRules = PluralRules.fromString('one,two,few,many,other'); + + expect(pluralRules.getIndexOf(PluralRule.ONE)) + .toBe(0); + expect(pluralRules.getIndexOf(PluralRule.TWO)) + .toBe(1); + expect(pluralRules.getIndexOf(PluralRule.FEW)) + .toBe(2); + expect(pluralRules.getIndexOf(PluralRule.MANY)) + .toBe(3); + expect(pluralRules.getIndexOf(PluralRule.OTHER)) + .toBe(4); + }); + }) +}); diff --git a/packages/neos-ui-i18n/src/model/PluralRules.ts b/packages/neos-ui-i18n/src/model/PluralRules.ts new file mode 100644 index 0000000000..b53eb3d13e --- /dev/null +++ b/packages/neos-ui-i18n/src/model/PluralRules.ts @@ -0,0 +1,52 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +import {InvalidPluralRule, PluralRule} from './PluralRule'; + +/** + * A list of plural cases + * @internal + */ +export class PluralRules { + private constructor(public readonly value: PluralRule[]) {} + + public static of = (...cases: PluralRule[]) => + new PluralRules(cases); + + public static fromString = (string: string): PluralRules => { + if (string === '') { + throw InvalidPluralRules.becauseTheyAreEmpty(); + } + + return new PluralRules(string.split(',').map((string, index) => { + try { + return PluralRule.fromString(string) + } catch (error) { + throw InvalidPluralRules.becauseOfInvalidPluralRule(index, error as InvalidPluralRule); + } + })); + } + + public getIndexOf(pluralRule: PluralRule): number { + return this.value.indexOf(pluralRule); + } +} + +export class InvalidPluralRules extends Error { + private constructor(message: string, public readonly cause?: InvalidPluralRule) { + super(message); + } + + public static becauseTheyAreEmpty = (): InvalidPluralRules => + new InvalidPluralRules(`PluralRules must not be empty, but were.`); + + public static becauseOfInvalidPluralRule = (index: number, cause: InvalidPluralRule): InvalidPluralRules => + new InvalidPluralRules(`PluralRules contain invalid value at index ${index}: ${cause.message}`, cause); +} diff --git a/packages/neos-ui-i18n/src/model/Translation.spec.ts b/packages/neos-ui-i18n/src/model/Translation.spec.ts new file mode 100644 index 0000000000..04cf77b92a --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Translation.spec.ts @@ -0,0 +1,142 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {Locale} from './Locale'; +import {Translation} from './Translation'; + +describe('Translation', () => { + const locale_en_US = Locale.create('en-US', 'one,other'); + + it('can be created from a defective DTO', () => { + const translation = Translation.fromDTO(locale_en_US, [ + 'This translation has only a singular form, despite its DTO being an array.' + ]); + + expect(translation.render(undefined, 24)) + .toBe('This translation has only a singular form, despite its DTO being an array.'); + }); + + describe('having a singular form only', () => { + it('renders a translation string without placeholders and quantity = 0', () => { + const translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and no placeholders.' + ); + + expect(translation.render(undefined, 0)) + .toBe('This translation has only a singular form and no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity = 1', () => { + const translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and no placeholders.' + ); + + expect(translation.render(undefined, 1)) + .toBe('This translation has only a singular form and no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity > 1', () => { + const translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and no placeholders.' + ); + + expect(translation.render(undefined, 42)) + .toBe('This translation has only a singular form and no placeholders.'); + }); + + it('renders a translation string with placeholders and quantity = 0', () => { + const translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and {some} placeholder.' + ); + + expect(translation.render({some: 'one'}, 0)) + .toBe('This translation has only a singular form and one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity = 1', () => { + const translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and {some} placeholder.' + ); + + expect(translation.render({some: 'one'}, 1)) + .toBe('This translation has only a singular form and one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity > 1', () => { + const translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and {some} placeholder.' + ); + + expect(translation.render({some: 'one'}, 42)) + .toBe('This translation has only a singular form and one placeholder.'); + }); + }); + + describe('having a singular and a plural form', () => { + it('renders a translation string without placeholders and quantity = 0', () => { + const translation = Translation.fromDTO(locale_en_US, [ + 'This translation has a singular form with no placeholders.', + 'This translation has a plural form with no placeholders.' + ]); + + expect(translation.render(undefined, 0)) + .toBe('This translation has a plural form with no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity = 1', () => { + const translation = Translation.fromDTO(locale_en_US, [ + 'This translation has a singular form with no placeholders.', + 'This translation has a plural form with no placeholders.' + ]); + + expect(translation.render(undefined, 1)) + .toBe('This translation has a singular form with no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity > 1', () => { + const translation = Translation.fromDTO(locale_en_US, [ + 'This translation has a singular form with no placeholders.', + 'This translation has a plural form with no placeholders.' + ]); + + expect(translation.render(undefined, 42)) + .toBe('This translation has a plural form with no placeholders.'); + }); + + it('renders a translation string with placeholders and quantity = 0', () => { + const translation = Translation.fromDTO(locale_en_US, [ + 'This translation has a singular form with {some} placeholder.', + 'This translation has a plural form with {some} placeholder.' + ]); + + expect(translation.render({some: 'one'}, 0)) + .toBe('This translation has a plural form with one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity = 1', () => { + const translation = Translation.fromDTO(locale_en_US, [ + 'This translation has a singular form with {some} placeholder.', + 'This translation has a plural form with {some} placeholder.' + ]); + + expect(translation.render({some: 'one'}, 1)) + .toBe('This translation has a singular form with one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity > 1', () => { + const translation = Translation.fromDTO(locale_en_US, [ + 'This translation has a singular form with {some} placeholder.', + 'This translation has a plural form with {some} placeholder.' + ]); + + expect(translation.render({some: 'one'}, 42)) + .toBe('This translation has a plural form with one placeholder.'); + }); + }); +}); diff --git a/packages/neos-ui-i18n/src/model/Translation.ts b/packages/neos-ui-i18n/src/model/Translation.ts new file mode 100644 index 0000000000..f9796388ac --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Translation.ts @@ -0,0 +1,48 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +import {substitutePlaceholders} from '../registry/substitutePlaceholders'; + +import {Locale} from './Locale'; +import type {Parameters} from './Parameters'; + +export type TranslationDTO = string | TranslationDTOTuple; +type TranslationDTOTuple = string[] | Record; + +export class Translation { + private constructor( + private readonly locale: Locale, + private readonly value: string[] + ) { + } + + public static fromDTO = (locale: Locale, dto: TranslationDTO): Translation => + dto instanceof Object + ? Translation.fromTuple(locale, dto) + : Translation.fromString(locale, dto); + + private static fromTuple = (locale: Locale, tuple: TranslationDTOTuple): Translation => + new Translation(locale, Object.values(tuple)); + + private static fromString = (locale: Locale, string: string): Translation => + new Translation(locale, [string]); + + public render(parameters: undefined | Parameters, quantity: number): string { + return parameters + ? substitutePlaceholders(this.byQuantity(quantity), parameters) + : this.byQuantity(quantity); + } + + private byQuantity(quantity: number): string { + const index = this.locale.getPluralFormIndexForQuantity(quantity); + + return this.value[index] ?? this.value[0] ?? ''; + } +} diff --git a/packages/neos-ui-i18n/src/model/TranslationAddress.spec.ts b/packages/neos-ui-i18n/src/model/TranslationAddress.spec.ts new file mode 100644 index 0000000000..f10ae30a7f --- /dev/null +++ b/packages/neos-ui-i18n/src/model/TranslationAddress.spec.ts @@ -0,0 +1,44 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {TranslationAddress, TranslationAddressIsInvalid} from './TranslationAddress'; + +describe('TranslationAddress', () => { + it('can be created from parts', () => { + const translationAddress = TranslationAddress.create({ + id: 'some.transunit.id', + sourceName: 'SomeSource', + packageKey: 'Some.Package' + }); + + expect(translationAddress.id).toBe('some.transunit.id'); + expect(translationAddress.sourceName).toBe('SomeSource'); + expect(translationAddress.packageKey).toBe('Some.Package'); + expect(translationAddress.fullyQualified).toBe('Some.Package:SomeSource:some.transunit.id'); + }); + + it('can be created from string', () => { + const translationAddress = TranslationAddress.fromString( + 'Some.Package:SomeSource:some.transunit.id' + ); + + expect(translationAddress.id).toBe('some.transunit.id'); + expect(translationAddress.sourceName).toBe('SomeSource'); + expect(translationAddress.packageKey).toBe('Some.Package'); + expect(translationAddress.fullyQualified).toBe('Some.Package:SomeSource:some.transunit.id'); + }); + + it('throws if given an invalid string', () => { + expect(() => TranslationAddress.fromString('foo bar')) + .toThrow( + TranslationAddressIsInvalid + .becauseStringDoesNotAdhereToExpectedFormat('foo bar') + ); + }); +}); diff --git a/packages/neos-ui-i18n/src/model/TranslationAddress.ts b/packages/neos-ui-i18n/src/model/TranslationAddress.ts new file mode 100644 index 0000000000..a563b3889b --- /dev/null +++ b/packages/neos-ui-i18n/src/model/TranslationAddress.ts @@ -0,0 +1,52 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +const TRANSLATION_ADDRESS_SEPARATOR = ':'; + +export class TranslationAddress { + private constructor( + public readonly id: string, + public readonly sourceName: string, + public readonly packageKey: string, + public readonly fullyQualified: string + ) {} + + public static create = (props: { + id: string; + sourceName: string; + packageKey: string; + }): TranslationAddress => + new TranslationAddress(props.id, props.sourceName, props.packageKey, `${props.packageKey}:${props.sourceName}:${props.id}`); + + public static fromString = (string: string): TranslationAddress => { + const parts = string.split(TRANSLATION_ADDRESS_SEPARATOR); + if (parts.length !== 3) { + throw TranslationAddressIsInvalid + .becauseStringDoesNotAdhereToExpectedFormat(string); + } + + const [packageKey, sourceName, id] = parts; + + return new TranslationAddress(id, sourceName, packageKey, string); + } +} + +export class TranslationAddressIsInvalid extends Error { + private constructor(message: string) { + super(message); + } + + public static becauseStringDoesNotAdhereToExpectedFormat( + attemptedString: string + ): TranslationAddressIsInvalid { + return new TranslationAddressIsInvalid( + `TranslationAddress must adhere to format "{packageKey}:{sourceName}:{transUnitId}". Got "${attemptedString}" instead.` + ); + } +} diff --git a/packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts b/packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts new file mode 100644 index 0000000000..7f7c027b82 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts @@ -0,0 +1,35 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {Locale} from './Locale'; +import {Translation} from './Translation'; +import {TranslationAddress} from './TranslationAddress'; + +import {TranslationRepository} from './TranslationRepository'; + +describe('TranslationRepository', () => { + const locale_en_US = Locale.create('en-US', 'one,other'); + + it('can find a translation by its translation address', () => { + const translationRepository = TranslationRepository.fromDTO(locale_en_US, { + 'Neos_Neos': { // eslint-disable-line quote-props + 'Main': { // eslint-disable-line quote-props + 'someLabel': 'The Translation' // eslint-disable-line quote-props + } + } + }); + const translationAddressThatCanBeFound = TranslationAddress.fromString('Neos.Neos:Main:someLabel'); + const translationAddressThatCannotBeFound = TranslationAddress.fromString('Vendor.Site:Main:someLabel'); + + expect(translationRepository.findOneByAddress(translationAddressThatCannotBeFound)) + .toBeNull(); + expect(translationRepository.findOneByAddress(translationAddressThatCanBeFound)) + .toStrictEqual(Translation.fromDTO(locale_en_US, 'The Translation')); + }); +}); diff --git a/packages/neos-ui-i18n/src/model/TranslationRepository.ts b/packages/neos-ui-i18n/src/model/TranslationRepository.ts new file mode 100644 index 0000000000..36c32ae069 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/TranslationRepository.ts @@ -0,0 +1,44 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {Locale} from './Locale'; +import {Translation, type TranslationDTO} from './Translation'; +import type {TranslationAddress} from './TranslationAddress'; + +export type TranslationsDTO = Record>>; + +export class TranslationRepository { + private _translationsByAddress: Record = {}; + + private constructor( + private readonly locale: Locale, + private readonly translations: TranslationsDTO + ) {} + + public static fromDTO = (locale: Locale, translations: TranslationsDTO): TranslationRepository => + new TranslationRepository(locale, translations); + + public findOneByAddress(address: TranslationAddress): null | Translation { + if (address.fullyQualified in this._translationsByAddress) { + return this._translationsByAddress[address.fullyQualified]; + } + + const [packageKey, sourceName, id] = [address.packageKey, address.sourceName, address.id] + // Replace all dots with underscores + .map(s => s ? s.replace(/\./g, '_') : '') + + const translationDTO = this.translations[packageKey]?.[sourceName]?.[id] ?? null; + const translation = translationDTO + ? Translation.fromDTO(this.locale, translationDTO) + : null; + this._translationsByAddress[address.fullyQualified] = translation; + + return translation; + } +} diff --git a/packages/neos-ui-i18n/src/model/index.ts b/packages/neos-ui-i18n/src/model/index.ts new file mode 100644 index 0000000000..acaae67935 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -0,0 +1,17 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {Locale} from './Locale'; +export type {Parameters} from './Parameters'; +export {Translation, TranslationDTO} from './Translation'; +export {TranslationAddress} from './TranslationAddress'; +export { + TranslationRepository, + type TranslationsDTO +} from './TranslationRepository'; diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.js b/packages/neos-ui-i18n/src/registry/I18nRegistry.js deleted file mode 100644 index f5f45ef896..0000000000 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.js +++ /dev/null @@ -1,111 +0,0 @@ -import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/registry'; - -import logger from '@neos-project/utils-logger'; - -const errorCache = {}; - -const getTranslationAddress = function (id, packageKey, sourceName) { - if (id && id.indexOf(':') !== -1) { - return id.split(':'); - } - - return [packageKey, sourceName, id]; -}; - -/** - * This code is taken from the Ember version with minor adjustments. Possibly refactor it later - * as its style is not superb. - */ -const substitutePlaceholders = function (textWithPlaceholders, parameters) { - const result = []; - let startOfPlaceholder; - let offset = 0; - while ((startOfPlaceholder = textWithPlaceholders.indexOf('{', offset)) !== -1) { - const endOfPlaceholder = textWithPlaceholders.indexOf('}', offset); - const startOfNextPlaceholder = textWithPlaceholders.indexOf('{', startOfPlaceholder + 1); - - if (endOfPlaceholder === -1 || (startOfPlaceholder + 1) >= endOfPlaceholder || (startOfNextPlaceholder !== -1 && startOfNextPlaceholder < endOfPlaceholder)) { - // There is no closing bracket, or it is placed before the opening bracket, or there is nothing between brackets - logger.error('Text provided contains incorrectly formatted placeholders. Please make sure you conform the placeholder\'s syntax.'); - break; - } - - const contentBetweenBrackets = textWithPlaceholders.substr(startOfPlaceholder + 1, endOfPlaceholder - startOfPlaceholder - 1); - const placeholderElements = contentBetweenBrackets.replace(' ', '').split(','); - - const valueIndex = placeholderElements[0]; - const value = parameters[valueIndex]; - if (typeof value === 'undefined') { - logger.error('Placeholder "' + valueIndex + '" was not provided, make sure you provide values for every placeholder.'); - break; - } - - let formattedPlaceholder; - if (typeof placeholderElements[1] === 'undefined') { - // No formatter defined, just string-cast the value - formattedPlaceholder = parameters[valueIndex]; - } else { - logger.error('Placeholder formatter not supported.'); - break; - } - - result.push(textWithPlaceholders.substr(offset, startOfPlaceholder - offset)); - result.push(formattedPlaceholder); - - offset = endOfPlaceholder + 1; - } - - result.push(textWithPlaceholders.substr(offset)); - - return result.join(''); -}; - -const getPluralForm = (translation, quantity = 0) => { - const translationHasPlurals = translation instanceof Object; - - // no defined quantity or less than one returns singular - if (translationHasPlurals && (!quantity || quantity <= 1)) { - return translation[0]; - } - - if (translationHasPlurals && quantity > 1) { - return translation[1] ? translation[1] : translation[0]; - } - - return translation; -}; - -export default class I18nRegistry extends SynchronousRegistry { - _translations = {}; - - setTranslations(translations) { - this._translations = translations; - } - - // eslint-disable-next-line max-params - translate(idOrig, fallbackOrig, params = {}, packageKeyOrig = 'Neos.Neos', sourceNameOrig = 'Main', quantity = 0) { - const fallback = fallbackOrig || idOrig; - const [packageKey, sourceName, id] = getTranslationAddress(idOrig, packageKeyOrig, sourceNameOrig); - let translation = [packageKey, sourceName, id] - // Replace all dots with underscores - .map(s => s ? s.replace(/\./g, '_') : '') - // Traverse through translations and find us a fitting one - .reduce((prev, cur) => (prev ? prev[cur] || '' : ''), this._translations); - - translation = getPluralForm(translation, quantity); - if (translation && translation.length) { - if (Object.keys(params).length) { - return substitutePlaceholders(translation, params); - } - return translation; - } - - if (!errorCache[`${packageKey}:${sourceName}:${id}`]) { - logger.error(`No translation found for id "${packageKey}:${sourceName}:${id}" in:`, this._translations, `Using ${fallback} instead.`); - - errorCache[`${packageKey}:${sourceName}:${id}`] = true; - } - - return fallback; - } -} diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.js b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.js deleted file mode 100644 index 32f4038a29..0000000000 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.js +++ /dev/null @@ -1,180 +0,0 @@ -import I18nRegistry from './I18nRegistry'; - -test(` - Host > Containers > I18n: should display configured fallback, if no translation - was found.`, () => { - const registry = new I18nRegistry(); - const actual = registry.translate('', 'The Fallback'); - - expect(actual).toBe('The Fallback'); -}); - -test(` - Host > Containers > I18n: should display the trans unit id, if no translation - was found and no fallback was configured.`, () => { - const registry = new I18nRegistry(); - const actual = registry.translate('The Trans Unit ID'); - - expect(actual).toBe('The Trans Unit ID'); -}); - -test(` - Host > Containers > I18n: should display the translated string, if a translation - was found via short-string.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation' // eslint-disable-line quote-props - } - } - }; - - const registry = new I18nRegistry(); - registry.setTranslations(translations); - const actual = registry.translate('Neos.Neos:Main:someLabel'); - - expect(actual).toBe('The Translation'); -}); - -test(` - Host > Containers > I18n: should display the translated string, if a translation - was found via full-length prop description.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation' // eslint-disable-line quote-props - } - } - }; - - const registry = new I18nRegistry(); - registry.setTranslations(translations); - const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main'); - - expect(actual).toBe('The Translation'); -}); - -test(` - Host > Containers > I18n: Should display singular when no quantity is defined.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - - const registry = new I18nRegistry(); - registry.setTranslations(translations); - const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main'); - - expect(actual).toBe('Singular Translation'); -}); - -test(` - Host > Containers > I18n: Should display singular when quantity is zero.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - - const registry = new I18nRegistry(); - registry.setTranslations(translations); - const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 0); - - expect(actual).toBe('Singular Translation'); -}); - -test(` - Host > Containers > I18n: Should display singular when quantity is one.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - - const registry = new I18nRegistry(); - registry.setTranslations(translations); - const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 1); - - expect(actual).toBe('Singular Translation'); -}); - -test(` - Host > Containers > I18n: Should display plural when quantity is two.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - - const registry = new I18nRegistry(); - registry.setTranslations(translations); - const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); - - expect(actual).toBe('Plural Translation'); -}); - -test(` - Host > Containers > I18n: Should display regular language label even when no plural exists and a quantity is defined.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - - const registry = new I18nRegistry(); - registry.setTranslations(translations); - const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); - - expect(actual).toBe('The Translation'); -}); - -test(` - Host > Containers > I18n: Should display singular when quantity is higher but plural label is not defined`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation' // eslint-disable-line quote-props - } - } - } - }; - - const registry = new I18nRegistry(); - registry.setTranslations(translations); - const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); - - expect(actual).toBe('Singular Translation'); -}); diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts new file mode 100644 index 0000000000..0c926dd8bb --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts @@ -0,0 +1,113 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {setupI18n} from '../global'; + +import {I18nRegistry} from './I18nRegistry'; + +beforeAll(() => { + setupI18n('en-US', 'one,other', { + 'Neos_Neos': { // eslint-disable-line quote-props + 'Main': { // eslint-disable-line quote-props + 'someLabel': 'The Translation', // eslint-disable-line quote-props + 'singularLabelOnly': { + 0: 'Singular Translation' // eslint-disable-line quote-props + }, + 'pluralLabel': { + 0: 'Singular Translation', // eslint-disable-line quote-props + 1: 'Plural Translation' // eslint-disable-line quote-props + } + } + } + }); +}) + +test(` + Host > Containers > I18n: should display configured fallback, if no translation + was found.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('', 'The Fallback'); + + expect(actual).toBe('The Fallback'); +}); + +test(` + Host > Containers > I18n: should display the trans unit id, if no translation + was found and no fallback was configured.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('The Trans Unit ID'); + + expect(actual).toBe('The Trans Unit ID'); +}); + +test(` + Host > Containers > I18n: should display the translated string, if a translation + was found via short-string.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:someLabel'); + + expect(actual).toBe('The Translation'); +}); + +test(` + Host > Containers > I18n: should display the translated string, if a translation + was found via full-length prop description.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main'); + + expect(actual).toBe('The Translation'); +}); + +test(` + Host > Containers > I18n: Should display plural when no quantity is defined.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main'); + + expect(actual).toBe('Plural Translation'); +}); + +test(` + Host > Containers > I18n: Should display plural when quantity is zero.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 0); + + expect(actual).toBe('Plural Translation'); +}); + +test(` + Host > Containers > I18n: Should display singular when quantity is one.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 1); + + expect(actual).toBe('Singular Translation'); +}); + +test(` + Host > Containers > I18n: Should display plural when quantity is two.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); + + expect(actual).toBe('Plural Translation'); +}); + +test(` + Host > Containers > I18n: Should display regular language label even when no plural exists and a quantity is defined.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); + + expect(actual).toBe('The Translation'); +}); + +test(` + Host > Containers > I18n: Should display singular when quantity is higher but plural label is not defined`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:singularLabelOnly', undefined, undefined, 'Neos.Neos', 'Main', 2); + + expect(actual).toBe('Singular Translation'); +}); diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts new file mode 100644 index 0000000000..b97b6ba2fc --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -0,0 +1,214 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/registry'; + +import logger from '@neos-project/utils-logger'; + +import {requireGlobals} from '../global'; +import type {Translation, TranslationAddress} from '../model'; + +import {getTranslationAddress} from './getTranslationAddress'; +import type {LegacyParameters} from './LegacyParameters'; + +const errorCache: Record = {}; + +/** + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + */ +export class I18nRegistry extends SynchronousRegistry { + /** + * Retrieves a the translation string that is identified by the given + * identifier. If it is a fully qualified translation address (a string + * following the pattern "{Package.Key:SourceName:actual.trans.unit.id}"), + * then the translation will be looked up in the respective package and + * *.xlf file. If it's a trans-unit id, the translation will be looked up + * in the "Main.xlf" file of the "Neos.Neos" package. + * + * If no translation string can be found for the given id, the fully + * qualified translation address will be returned. + * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + * @param {string} transUnitIdOrFullyQualifiedTranslationAddress A trans-unit id or a fully qualified translation address + */ + translate(transUnitIdOrFullyQualifiedTranslationAddress: string): string; + + /** + * Retrieves a the translation string that is identified by the given + * identifier. If it is a fully qualified translation address (a string + * following the pattern "{Package.Key:SourceName:actual.trans.unit.id}"), + * then the translation will be looked up in the respective package and + * *.xlf file. If it's a trans-unit id, the translation will be looked up + * in the "Main.xlf" file of the "Neos.Neos" package. + * + * If no translation string can be found for the given id, the given + * fallback value will be returned. + * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + * @param {string} transUnitIdOrFullyQualifiedTranslationAddress A trans-unit id or a fully qualified translation address + * @param {string} fallback The string that shall be displayed, when no translation string could be found. + */ + translate(transUnitIdOrFullyQualifiedTranslationAddress: string, fallback: string): string; + + /** + * Retrieves a the translation string that is identified by the given + * identifier. If it is a fully qualified translation address (a string + * following the pattern "{Package.Key:SourceName:actual.trans.unit.id}"), + * then the translation will be looked up in the respective package and + * *.xlf file. If it's just a trans-unit id, the translation will be looked + * up in the "Main.xlf" file of the "Neos.Neos" package. + * + * If no translation string can be found for the given id, the given + * fallback value will be returned. If no fallback value has been given, + * the fully qualified translation address will be returned. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + * @param {string} transUnitIdOrFullyQualifiedTranslationAddress The fully qualified translation address, that follows the format "{Package.Key:SourceName:trans.unit.id}" + * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. + * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string + */ + translate( + transUnitIdOrFullyQualifiedTranslationAddress: string, + fallback: undefined | string, + parameters: LegacyParameters + ): string; + + /** + * Retrieves a the translation string that is identified by the given + * trans-unit id. The translation file will be looked up inside the package + * identified by the given package key. The file itself will be the Main.xlf + * in that package's resource translations. + * + * If no translation string can be found for the given id, the given fallback + * value will be returned. If no fallback value has been given, the fully + * qualified translation address will be returned. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + * @param {string} transUnitId The trans-unit id + * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. + * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string + * @param {string} packageKey The key of the package in which to look for the translation file + */ + translate( + transUnitId: string, + fallback: undefined | string, + parameters: undefined | LegacyParameters, + packageKey: string + ): string; + + /** + * Retrieves a the translation string that is identified by the given + * trans-unit id. The translation file will be looked up inside the package + * identified by the given package key. The file itself will be the *.xlf file + * in that package's resource translations that is identified by the given + * sourceName. + * + * If no translation string can be found for the given id, the given fallback + * value will be returned. If no fallback value has been given, the fully + * qualified translation address will be returned. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + * @param {string} transUnitId The trans-unit id + * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. + * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string + * @param {string} packageKey The key of the package in which to look for the translation file + * @param {string} sourceName The name of the translation file in that package's resource translations + */ + translate( + transUnitId: string, + fallback: undefined | string, + parameters: undefined | LegacyParameters, + packageKey: string, + sourceName: string + ): string; + + /** + * Retrieves a the translation string that is identified by the given + * trans-unit id. The translation file will be looked up inside the package + * identified by the given package key. The file itself will be the *.xlf file + * in that package's resource translations that is identified by the given + * sourceName. + * + * If no translation string can be found for the given id, the given fallback + * value will be returned. If no fallback value has been given, the fully + * qualified translation address will be returned. + * + * If the provided quantity is greater than 1, and the found translation has a + * plural form, then the plural form will be used. If the quantity equals 1 + * or is smaller than 1, the singular form will be used. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + * @param {string} transUnitId The trans-unit id + * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. + * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string + * @param {string} packageKey The key of the package in which to look for the translation file + * @param {string} sourceName The name of the translation file in that package's resource translations + */ + translate( + transUnitId: string, + fallback: undefined | string, + parameters: undefined | LegacyParameters, + packageKey: string, + sourceName: string, + quantity: number + ): string; + + translate( + transUnitIdOrFullyQualifiedTranslationAddress: string, + explicitlyProvidedFallback?: string, + parameters?: LegacyParameters, + explicitlyProvidedPackageKey: string = 'Neos.Neos', + explicitlyProvidedSourceName: string = 'Main', + quantity: number = 0 + ) { + const fallback = explicitlyProvidedFallback || transUnitIdOrFullyQualifiedTranslationAddress; + const translationAddess = getTranslationAddress(transUnitIdOrFullyQualifiedTranslationAddress, explicitlyProvidedPackageKey, explicitlyProvidedSourceName); + const translation = this.getTranslation(translationAddess); + if (translation === null) { + this.logTranslationNotFound(translationAddess, fallback); + return fallback; + } + + return translation.render(parameters as any, quantity); + } + + private logTranslationNotFound(address: TranslationAddress, fallback: string) { + if (!errorCache[address.fullyQualified]) { + const {translationRepository} = requireGlobals(); + logger.error(`No translation found for id "${address.fullyQualified}" in:`, translationRepository, `Using ${fallback} instead.`); + errorCache[address.fullyQualified] = true; + } + } + + private getTranslation(address: TranslationAddress): null | Translation { + const {translationRepository} = requireGlobals(); + return translationRepository.findOneByAddress(address) ?? null; + } +} + +/** + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + */ +export const i18nRegistry = new I18nRegistry('The i18n registry'); diff --git a/packages/neos-ui-i18n/src/registry/LegacyParameters.ts b/packages/neos-ui-i18n/src/registry/LegacyParameters.ts new file mode 100644 index 0000000000..843f953ed2 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/LegacyParameters.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export type LegacyParameters = unknown[] | Record; diff --git a/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts b/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts new file mode 100644 index 0000000000..3707e20377 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts @@ -0,0 +1,34 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {getTranslationAddress} from './getTranslationAddress'; + +describe('getTranslationAddress', () => { + it('provides a translation address tuple if given a single string as parameter', () => { + const translationAddress = getTranslationAddress( + 'Some.Package:SomeSource:some.transunit.id' + ); + + expect(translationAddress.id).toBe('some.transunit.id'); + expect(translationAddress.sourceName).toBe('SomeSource'); + expect(translationAddress.packageKey).toBe('Some.Package'); + }); + + it('provides a translation address tuple if given three separate parameters', () => { + const translationAddress = getTranslationAddress( + 'some.transunit.id', + 'Some.Package', + 'SomeSource' + ); + + expect(translationAddress.id).toBe('some.transunit.id'); + expect(translationAddress.sourceName).toBe('SomeSource'); + expect(translationAddress.packageKey).toBe('Some.Package'); + }); +}); diff --git a/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts b/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts new file mode 100644 index 0000000000..4a3a742824 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts @@ -0,0 +1,38 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {TranslationAddress} from '../model'; + +export function getTranslationAddress( + fullyQualifiedTransUnitId: string +): TranslationAddress; +export function getTranslationAddress( + transUnitId: string, + packageKey: string, + sourceName: string +): TranslationAddress; +export function getTranslationAddress( + id: string, + packageKey?: string, + sourceName?: string +) { + if (id && id.indexOf(':') !== -1) { + return TranslationAddress.fromString(id); + } + + if (packageKey === undefined) { + throw new Error(`${id} is not a fully qualified trans-unit id. A package key must be provided.`); + } + + if (sourceName === undefined) { + throw new Error(`${id} is not a fully qualified trans-unit id. A source name must be provided.`); + } + + return TranslationAddress.create({packageKey, sourceName, id}); +} diff --git a/packages/neos-ui-i18n/src/registry/index.js b/packages/neos-ui-i18n/src/registry/index.js deleted file mode 100644 index c0eb2ae997..0000000000 --- a/packages/neos-ui-i18n/src/registry/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import I18nRegistry from './I18nRegistry'; - -export { - I18nRegistry -}; diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts new file mode 100644 index 0000000000..f1f308166b --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -0,0 +1,15 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export type {I18nRegistry} from './I18nRegistry'; +export {i18nRegistry} from './I18nRegistry'; + +export type {LegacyParameters} from './LegacyParameters'; + +export {substitutePlaceholders} from './substitutePlaceholders'; diff --git a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts new file mode 100644 index 0000000000..26fa7bef50 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts @@ -0,0 +1,182 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import logger from '@neos-project/utils-logger'; +import {substitutePlaceholders} from './substitutePlaceholders'; + +describe('substitutePlaceholders', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with numerically indexed placeholders', () => { + it('substitutes placeholders with no formatter set', () => { + expect(substitutePlaceholders('Hello {0}!', ['World'])) + .toBe('Hello World!'); + expect(substitutePlaceholders('Foo {0}{1} Bar', ['{', '}'])) + .toBe('Foo {} Bar'); + }); + + it('substitutes placeholders for string-cast value if no formatter is set', () => { + expect(substitutePlaceholders('The answer is: {0}', [42])) + .toBe('The answer is: 42'); + }); + + it('complains if a placeholder has a formatter set', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('formatted {0,number} output?', [12]); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('formatter not supported') + ); + }); + + it('complains when an invalid placeholder is encountered', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('damaged {0{} placeholder', [12]); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('incorrectly formatted placeholder') + ); + }); + + it('complains when an insufficient number of arguments has been provided', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('at least 1 argument: {0}', []); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('provide values for every placeholder') + ); + + substitutePlaceholders('at least 3 arguments: {0} {1} {2}', ['foo', 'bar']); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('provide values for every placeholder') + ); + }); + + it('complains when arguments of a strange type have been provided', () => { + const logError = jest.spyOn(logger, 'error'); + + substitutePlaceholders('One argument: {0}', [() => {}]); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('is not of type string or number') + ); + + substitutePlaceholders('One argument: {0}', [Boolean]); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('is not of type string or number') + ); + + logError.mockClear(); + }); + + it('substitutes multiple occurrences of the same placeholder', () => { + expect(substitutePlaceholders('{0} {0} {0} {1} {1} {1}', ['foo', 'bar'])) + .toBe('foo foo foo bar bar bar'); + }); + + it('substitutes placeholders regardless of order in text', () => { + expect(substitutePlaceholders('{2} {1} {3} {0}', ['foo', 'bar', 'baz', 'qux'])) + .toBe('baz bar qux foo'); + }); + }); + + describe('with named placeholders', () => { + it('substitutes placeholders with no formatter set', () => { + expect(substitutePlaceholders('Hello {name}!', {name: 'World'})) + .toBe('Hello World!'); + expect(substitutePlaceholders('Foo {a}{b} Bar', {a: '{', b: '}'})) + .toBe('Foo {} Bar'); + }); + + it('substitutes placeholders for string-cast value if no formatter is set', () => { + expect(substitutePlaceholders('The answer is: {answer}', {answer: 42})) + .toBe('The answer is: 42'); + }); + + it('complains if a placeholder has a formatter set', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('formatted {a,number} output?', {a: 12}); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('formatter not supported') + ); + }); + + it('complains when an invalid placeholder is encountered', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('damaged {broken{} placeholder', {broken: 12}); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('incorrectly formatted placeholder') + ); + }); + + it('complains when an insufficient number of arguments has been provided', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('at least 1 argument: {a}', {}); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('provide values for every placeholder') + ); + + substitutePlaceholders('at least 3 arguments: {a} {b} {c}', {a: 'foo', c: 'bar'}); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('provide values for every placeholder') + ); + }); + + it('complains when arguments of a strange type have been provided', () => { + const logError = jest.spyOn(logger, 'error'); + + substitutePlaceholders('One argument: {a}', { + a: () => {} + }); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('is not of type string or number') + ); + + substitutePlaceholders('One argument: {a}', { + a: Boolean + }); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('is not of type string or number') + ); + + logError.mockClear(); + }); + + it('substitutes multiple occurrences of the same placeholder', () => { + expect( + substitutePlaceholders( + '{name} {name} {name} {value} {value} {value}', + {name: 'foo', value: 'bar'} + ) + ).toBe('foo foo foo bar bar bar'); + }); + }); +}); diff --git a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts new file mode 100644 index 0000000000..ad7c57077e --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts @@ -0,0 +1,66 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import logger from '@neos-project/utils-logger'; + +import {LegacyParameters} from './LegacyParameters'; + +/** + * This code is taken from the Ember version with minor adjustments. Possibly refactor it later + * as its style is not superb. + */ +export const substitutePlaceholders = function (textWithPlaceholders: string, parameters: LegacyParameters) { + const result = []; + let startOfPlaceholder; + let offset = 0; + while ((startOfPlaceholder = textWithPlaceholders.indexOf('{', offset)) !== -1) { + const endOfPlaceholder = textWithPlaceholders.indexOf('}', offset); + const startOfNextPlaceholder = textWithPlaceholders.indexOf('{', startOfPlaceholder + 1); + + if (endOfPlaceholder === -1 || (startOfPlaceholder + 1) >= endOfPlaceholder || (startOfNextPlaceholder !== -1 && startOfNextPlaceholder < endOfPlaceholder)) { + // There is no closing bracket, or it is placed before the opening bracket, or there is nothing between brackets + logger.error('Text provided contains incorrectly formatted placeholders. Please make sure you conform the placeholder\'s syntax.'); + break; + } + + const contentBetweenBrackets = textWithPlaceholders.substr(startOfPlaceholder + 1, endOfPlaceholder - startOfPlaceholder - 1); + const placeholderElements = contentBetweenBrackets.replace(' ', '').split(','); + + const valueIndex = placeholderElements[0]; + const value = Array.isArray(parameters) + ? parameters[parseInt(valueIndex, 10)] + : parameters[valueIndex]; + if (typeof value === 'undefined') { + logger.error('Placeholder "' + valueIndex + '" was not provided, make sure you provide values for every placeholder.'); + break; + } + if (typeof value !== 'string' && typeof value !== 'number') { + logger.error('Placeholder "' + valueIndex + '" is not of type string or number.'); + break; + } + + let formattedPlaceholder; + if (typeof placeholderElements[1] === 'undefined') { + // No formatter defined, just string-cast the value + formattedPlaceholder = value; + } else { + logger.error('Placeholder formatter not supported.'); + break; + } + + result.push(textWithPlaceholders.substr(offset, startOfPlaceholder - offset)); + result.push(formattedPlaceholder); + + offset = endOfPlaceholder + 1; + } + + result.push(textWithPlaceholders.substr(offset)); + + return result.join(''); +}; diff --git a/packages/neos-ui-i18n/src/translate.spec.ts b/packages/neos-ui-i18n/src/translate.spec.ts new file mode 100644 index 0000000000..a25e62472c --- /dev/null +++ b/packages/neos-ui-i18n/src/translate.spec.ts @@ -0,0 +1,308 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {setupI18n, teardownI18n} from './global'; +import {translate} from './translate'; + +/* eslint-disable max-nested-callbacks */ +describe('translate', () => { + describe('when no translation was found', () => { + beforeAll(() => { + setupI18n('en-US', 'one,other', {}); + }); + afterAll(() => { + teardownI18n(); + }); + + it('returns given fallback', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', 'This is the fallback')) + .toBe('This is the fallback'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', 'This is another fallback')) + .toBe('This is another fallback'); + }); + + it('returns given "other" form of fallback when quantity = 0', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 0)) + .toBe('Plural Fallback'); + }); + + it('returns given "one" form of fallback when quantity = 1', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 1)) + .toBe('Singular Fallback'); + }); + + it('returns given "other" form of fallback when quantity > 1', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 2)) + .toBe('Plural Fallback'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 42)) + .toBe('Plural Fallback'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 24227)) + .toBe('Plural Fallback'); + }); + + it('substitutes numerical parameters in fallback string', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', 'This is {0} fallback with {1} parameters.', ['a', 'a few'])) + .toBe('This is a fallback with a few parameters.'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['This is a fallback with {0} parameter.', 'This is a fallback with {0} parameters.'], ['just one'], 1)) + .toBe('This is a fallback with just one parameter.'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['This is a fallback with {0} parameter.', 'This is a fallback with {0} parameters.'], ['one or more'], 2)) + .toBe('This is a fallback with one or more parameters.'); + }); + + it('substitutes named parameters in fallback string', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', 'This is {foo} fallback with {bar} parameters.', {foo: 'one', bar: 'a couple of'})) + .toBe('This is one fallback with a couple of parameters.'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['This is a fallback with {foo} parameter.', 'This is a fallback with {foo} parameters.'], {foo: 'just one'}, 1)) + .toBe('This is a fallback with just one parameter.'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['This is a fallback with {foo} parameter.', 'This is a fallback with {foo} parameters.'], {foo: 'one or more'}, 2)) + .toBe('This is a fallback with one or more parameters.'); + }); + }); + + describe('when a translation was found', () => { + describe('in locale "en-US"', () => { + beforeAll(() => { + setupI18n('en-US', 'one,other', { + 'Neos_Neos_Ui': { + 'Main': { + 'translation_without_plural_forms': + 'This is a translation without plural forms.', + 'translation_with_numerical_parameters': + 'This translation contains {0} {1} {2}.', + 'translation_with_named_parameters': + 'This translation contains {foo} {bar} {baz}.', + 'translation_with_plural_forms': [ + 'This is the "one" form of the translated string.', + 'This is the "other" form of the translated string.' + ], + 'translation_with_plural_forms_and_numerical_parameters': [ + 'This is the "one" form of a translation that contains {0} {1} {2}.', + 'This is the "other" form of a translation that contains {0} {1} {2}.' + ], + 'translation_with_plural_forms_and_named_parameters': [ + 'This is the "one" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "other" form of a translation that contains {foo} {bar} {baz}.' + ] + } + } + }); + }); + afterAll(() => { + teardownI18n(); + }); + + it('returns translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.without.plural.forms', 'This is the fallback')) + .toBe('This is a translation without plural forms.'); + }); + + it('substitutes numerical parameters in translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'])) + .toBe('This translation contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'})) + .toBe('This translation contains 3 named parameters.'); + }); + + describe('when quantity = 0', () => { + it('returns "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 0)) + .toBe('This is the "other" form of the translated string.'); + }); + + it('substitutes numerical parameters in "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 0)) + .toBe('This is the "other" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 0)) + .toBe('This is the "other" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity = 1', () => { + it('returns "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 1)) + .toBe('This is the "one" form of the translated string.'); + }); + + it('substitutes numerical parameters in "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 1)) + .toBe('This is the "one" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 1)) + .toBe('This is the "one" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity > 1', () => { + it('returns "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 23)) + .toBe('This is the "other" form of the translated string.'); + }); + + it('substitutes numerical parameters in "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 42)) + .toBe('This is the "other" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 274711)) + .toBe('This is the "other" form of a translation that contains 3 named parameters.'); + }); + }); + }); + + describe('in locale "ar-EG"', () => { + beforeAll(() => { + setupI18n('ar-EG', 'zero,one,two,few,many', { + 'Neos_Neos_Ui': { + 'Main': { + 'translation_without_plural_forms': + 'This is a translation without plural forms.', + 'translation_with_numerical_parameters': + 'This translation contains {0} {1} {2}.', + 'translation_with_named_parameters': + 'This translation contains {foo} {bar} {baz}.', + 'translation_with_plural_forms': [ + 'This is the "zero" form of the translated string.', + 'This is the "one" form of the translated string.', + 'This is the "two" form of the translated string.', + 'This is the "few" form of the translated string.', + 'This is the "many" form of the translated string.' + ], + 'translation_with_plural_forms_and_numerical_parameters': [ + 'This is the "zero" form of a translation that contains {0} {1} {2}.', + 'This is the "one" form of a translation that contains {0} {1} {2}.', + 'This is the "two" form of a translation that contains {0} {1} {2}.', + 'This is the "few" form of a translation that contains {0} {1} {2}.', + 'This is the "many" form of a translation that contains {0} {1} {2}.' + ], + 'translation_with_plural_forms_and_named_parameters': [ + 'This is the "zero" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "one" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "two" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "few" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "many" form of a translation that contains {foo} {bar} {baz}.' + ] + } + } + }); + }); + afterAll(() => { + teardownI18n(); + }); + + it('returns translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.without.plural.forms', 'This is the fallback')) + .toBe('This is a translation without plural forms.'); + }); + + it('substitutes numerical parameters in translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'])) + .toBe('This translation contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'})) + .toBe('This translation contains 3 named parameters.'); + }); + + describe('when quantity = 0', () => { + it('returns "zero" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 0)) + .toBe('This is the "zero" form of the translated string.'); + }); + + it('substitutes numerical parameters in "zero" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 0)) + .toBe('This is the "zero" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "zero" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 0)) + .toBe('This is the "zero" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity = 1', () => { + it('returns "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 1)) + .toBe('This is the "one" form of the translated string.'); + }); + + it('substitutes numerical parameters in "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 1)) + .toBe('This is the "one" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 1)) + .toBe('This is the "one" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity = 2', () => { + it('returns "two" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 2)) + .toBe('This is the "two" form of the translated string.'); + }); + + it('substitutes numerical parameters in "two" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 2)) + .toBe('This is the "two" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "two" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 2)) + .toBe('This is the "two" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity % 100 is between 3 and 10', () => { + it('returns "few" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 7)) + .toBe('This is the "few" form of the translated string.'); + }); + + it('substitutes numerical parameters in "few" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 108)) + .toBe('This is the "few" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "few" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 2005)) + .toBe('This is the "few" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity % 100 is between 11 and 99', () => { + it('returns "many" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 11)) + .toBe('This is the "many" form of the translated string.'); + }); + + it('substitutes numerical parameters in "many" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 112)) + .toBe('This is the "many" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "many" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 10099)) + .toBe('This is the "many" form of a translation that contains 3 named parameters.'); + }); + }); + }); + }); +}); diff --git a/packages/neos-ui-i18n/src/translate.ts b/packages/neos-ui-i18n/src/translate.ts new file mode 100644 index 0000000000..82c1a7dc82 --- /dev/null +++ b/packages/neos-ui-i18n/src/translate.ts @@ -0,0 +1,68 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {requireGlobals} from './global'; +import {TranslationAddress, type Parameters} from './model'; +import {substitutePlaceholders} from './registry'; + +/** + * Retrieves a the translation string that is identified by the given fully + * qualified translation address (a string following the pattern + * "{Package.Key:SourceName:actual.trans.unit.id}"), then the translation will + * be looked up in the respective package and *.xlf file. + * + * If no translation string can be found for the given address, the given + * fallback value will be returned. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * Optionally, a quantity can be provided, which will then be used to determine + * a plural version of the translation string, within the plural rules set + * within the currently registered locale. + * + * @api + * @param {string} fullyQualifiedTranslationAddressAsString The translation address + * @param {string | [string, string]} fallback The string that shall be displayed, when no translation string could be found. If a tuple of two values is given, the first value will be treated as the singular, the second value as the plural form. + * @param {Parameters} [parameters] The values to replace substitution placeholders with in the translation string + * @param {quantity} [quantity] The key of the package in which to look for the translation file + */ +export function translate( + fullyQualifiedTranslationAddressAsString: string, + fallback: string | [string, string], + parameters: Parameters = [], + quantity: number = 0 +): string { + const {translationRepository} = requireGlobals(); + const translationAddress = TranslationAddress.fromString(fullyQualifiedTranslationAddressAsString); + const translation = translationRepository.findOneByAddress(translationAddress); + + if (translation === null) { + return renderFallback(fallback, quantity, parameters); + } + + return translation.render(parameters, quantity); +} + +function renderFallback( + fallback: string | [string, string], + quantity: number, + parameters: Parameters +) { + const fallbackHasPluralForms = Array.isArray(fallback); + let result: string; + if (fallbackHasPluralForms) { + result = quantity === 1 ? fallback[0] : fallback[1]; + } else { + result = fallback; + } + + return substitutePlaceholders(result, parameters); +} diff --git a/packages/neos-ui-redux-store/src/UI/FlashMessages/index.spec.js b/packages/neos-ui-redux-store/src/UI/FlashMessages/index.spec.js index ec2f0d759a..76181d64f3 100644 --- a/packages/neos-ui-redux-store/src/UI/FlashMessages/index.spec.js +++ b/packages/neos-ui-redux-store/src/UI/FlashMessages/index.spec.js @@ -1,117 +1,11 @@ -import {actionTypes, actions, reducer} from './index'; - -import {actionTypes as system} from '../../System/index'; +import {actionTypes, actions} from './index'; test(`should export actionTypes`, () => { expect(actionTypes).not.toBe(undefined); expect(typeof (actionTypes.ADD)).toBe('string'); - expect(typeof (actionTypes.REMOVE)).toBe('string'); }); test(`should export action creators`, () => { expect(actions).not.toBe(undefined); expect(typeof (actions.add)).toBe('function'); - expect(typeof (actions.remove)).toBe('function'); -}); - -test(`should export a reducer`, () => { - expect(reducer).not.toBe(undefined); - expect(typeof (reducer)).toBe('function'); -}); - -test(`The reducer should return a plain JS object as the initial state.`, () => { - const nextState = reducer(undefined, { - type: system.INIT - }); - - expect(typeof nextState).toBe('object'); -}); - -test(`The "add" action should throw an error if no arguments where passed.`, () => { - const fn = () => reducer(undefined, actions.add()); - - expect(fn).toThrowError( - 'Empty or non existent "id" passed to the addFlashMessage reducer. Please specify a string containing a random id.' - ); -}); - -test(`The "add" action should throw an error no "message" was passed.`, () => { - const fn = () => reducer(undefined, actions.add('myMessageId', null)); - - expect(fn).toThrowError( - 'Empty or non existent "message" passed to the addFlashMessage reducer. Please specify a string containing your desired message.' - ); -}); - -test(`The "add" action should throw an error if an invalid "severity" was passed.`, () => { - const fn = () => reducer(undefined, actions.add('myMessageId', 'myMessage', null)); - - expect(fn).toThrowError( - 'Invalid "severity" specified while adding a new FlashMessage. Allowed severities are success error info.' - ); -}); - -test(` - The "add" action should be able to add the passed data as a new flashMessage - item.`, () => { - const state = {}; - const nextState = reducer(state, actions.add('myMessageId', 'myMessage', 'error', 300)); - - const addedMessage = nextState.myMessageId; - - expect(addedMessage).toEqual({ - severity: 'error', - id: 'myMessageId', - message: 'myMessage', - timeout: 300 - }); -}); - -test(` - The "add" action should normalize the severity to lowercase for the new - flashMessage item.`, () => { - const state = {}; - const nextState1 = reducer(state, actions.add('myMessageId', 'myMessage', 'Error', 300)); - const nextState2 = reducer(state, actions.add('myMessageId', 'myMessage', 'ERROR', 300)); - const nextState3 = reducer(state, actions.add('myMessageId', 'myMessage', 'eRrOr', 300)); - - const addedMessage1 = nextState1.myMessageId; - const addedMessage2 = nextState2.myMessageId; - const addedMessage3 = nextState3.myMessageId; - - expect(addedMessage1.severity).toBe('error'); - expect(addedMessage2.severity).toBe('error'); - expect(addedMessage3.severity).toBe('error'); -}); - -test(` - The "add" action should set a default timeout of "0" if none was passed for - the new flashMessage item.`, () => { - const state = {}; - const nextState = reducer(state, actions.add('myMessageId', 'myMessage', 'error')); - - const addedMessage = nextState.myMessageId; - - expect(addedMessage.timeout).toBe(0); -}); - -test(` - The "remove" action should be able to remove an added flashMessage item for - the passed key.`, () => { - const state = { - someMessage: 'someMessage', - anotherMessage: 'anotherMessage' - }; - - const nextState1 = reducer(state, actions.remove('someMessage')); - const nextState2 = reducer(state, actions.remove('anotherMessage')); - const nextState3 = reducer(nextState1, actions.remove('anotherMessage')); - - expect(nextState1).toEqual({ - anotherMessage: 'anotherMessage' - }); - expect(nextState2).toEqual({ - someMessage: 'someMessage' - }); - expect(nextState3).toEqual({}); }); diff --git a/packages/neos-ui-redux-store/src/UI/FlashMessages/index.ts b/packages/neos-ui-redux-store/src/UI/FlashMessages/index.ts index b3bfcf0c55..dd388dadb1 100644 --- a/packages/neos-ui-redux-store/src/UI/FlashMessages/index.ts +++ b/packages/neos-ui-redux-store/src/UI/FlashMessages/index.ts @@ -1,27 +1,10 @@ -import produce from 'immer'; import {action as createAction, ActionType} from 'typesafe-actions'; -import {InitAction} from '../../System'; - -export interface FlashMessage extends Readonly<{ - severity: string; - id: string; - message: string; - timeout: number; -}> {} - -export interface State extends Readonly<{ - [propName: string]: FlashMessage; -}> {} - -export const defaultState: State = {}; - // // Export the action types // export enum actionTypes { - ADD = '@neos/neos-ui/UI/FlashMessages/ADD', - REMOVE = '@neos/neos-ui/UI/FlashMessages/REMOVE' + ADD = '@neos/neos-ui/UI/FlashMessages/ADD' } /** @@ -39,59 +22,11 @@ const add = (id: string, message: string, severity: string, timeout: number = 0) timeout })); -/** - * Removes a flash message - * - * @param {String} id The flashMessage id to delete. - */ -const remove = (id: string) => createAction(actionTypes.REMOVE, ({ - id -})); - // // Export the actions // export const actions = { - add, - remove + add }; export type Action = ActionType; - -// -// Export the reducer -// -export const reducer = (state: State = defaultState, action: InitAction | Action) => produce(state, draft => { - switch (action.type) { - case actionTypes.ADD: { - const message = action.payload; - const allowedSeverities = ['success', 'error', 'info']; - const {id, severity} = message; - const messageContents = message.message; - - if (!id || id.length < 0) { - throw new Error('Empty or non existent "id" passed to the addFlashMessage reducer. Please specify a string containing a random id.'); - } - - if (!messageContents || messageContents.length < 0) { - throw new Error('Empty or non existent "message" passed to the addFlashMessage reducer. Please specify a string containing your desired message.'); - } - - if (!severity || allowedSeverities.indexOf(severity.toLowerCase()) < 0) { - throw new Error(`Invalid "severity" specified while adding a new FlashMessage. Allowed severities are ${allowedSeverities.join(' ')}.`); - } - message.severity = message.severity.toLowerCase(); - draft[message.id] = message; - break; - } - case actionTypes.REMOVE: { - delete draft[action.payload.id]; - break; - } - } -}); - -// -// Export the selectors -// -export const selectors = {}; diff --git a/packages/neos-ui-redux-store/src/UI/index.ts b/packages/neos-ui-redux-store/src/UI/index.ts index 507046bba8..9b868a36ee 100644 --- a/packages/neos-ui-redux-store/src/UI/index.ts +++ b/packages/neos-ui-redux-store/src/UI/index.ts @@ -24,7 +24,6 @@ import * as ContentTree from './ContentTree'; // Export the reducer state shape interface // export interface State { - flashMessages: FlashMessages.State; fullScreen: FullScreen.State; keyboardShortcutModal: KeyboardShortcutModal.State; leftSideBar: LeftSideBar.State; @@ -98,7 +97,6 @@ export const actions = { // Export the reducer // export const reducer = combineReducers({ - flashMessages: FlashMessages.reducer, fullScreen: FullScreen.reducer, keyboardShortcutModal: KeyboardShortcutModal.reducer, leftSideBar: LeftSideBar.reducer, @@ -122,7 +120,6 @@ export const reducer = combineReducers({ // Export the selectors // export const selectors = { - FlashMessages: FlashMessages.selectors, FullScreen: FullScreen.selectors, KeyboardShortcutModal: KeyboardShortcutModal.selectors, LeftSideBar: LeftSideBar.selectors, diff --git a/packages/neos-ui-sagas/package.json b/packages/neos-ui-sagas/package.json index ae10867255..99edf11223 100644 --- a/packages/neos-ui-sagas/package.json +++ b/packages/neos-ui-sagas/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@neos-project/neos-ui-backend-connector": "workspace:*", + "@neos-project/neos-ui-error": "workspace:*", "@neos-project/neos-ui-extensibility": "workspace:*", "@neos-project/neos-ui-guest-frame": "workspace:*", "@neos-project/neos-ui-redux-store": "workspace:*", diff --git a/packages/neos-ui-sagas/src/UI/ContentCanvas/index.js b/packages/neos-ui-sagas/src/UI/ContentCanvas/index.js index 6a24155440..09c62d9c95 100644 --- a/packages/neos-ui-sagas/src/UI/ContentCanvas/index.js +++ b/packages/neos-ui-sagas/src/UI/ContentCanvas/index.js @@ -4,6 +4,7 @@ import {takeLatest, put, select, take, race} from 'redux-saga/effects'; import {getGuestFrameDocument} from '@neos-project/neos-ui-guest-frame/src/dom'; import {actionTypes, actions} from '@neos-project/neos-ui-redux-store'; +import {showFlashMessage} from '@neos-project/neos-ui-error'; /** * Load newly created page into canvas @@ -80,7 +81,12 @@ export function * watchControlOverIFrame() { const nextAction = Object.keys(waitForNextAction).map(k => waitForNextAction[k])[0]; if (nextAction.type === actionTypes.UI.ContentCanvas.REQUEST_REGAIN_CONTROL) { - yield put(actions.UI.FlashMessages.add('iframe access', nextAction.payload.errorMessage, 'error', 5000)); + showFlashMessage({ + id: 'iframe access', + severity: 'error', + message: nextAction.payload.errorMessage, + timeout: 5000 + }); // // We need to delay, so that the iframe gets cleared before we load a new src diff --git a/packages/neos-ui-sagas/src/UI/ContentTree/index.js b/packages/neos-ui-sagas/src/UI/ContentTree/index.js index e597864db0..177e2d9bae 100644 --- a/packages/neos-ui-sagas/src/UI/ContentTree/index.js +++ b/packages/neos-ui-sagas/src/UI/ContentTree/index.js @@ -4,6 +4,7 @@ import {actionTypes, actions, selectors} from '@neos-project/neos-ui-redux-store import {isNodeCollapsed} from '@neos-project/neos-ui-redux-store/src/CR/Nodes/helpers'; import backend from '@neos-project/neos-ui-backend-connector'; +import {showFlashMessage} from '@neos-project/neos-ui-error'; export function * watchReloadTree({globalRegistry}) { const nodeTypesRegistry = globalRegistry.get('@neos-project/neos-ui-contentrepository'); @@ -122,7 +123,11 @@ export function * watchRequestChildrenForContextPath({globalRegistry}) { childNodes = yield query.neosUiFilteredChildren(nodeTypeFilter).get(); } catch (err) { yield put(actions.UI.ContentTree.invalidate(contextPath)); - yield put(actions.UI.FlashMessages.add('loadChildNodesError', err.message, 'error')); + showFlashMessage({ + id: 'loadChildNodesError', + severity: 'error', + message: err.message + }); } yield put(actions.UI.ContentTree.setAsLoaded(contextPath)); diff --git a/packages/neos-ui-sagas/src/UI/FlashMessages/index.ts b/packages/neos-ui-sagas/src/UI/FlashMessages/index.ts new file mode 100644 index 0000000000..a40562f094 --- /dev/null +++ b/packages/neos-ui-sagas/src/UI/FlashMessages/index.ts @@ -0,0 +1,29 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {takeEvery} from 'redux-saga/effects'; + +import {actionTypes, actions} from '@neos-project/neos-ui-redux-store'; +import {Severity, showFlashMessage} from '@neos-project/neos-ui-error'; + +export function * legacy__redirectReduxFlashMessagesToHighLevelApiCall() { + yield takeEvery( + actionTypes.UI.FlashMessages.ADD, + function restore( + action: ReturnType + ) { + showFlashMessage({ + id: action.payload.id, + message: action.payload.message, + severity: action.payload.severity.toLowerCase() as Severity, + timeout: action.payload.timeout + }); + } + ); +} diff --git a/packages/neos-ui-sagas/src/UI/Impersonate/index.js b/packages/neos-ui-sagas/src/UI/Impersonate/index.js index a29646551a..fd819bc6e6 100644 --- a/packages/neos-ui-sagas/src/UI/Impersonate/index.js +++ b/packages/neos-ui-sagas/src/UI/Impersonate/index.js @@ -1,20 +1,22 @@ -import {put, call, takeEvery} from 'redux-saga/effects'; +import {call, takeEvery} from 'redux-saga/effects'; -import {actionTypes, actions} from '@neos-project/neos-ui-redux-store'; +import {actionTypes} from '@neos-project/neos-ui-redux-store'; import backend from '@neos-project/neos-ui-backend-connector'; +import {showFlashMessage} from '@neos-project/neos-ui-error'; export function * impersonateRestore({globalRegistry, routes}) { const {impersonateRestore} = backend.get().endpoints; const i18nRegistry = globalRegistry.get('i18n'); - const errorMessage = i18nRegistry.translate( - 'impersonate.error.restoreUser', - 'Could not switch back to the original user.', - {}, - 'Neos.Neos', - 'Main' - ); yield takeEvery(actionTypes.User.Impersonate.RESTORE, function * restore(action) { + const errorMessage = i18nRegistry.translate( + 'impersonate.error.restoreUser', + 'Could not switch back to the original user.', + {}, + 'Neos.Neos', + 'Main' + ); + try { const feedback = yield call(impersonateRestore, action.payload); const originUser = feedback?.origin?.accountIdentifier; @@ -33,14 +35,26 @@ export function * impersonateRestore({globalRegistry, routes}) { ); if (status) { - yield put(actions.UI.FlashMessages.add('restoreUserImpersonateUser', restoreMessage, 'success')); + showFlashMessage({ + id: 'restoreUserImpersonateUser', + severity: 'success', + message: restoreMessage + }); } else { - yield put(actions.UI.FlashMessages.add('restoreUserImpersonateUser', errorMessage, 'error')); + showFlashMessage({ + id: 'restoreUserImpersonateUser', + severity: 'error', + message: errorMessage + }); } window.location.href = routes?.core?.modules?.defaultModule; } catch (error) { - yield put(actions.UI.FlashMessages.add('restoreUserImpersonateUser', errorMessage, 'error')); + showFlashMessage({ + id: 'restoreUserImpersonateUser', + severity: 'error', + message: errorMessage + }); } }); } diff --git a/packages/neos-ui-sagas/src/UI/PageTree/index.js b/packages/neos-ui-sagas/src/UI/PageTree/index.js index 8260df6bdb..d6733d5a20 100644 --- a/packages/neos-ui-sagas/src/UI/PageTree/index.js +++ b/packages/neos-ui-sagas/src/UI/PageTree/index.js @@ -2,6 +2,7 @@ import {takeLatest, takeEvery, put, select} from 'redux-saga/effects'; import {actionTypes, actions, selectors} from '@neos-project/neos-ui-redux-store'; import backend from '@neos-project/neos-ui-backend-connector'; +import {showFlashMessage} from '@neos-project/neos-ui-error'; import {isNodeCollapsed} from '@neos-project/neos-ui-redux-store/src/CR/Nodes/helpers'; @@ -42,7 +43,11 @@ export function * watchRequestChildrenForContextPath({configuration}) { childNodes = yield query.neosUiFilteredChildren(baseNodeType).getForTree(); } catch (err) { yield put(actions.UI.PageTree.invalidate(contextPath)); - yield put(actions.UI.FlashMessages.add('loadChildNodesError', err.message, 'error')); + showFlashMessage({ + id: 'loadChildNodesError', + severity: 'error', + message: err.message + }); } yield put(actions.UI.PageTree.setAsLoaded(contextPath)); @@ -183,7 +188,11 @@ export function * watchSearch({configuration}) { } catch (err) { console.error('Error while executing a tree search: ', err); yield put(actions.UI.PageTree.invalidate(contextPath)); - yield put(actions.UI.FlashMessages.add('searchError', 'There was an error searching in the node tree. Contact your administrator for fixing this issue.', 'error')); + showFlashMessage({ + id: 'searchError', + severity: 'error', + message: 'There was an error searching in the node tree. Contact your administrator for fixing this issue.' + }); return; } const siteNode = yield select(selectors.CR.Nodes.siteNodeSelector); diff --git a/packages/neos-ui-sagas/src/index.js b/packages/neos-ui-sagas/src/index.js index c0a6392b6b..7a78469b3a 100644 --- a/packages/neos-ui-sagas/src/index.js +++ b/packages/neos-ui-sagas/src/index.js @@ -12,4 +12,5 @@ export * as uiInspector from './UI/Inspector/index'; export * as uiPageTree from './UI/PageTree/index'; export * as uiHotkeys from './UI/Hotkeys/index'; export * as impersonate from './UI/Impersonate/index'; +export * as flashMessages from './UI/FlashMessages/index'; export * as sync from './Sync/index'; diff --git a/packages/neos-ui-sagas/src/manifest.js b/packages/neos-ui-sagas/src/manifest.js index ac9db2832d..4e226a187d 100644 --- a/packages/neos-ui-sagas/src/manifest.js +++ b/packages/neos-ui-sagas/src/manifest.js @@ -16,7 +16,8 @@ import { uiInspector, uiPageTree, uiHotkeys, - impersonate + impersonate, + flashMessages } from './index'; manifest('main.sagas', {}, globalRegistry => { @@ -81,4 +82,6 @@ manifest('main.sagas', {}, globalRegistry => { sagasRegistry.set('neos-ui/UI/Hotkeys/handleHotkeys', {saga: uiHotkeys.handleHotkeys}); sagasRegistry.set('neos-ui/UI/Impersonate/impersonateRestore', {saga: impersonate.impersonateRestore}); + + sagasRegistry.set('neos-ui/UI/FlashMessages/legacy__redirectReduxFlashMessagesToHighLevelApiCall', {saga: flashMessages.legacy__redirectReduxFlashMessagesToHighLevelApiCall}); }); diff --git a/packages/neos-ui/package.json b/packages/neos-ui/package.json index 2b4845b352..dec01a3f3d 100644 --- a/packages/neos-ui/package.json +++ b/packages/neos-ui/package.json @@ -17,6 +17,8 @@ "@fortawesome/free-regular-svg-icons": "^5.15.3", "@fortawesome/free-solid-svg-icons": "^5.15.3", "@friendsofreactjs/react-css-themr": "~4.2.0", + "@neos-project/framework-observable": "workspace:*", + "@neos-project/framework-observable-react": "workspace:*", "@neos-project/neos-ts-interfaces": "workspace:*", "@neos-project/neos-ui-backend-connector": "workspace:*", "@neos-project/neos-ui-ckeditor5-bindings": "workspace:*", diff --git a/packages/neos-ui/src/Containers/App.js b/packages/neos-ui/src/Containers/App.js index 21abe453f8..08fe022306 100644 --- a/packages/neos-ui/src/Containers/App.js +++ b/packages/neos-ui/src/Containers/App.js @@ -4,7 +4,7 @@ import {connect} from 'react-redux'; import mergeClassNames from 'classnames'; import style from './style.module.css'; -import FlashMessages from './FlashMessages/index'; +import {FlashMessages} from '@neos-project/neos-ui-error'; const App = ({globalRegistry, menu, isFullScreen, leftSidebarIsHidden, rightSidebarIsHidden}) => { const containerRegistry = globalRegistry.get('containers'); diff --git a/packages/neos-ui/src/Containers/Drawer/Drawer.tsx b/packages/neos-ui/src/Containers/Drawer/Drawer.tsx new file mode 100644 index 0000000000..325ac24999 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/Drawer.tsx @@ -0,0 +1,121 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +import mergeClassNames from 'classnames'; + +import {neos} from '@neos-project/neos-ui-decorators'; +import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility'; +import {createState} from '@neos-project/framework-observable'; +import {useLatestState} from '@neos-project/framework-observable-react'; + +import MenuItemGroup from './MenuItemGroup/index'; +import style from './style.module.css'; +import {THRESHOLD_MOUSE_LEAVE} from './constants'; + +const withNeosGlobals = neos(globalRegistry => ({ + containerRegistry: globalRegistry.get('containers') +})); + +export const drawer$ = createState({ + isHidden: true, + collapsedMenuGroups: [] as string[] +}); + +export function toggleDrawer() { + drawer$.update((state) => ({ + ...state, + isHidden: !state.isHidden + })); +} + +function hideDrawer() { + drawer$.update((state) => ({ + ...state, + isHidden: true + })); +} + +function toggleMenuGroup(menuGroupId: string) { + drawer$.update((state) => ({ + ...state, + collapsedMenuGroups: state.collapsedMenuGroups.includes(menuGroupId) + ? state.collapsedMenuGroups.filter((m) => m !== menuGroupId) + : [...state.collapsedMenuGroups, menuGroupId] + })); +} + +const StatelessDrawer: React.FC<{ + containerRegistry: SynchronousRegistry; + + menuData: { + icon?: string; + label: string; + uri: string; + target?: string; + + children: { + icon?: string; + label: string; + uri?: string; + isActive: boolean; + skipI18n: boolean; + }[]; + }[]; +}> = (props) => { + const {isHidden, collapsedMenuGroups} = useLatestState(drawer$); + const mouseLeaveTimeoutRef = React.useRef>(null); + const handleMouseEnter = React.useCallback(() => { + if (mouseLeaveTimeoutRef.current) { + clearTimeout(mouseLeaveTimeoutRef.current); + mouseLeaveTimeoutRef.current = null; + } + }, []); + const handleMouseLeave = React.useCallback(() => { + if (!mouseLeaveTimeoutRef.current) { + mouseLeaveTimeoutRef.current = setTimeout(() => { + hideDrawer(); + mouseLeaveTimeoutRef.current = null; + }, THRESHOLD_MOUSE_LEAVE); + } + }, []); + const {menuData, containerRegistry} = props; + const classNames = mergeClassNames({ + [style.drawer]: true, + [style['drawer--isHidden']]: isHidden + }); + + const BottomComponents = containerRegistry.getChildren('Drawer/Bottom'); + + return ( +
+
+ {Object.entries(menuData).map(([menuGroup, menuGroupConfiguration]) => ( + + ))} +
+
+ {BottomComponents.map((Item, key) => )} +
+
+ ); +} + +export const Drawer = withNeosGlobals(StatelessDrawer as any); diff --git a/packages/neos-ui/src/Containers/Drawer/MenuItem/MenuItem.tsx b/packages/neos-ui/src/Containers/Drawer/MenuItem/MenuItem.tsx new file mode 100644 index 0000000000..50b302bec9 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/MenuItem/MenuItem.tsx @@ -0,0 +1,40 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import {Icon, Button} from '@neos-project/react-ui-components'; + +import I18n from '@neos-project/neos-ui-i18n'; + +import style from '../style.module.css'; + +export const MenuItem: React.FC<{ + icon?: string; + label: string; + uri?: string; + isActive: boolean; + skipI18n?: boolean; +}> = (props) => { + const {skipI18n, label, icon, uri} = props; + + return ( + + + + ); +} diff --git a/packages/neos-ui/src/Containers/Drawer/MenuItem/index.js b/packages/neos-ui/src/Containers/Drawer/MenuItem/index.js deleted file mode 100644 index 0db21ad763..0000000000 --- a/packages/neos-ui/src/Containers/Drawer/MenuItem/index.js +++ /dev/null @@ -1,50 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; - -import {Icon, Button} from '@neos-project/react-ui-components'; - -import I18n from '@neos-project/neos-ui-i18n'; - -import style from '../style.module.css'; -import {TARGET_WINDOW} from '../constants'; - -export default class MenuItem extends PureComponent { - static propTypes = { - icon: PropTypes.string, - label: PropTypes.string.isRequired, - uri: PropTypes.string, - target: PropTypes.string, - isActive: PropTypes.bool.isRequired, - skipI18n: PropTypes.bool, - - onClick: PropTypes.func.isRequired - }; - - handleClick = () => { - const {uri, target, onClick} = this.props; - - onClick(target, uri); - } - - render() { - const {skipI18n, label, icon, uri, target} = this.props; - - const button = ( - - ); - - if (target === TARGET_WINDOW) { - return {button}; - } - return button; - } -} diff --git a/packages/neos-ui/src/Containers/Drawer/MenuItem/index.ts b/packages/neos-ui/src/Containers/Drawer/MenuItem/index.ts new file mode 100644 index 0000000000..ae67147647 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/MenuItem/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {MenuItem as default} from './MenuItem'; diff --git a/packages/neos-ui/src/Containers/Drawer/MenuItemGroup/MenuItemGroup.tsx b/packages/neos-ui/src/Containers/Drawer/MenuItemGroup/MenuItemGroup.tsx new file mode 100644 index 0000000000..09d97f96d4 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/MenuItemGroup/MenuItemGroup.tsx @@ -0,0 +1,62 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import {Icon, ToggablePanel, Button} from '@neos-project/react-ui-components'; + +import I18n from '@neos-project/neos-ui-i18n'; + +import MenuItem from '../MenuItem/index'; +import style from '../style.module.css'; + +export const MenuItemGroup: React.FC<{ + id: string; + icon?: string; + label: string; + uri: string; + target?: string; + collapsed: boolean; + onMenuGroupToggle: (menuGroupId: string) => void; + children: { + icon?: string; + label: string; + uri?: string; + isActive: boolean; + skipI18n?: boolean; + }[]; +}> = (props) => { + const {label, icon, children, uri, collapsed} = props; + const handleMenuGroupToggle = React.useCallback(() => { + props.onMenuGroupToggle(props.id); + }, [props.id]) + + return ( + + + + + + + + {children.map((item, index) => ( + + ))} + + + ); +} diff --git a/packages/neos-ui/src/Containers/Drawer/MenuItemGroup/index.js b/packages/neos-ui/src/Containers/Drawer/MenuItemGroup/index.js deleted file mode 100644 index 5cf82daf35..0000000000 --- a/packages/neos-ui/src/Containers/Drawer/MenuItemGroup/index.js +++ /dev/null @@ -1,73 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; - -import {Icon, ToggablePanel, Button} from '@neos-project/react-ui-components'; - -import I18n from '@neos-project/neos-ui-i18n'; - -import MenuItem from '../MenuItem/index'; -import {TARGET_WINDOW} from '../constants'; -import style from '../style.module.css'; - -export default class MenuItemGroup extends PureComponent { - static propTypes = { - icon: PropTypes.string, - label: PropTypes.string.isRequired, - uri: PropTypes.string.isRequired, - target: PropTypes.string, - collapsed: PropTypes.bool.isRequired, - handleMenuGroupToggle: PropTypes.func.isRequired, - - children: PropTypes.arrayOf( - PropTypes.shape({ - icon: PropTypes.string, - label: PropTypes.string.isRequired, - uri: PropTypes.string, - target: PropTypes.string, - isActive: PropTypes.bool.isRequired, - skipI18n: PropTypes.bool - }) - ), - - onClick: PropTypes.func.isRequired, - onChildClick: PropTypes.func.isRequired - }; - - handleClick = () => { - const {uri, target, onClick} = this.props; - - onClick(target, uri); - } - - render() { - const {label, icon, children, onChildClick, target, uri, collapsed, handleMenuGroupToggle} = this.props; - - const headerButton = ( - - ); - - const header = (target === TARGET_WINDOW ? {headerButton} : headerButton); - - return ( - - - {header} - - - {children.map((item, index) => ( - - ))} - - - ); - } -} diff --git a/packages/neos-ui/src/Containers/Drawer/MenuItemGroup/index.ts b/packages/neos-ui/src/Containers/Drawer/MenuItemGroup/index.ts new file mode 100644 index 0000000000..513ee6aef1 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/MenuItemGroup/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {MenuItemGroup as default} from './MenuItemGroup'; diff --git a/packages/neos-ui/src/Containers/Drawer/UserDropDown/RestoreButtonItem.js b/packages/neos-ui/src/Containers/Drawer/UserDropDown/RestoreButtonItem.js deleted file mode 100644 index e4fc5840ba..0000000000 --- a/packages/neos-ui/src/Containers/Drawer/UserDropDown/RestoreButtonItem.js +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import {Icon} from '@neos-project/react-ui-components'; -import {neos} from '@neos-project/neos-ui-decorators'; -import {connect} from 'react-redux'; -import {actions} from '@neos-project/neos-ui-redux-store'; -import I18n from '@neos-project/neos-ui-i18n'; - -import buttonTheme from './style.module.css'; - -@connect( - state => ({ - originUser: state?.user?.impersonate?.origin - }), - { - impersonateRestore: actions.User.Impersonate.restore - } -) -@neos(globalRegistry => ({ - i18nRegistry: globalRegistry.get('i18n') -})) -export default class RestoreButtonItem extends React.PureComponent { - static propTypes = { - originUser: PropTypes.object, - impersonateRestore: PropTypes.func.isRequired, - i18nRegistry: PropTypes.object.isRequired - }; - - render() { - const {originUser, i18nRegistry, impersonateRestore} = this.props; - const title = i18nRegistry.translate( - 'impersonate.title.restoreUserButton', - 'Switch back to the orginal user account', - {}, - 'Neos.Neos', - 'Main' - ); - - return (originUser ? ( -
  • - -
  • - ) : null); - } -} diff --git a/packages/neos-ui/src/Containers/Drawer/UserDropDown/RestoreButtonItem.tsx b/packages/neos-ui/src/Containers/Drawer/UserDropDown/RestoreButtonItem.tsx new file mode 100644 index 0000000000..aead35e83b --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/UserDropDown/RestoreButtonItem.tsx @@ -0,0 +1,135 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import {Icon} from '@neos-project/react-ui-components'; +import I18n from '@neos-project/neos-ui-i18n'; +import {I18nRegistry} from '@neos-project/neos-ts-interfaces'; +import backend from '@neos-project/neos-ui-backend-connector'; + +import {routes} from '../../../System'; + +import buttonTheme from './style.module.css'; + +type ImpersonateAccount = { + accountIdentifier: string; + fullName: string; +}; + +type ImpersonateStatus = { + status: boolean; + user?: ImpersonateAccount; + origin?: ImpersonateAccount; +}; + +export const RestoreButtonItem: React.FC<{ + originUser?: { + fullName: string; + }; + onLoadError: (message: string) => void; + onRestoreSuccess: (message: string) => void; + onRestoreError: (message: string) => void; + i18n: I18nRegistry; +}> = (props) => { + const [impersonateStatus, setImpersonateStatus] = React.useState(null); + const title = props.i18n.translate( + 'impersonate.title.restoreUserButton', + 'Switch back to the orginal user account', + {}, + 'Neos.Neos', + 'Main' + ); + const errorMessage = props.i18n.translate( + 'impersonate.error.restoreUser', + 'Could not switch back to the original user.', + {}, + 'Neos.Neos', + 'Main' + ); + const handleClick = React.useCallback( + async function restoreOriginalUser() { + const {impersonateRestore} = backend.get().endpoints; + const feedback = await impersonateRestore(); + const originUser = feedback?.origin?.accountIdentifier; + const user = feedback?.impersonate?.accountIdentifier; + const status = feedback?.status; + + const restoreMessage = props.i18n.translate( + 'impersonate.success.restoreUser', + 'Switched back from {0} to the orginal user {1}.', + { + 0: user, + 1: originUser + }, + 'Neos.Neos', + 'Main' + ); + + if (status) { + props.onRestoreSuccess(restoreMessage); + } else { + props.onRestoreError(errorMessage); + } + + window.location.href = routes?.core?.modules?.defaultModule; + }, + [props.i18n] + ); + + React.useEffect( + () => { + (async function loadImpersonateStatus(): Promise { + try { + const {impersonateStatus: fetchImpersonateStatus} = + backend.get().endpoints; + const impersonateStatus: null|ImpersonateStatus = + await fetchImpersonateStatus(); + + if (impersonateStatus) { + setImpersonateStatus(impersonateStatus); + } + } catch (error) { + props.onLoadError((error as Error).message); + } + })(); + }, + [] + ); + + if (impersonateStatus?.status !== true) { + return null; + } + + if (!impersonateStatus.origin) { + return null; + } + + return ( +
  • + +
  • + ); +} diff --git a/packages/neos-ui/src/Containers/Drawer/UserDropDown/UserDropDown.tsx b/packages/neos-ui/src/Containers/Drawer/UserDropDown/UserDropDown.tsx new file mode 100644 index 0000000000..e9a26abe94 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/UserDropDown/UserDropDown.tsx @@ -0,0 +1,91 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +// @ts-ignore +import {connect} from 'react-redux'; + +import {neos} from '@neos-project/neos-ui-decorators'; +import {Icon, DropDown} from '@neos-project/react-ui-components'; +import {I18nRegistry} from '@neos-project/neos-ts-interfaces'; +import {NeosContextInterface} from '@neos-project/neos-ui-decorators/src/neos'; +import {showFlashMessage} from '@neos-project/neos-ui-error'; + +import {UserImage} from './UserImage'; +import {RestoreButtonItem} from './RestoreButtonItem'; + +import I18n from '@neos-project/neos-ui-i18n'; + +import style from './style.module.css'; + +import {user} from '../../../System'; + +const withNeosGlobals = neos(globalRegistry => ({ + i18nRegistry: globalRegistry.get('i18n') +})); + +const UserDropDown: React.FC<{ + i18nRegistry: I18nRegistry; + neos: NeosContextInterface; +}> = (props) => { + const logoutUri = (props.neos?.routes as any)?.core?.logout; + const userSettingsUri = (props.neos?.routes as any)?.core?.modules?.userSettings; + const {csrfToken} = document.getElementById('appContainer')!.dataset; + + return ( +
    + + + + {user.name.fullName} + + +
  • + + +
  • +
  • +
    + + +
    +
  • + showFlashMessage({ + id: 'impersonateStatusError', + severity: 'error', + message + })} + onRestoreSuccess={(message) => showFlashMessage({ + id: 'restoreUserImpersonateUser', + severity: 'success', + message + })} + onRestoreError={(message) => showFlashMessage({ + id: 'restoreUserImpersonateUser', + severity: 'success', + message + })} + /> +
    +
    +
    + ); +} + +export default withNeosGlobals(UserDropDown as any); diff --git a/packages/neos-ui/src/Containers/Drawer/UserDropDown/UserImage.js b/packages/neos-ui/src/Containers/Drawer/UserDropDown/UserImage.js deleted file mode 100644 index 17d2293340..0000000000 --- a/packages/neos-ui/src/Containers/Drawer/UserDropDown/UserImage.js +++ /dev/null @@ -1,24 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; - -import style from './style.module.css'; - -@connect(state => ({ - userFirstName: state?.user?.name?.firstName, - userLastName: state?.user?.name?.lastName -})) - -export default class UserImage extends PureComponent { - static propTypes = { - userFirstName: PropTypes.string, - userLastName: PropTypes.string - }; - - render() { - const userInitials = this.props.userFirstName?.charAt(0) + this.props.userLastName?.charAt(0); - return ( -
    {userInitials}
    - ); - } -} diff --git a/packages/neos-ui/src/Containers/Drawer/UserDropDown/UserImage.tsx b/packages/neos-ui/src/Containers/Drawer/UserDropDown/UserImage.tsx new file mode 100644 index 0000000000..b432409ba0 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/UserDropDown/UserImage.tsx @@ -0,0 +1,23 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import style from './style.module.css'; + +export const UserImage: React.FC<{ + userFirstName: string; + userLastName: string; +}> = (props) => { + const userInitials = props.userFirstName?.charAt(0) + props.userLastName?.charAt(0); + + return ( +
    {userInitials}
    + ); +} diff --git a/packages/neos-ui/src/Containers/Drawer/UserDropDown/index.js b/packages/neos-ui/src/Containers/Drawer/UserDropDown/index.js deleted file mode 100644 index 18efa136ab..0000000000 --- a/packages/neos-ui/src/Containers/Drawer/UserDropDown/index.js +++ /dev/null @@ -1,59 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; - -import {neos} from '@neos-project/neos-ui-decorators'; -import {Icon, DropDown} from '@neos-project/react-ui-components'; -import UserImage from './UserImage'; -import RestoreButtonItem from './RestoreButtonItem'; - -import I18n from '@neos-project/neos-ui-i18n'; - -import style from './style.module.css'; -@connect(state => ({ - userName: state?.user?.name?.fullName, - impersonateStatus: state?.user?.impersonate?.status -})) -@neos() -export default class UserDropDown extends PureComponent { - static propTypes = { - userName: PropTypes.string.isRequired, - impersonateStatus: PropTypes.bool.isRequired - }; - - render() { - const logoutUri = this.props.neos?.routes?.core?.logout; - const userSettingsUri = this.props.neos?.routes?.core?.modules?.userSettings; - const {csrfToken} = document.getElementById('appContainer').dataset; - return ( -
    - - - - {this.props.userName} - - -
  • - - -
  • -
  • -
    - - -
    -
  • - {this.props.impersonateStatus === true ? ( - - ) : null} -
    -
    -
    - ); - } -} diff --git a/packages/neos-ui/src/Containers/Drawer/UserDropDown/index.ts b/packages/neos-ui/src/Containers/Drawer/UserDropDown/index.ts new file mode 100644 index 0000000000..63a4888e9f --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/UserDropDown/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {default} from './UserDropDown'; diff --git a/packages/neos-ui/src/Containers/Drawer/VersionPanel/VersionPanel.tsx b/packages/neos-ui/src/Containers/Drawer/VersionPanel/VersionPanel.tsx new file mode 100644 index 0000000000..87d13bfe04 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/VersionPanel/VersionPanel.tsx @@ -0,0 +1,20 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +import {getVersion} from '@neos-project/utils-helpers'; + +import style from '../style.module.css'; + +const uiVersion = getVersion(); +export const VersionPanel: React.FC = () => ( +
    +
    UI version: {uiVersion}
    +
    +); diff --git a/packages/neos-ui/src/Containers/Drawer/VersionPanel/index.js b/packages/neos-ui/src/Containers/Drawer/VersionPanel/index.js deleted file mode 100644 index 8eecf1d727..0000000000 --- a/packages/neos-ui/src/Containers/Drawer/VersionPanel/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import React, {PureComponent} from 'react'; -import {getVersion} from '@neos-project/utils-helpers'; - -import style from '../style.module.css'; - -export default class VersionPanel extends PureComponent { - render() { - // Current version to enhance bugreports - const uiVersion = getVersion(); - - return ( -
    -
    UI version: {uiVersion}
    -
    - ); - } -} diff --git a/packages/neos-ui/src/Containers/Drawer/VersionPanel/index.ts b/packages/neos-ui/src/Containers/Drawer/VersionPanel/index.ts new file mode 100644 index 0000000000..f77ad3865a --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/VersionPanel/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {VersionPanel as default} from './VersionPanel'; diff --git a/packages/neos-ui/src/Containers/Drawer/constants.js b/packages/neos-ui/src/Containers/Drawer/constants.js deleted file mode 100644 index 15f065a3d8..0000000000 --- a/packages/neos-ui/src/Containers/Drawer/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -export const TARGET_WINDOW = 'Window'; -export const TARGET_CONTENT_CANVAS = 'ContentCanvas'; -export const THRESHOLD_MOUSE_LEAVE = 500; diff --git a/packages/neos-ui/src/Containers/Drawer/constants.ts b/packages/neos-ui/src/Containers/Drawer/constants.ts new file mode 100644 index 0000000000..bdbad066c0 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/constants.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export const THRESHOLD_MOUSE_LEAVE = 500; diff --git a/packages/neos-ui/src/Containers/Drawer/index.js b/packages/neos-ui/src/Containers/Drawer/index.js deleted file mode 100644 index a73f45aabc..0000000000 --- a/packages/neos-ui/src/Containers/Drawer/index.js +++ /dev/null @@ -1,141 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import mergeClassNames from 'classnames'; -import {connect} from 'react-redux'; - -import {actions} from '@neos-project/neos-ui-redux-store'; -import {neos} from '@neos-project/neos-ui-decorators'; - -import MenuItemGroup from './MenuItemGroup/index'; -import style from './style.module.css'; -import {TARGET_WINDOW, TARGET_CONTENT_CANVAS, THRESHOLD_MOUSE_LEAVE} from './constants'; - -@neos(globalRegistry => ({ - containerRegistry: globalRegistry.get('containers') -})) -@connect(state => ({ - isHidden: state?.ui?.drawer?.isHidden, - collapsedMenuGroups: state?.ui?.drawer?.collapsedMenuGroups -}), { - hideDrawer: actions.UI.Drawer.hide, - toggleMenuGroup: actions.UI.Drawer.toggleMenuGroup, - setContentCanvasSrc: actions.UI.ContentCanvas.setSrc -}) -export default class Drawer extends PureComponent { - static propTypes = { - isHidden: PropTypes.bool.isRequired, - collapsedMenuGroups: PropTypes.array.isRequired, - - hideDrawer: PropTypes.func.isRequired, - toggleMenuGroup: PropTypes.func.isRequired, - setContentCanvasSrc: PropTypes.func.isRequired, - - containerRegistry: PropTypes.object.isRequired, - - menuData: PropTypes.arrayOf( - PropTypes.shape({ - icon: PropTypes.string, - label: PropTypes.string.isRequired, - uri: PropTypes.string.isRequired, - target: PropTypes.string, - - children: PropTypes.arrayOf( - PropTypes.shape({ - icon: PropTypes.string, - label: PropTypes.string.isRequired, - uri: PropTypes.string, - target: PropTypes.string, - isActive: PropTypes.bool.isRequired, - skipI18n: PropTypes.bool.isRequired - }) - ) - }) - ).isRequired - }; - - state = { - mouseLeaveTimeout: null - }; - - handleMouseLeave = () => { - const {hideDrawer} = this.props; - const {mouseLeaveTimeout} = this.state; - - if (!mouseLeaveTimeout) { - const timeout = setTimeout(() => { - hideDrawer(); - this.setState({ - mouseLeaveTimeout: null - }); - }, THRESHOLD_MOUSE_LEAVE); - - this.setState({ - mouseLeaveTimeout: timeout - }); - } - } - - handleMouseEnter = () => { - const {mouseLeaveTimeout} = this.state; - - if (mouseLeaveTimeout) { - clearTimeout(mouseLeaveTimeout); - this.setState({ - mouseLeaveTimeout: null - }); - } - } - - handleMenuItemClick = (target, uri) => { - const {setContentCanvasSrc, hideDrawer} = this.props; - - switch (target) { - case TARGET_CONTENT_CANVAS: - setContentCanvasSrc(uri); - hideDrawer(); - break; - - case TARGET_WINDOW: - default: - // we do not need to do anything here, as MenuItems of type TARGET_WINDOW automatically - // wrap their contents in an -tag (such that the user can crtl-click it to open in a - // new window). - break; - } - } - - render() { - const {isHidden, menuData, collapsedMenuGroups, toggleMenuGroup, containerRegistry} = this.props; - const classNames = mergeClassNames({ - [style.drawer]: true, - [style['drawer--isHidden']]: isHidden - }); - - const BottomComponents = containerRegistry.getChildren('Drawer/Bottom'); - - return ( -
    -
    - {!isHidden && Object.entries(menuData).map(([menuGroup, menuGroupConfiguration]) => ( - toggleMenuGroup(menuGroup)} - {...menuGroupConfiguration} - /> - ))} -
    -
    - {BottomComponents.map((Item, key) => )} -
    -
    - ); - } -} diff --git a/packages/neos-ui/src/Containers/Drawer/index.ts b/packages/neos-ui/src/Containers/Drawer/index.ts new file mode 100644 index 0000000000..122c4c1382 --- /dev/null +++ b/packages/neos-ui/src/Containers/Drawer/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {Drawer as default, drawer$, toggleDrawer} from './Drawer'; diff --git a/packages/neos-ui/src/Containers/FlashMessages/FlashMessage/index.js b/packages/neos-ui/src/Containers/FlashMessages/FlashMessage/index.js deleted file mode 100644 index 278acae8f4..0000000000 --- a/packages/neos-ui/src/Containers/FlashMessages/FlashMessage/index.js +++ /dev/null @@ -1,64 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import mergeClassNames from 'classnames'; -import {IconButton, Icon} from '@neos-project/react-ui-components'; - -import style from './style.module.css'; - -export default class FlashMessage extends PureComponent { - static propTypes = { - id: PropTypes.string.isRequired, - message: PropTypes.string.isRequired, - severity: PropTypes.string.isRequired, - timeout: PropTypes.number, - onClose: PropTypes.func.isRequired - }; - - handleClose = () => { - const {onClose, id} = this.props; - - setTimeout(() => onClose(id), 100); - } - - componentDidMount() { - const {timeout} = this.props; - - if (timeout) { - setTimeout(this.handleClose, timeout); - } - } - - render() { - const {message, severity} = this.props; - const isSuccess = severity === 'success'; - const isError = severity === 'error'; - const isInfo = severity === 'info'; - - const flashMessageClasses = mergeClassNames({ - [style.flashMessage]: true, - [style['flashMessage--success']]: isSuccess, - [style['flashMessage--error']]: isError, - [style['flashMessage--info']]: isInfo - }); - - const iconName = mergeClassNames({ - check: isSuccess, - ban: isError, - info: isInfo - }) || 'info'; - - return ( -
    - -
    {message}
    - -
    - ); - } -} diff --git a/packages/neos-ui/src/Containers/FlashMessages/index.js b/packages/neos-ui/src/Containers/FlashMessages/index.js deleted file mode 100644 index 077fd4657d..0000000000 --- a/packages/neos-ui/src/Containers/FlashMessages/index.js +++ /dev/null @@ -1,48 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; - -import {actions} from '@neos-project/neos-ui-redux-store'; -import FlashMessage from './FlashMessage/index'; - -import style from './style.module.css'; - -@connect(state => ({ - flashMessages: state?.ui?.flashMessages -}), { - removeMessage: actions.UI.FlashMessages.remove -}) -export default class FlashMessages extends PureComponent { - static propTypes = { - flashMessages: PropTypes.object, - removeMessage: PropTypes.func.isRequired - }; - - static defaultProps = { - flashMessages: {} - }; - - render() { - const {flashMessages, removeMessage} = this.props; - - return ( -
    - {Object.keys(flashMessages).map(flashMessageId => { - const flashMessage = flashMessages[flashMessageId]; - const {id, message, severity, timeout} = flashMessage; - - return ( - - ); - })} -
    - ); - } -} diff --git a/packages/neos-ui/src/Containers/FlashMessages/style.module.css b/packages/neos-ui/src/Containers/FlashMessages/style.module.css deleted file mode 100644 index 853c2c4c4f..0000000000 --- a/packages/neos-ui/src/Containers/FlashMessages/style.module.css +++ /dev/null @@ -1,15 +0,0 @@ -.flashMessageContainer { - position: fixed; - z-index: var(--zIndex-FlashMessageContainer); - top: 0; - left: 50%; - width: 516px; - margin-top: 8px; - transform: translate(-50%, 0); - max-height: calc(100% - 16px); - overflow: auto; - - &:empty { - display: none; - } -} diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx index b6a20b94e8..353943ba63 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx @@ -9,8 +9,7 @@ */ import React from 'react'; -import {I18nRegistry} from '@neos-project/neos-ts-interfaces'; -import I18n from '@neos-project/neos-ui-i18n'; +import I18n, {I18nRegistry} from '@neos-project/neos-ui-i18n'; import {Icon} from '@neos-project/react-ui-components'; import {Conflict, ReasonForConflict} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; import {TypeOfChange} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx index c0e67a104a..9aa24adfd7 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx @@ -9,8 +9,8 @@ */ import React from 'react'; -import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; -import I18n from '@neos-project/neos-ui-i18n'; +import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import I18n, {I18nRegistry} from '@neos-project/neos-ui-i18n'; import {Button, Dialog, Icon} from '@neos-project/react-ui-components'; import {PublishingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Publishing'; import {Conflict, ResolutionStrategy} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx index c4feb6f686..6c46c83095 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx @@ -9,8 +9,8 @@ */ import React from 'react'; -import I18n from '@neos-project/neos-ui-i18n'; -import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import I18n, {I18nRegistry} from '@neos-project/neos-ui-i18n'; +import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; import {Button, Dialog, Icon, SelectBox, SelectBox_Option_MultiLineWithThumbnail} from '@neos-project/react-ui-components'; import {Conflict, ResolutionStrategy, SyncingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index 2da8d0a0fd..bdb242ee24 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -14,7 +14,8 @@ import {connect} from 'react-redux'; import {neos} from '@neos-project/neos-ui-decorators'; import {selectors, actions} from '@neos-project/neos-ui-redux-store'; import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; -import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import type {WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import type {I18nRegistry} from '@neos-project/neos-ui-i18n'; import {ResolutionStrategy, SyncingPhase, State as SyncingState} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; import {ConfirmationDialog} from './ConfirmationDialog'; diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/MenuToggler/MenuToggler.tsx b/packages/neos-ui/src/Containers/PrimaryToolbar/MenuToggler/MenuToggler.tsx new file mode 100644 index 0000000000..96b7cce780 --- /dev/null +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/MenuToggler/MenuToggler.tsx @@ -0,0 +1,65 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +import mergeClassNames from 'classnames'; + +import Button from '@neos-project/react-ui-components/src/Button/'; +import {neos} from '@neos-project/neos-ui-decorators'; +import {I18nRegistry} from '@neos-project/neos-ts-interfaces'; +import {useLatestState} from '@neos-project/framework-observable-react'; + +import {drawer$, toggleDrawer} from '../../Drawer'; + +import style from './style.module.css'; + +const withNeosGlobals = neos(globalRegistry => ({ + i18nRegistry: globalRegistry.get('i18n') +})); + +const StatelessMenuToggler: React.FC<{ + i18nRegistry: I18nRegistry; + + className?: string; +}> = (props) => { + const handleToggle = React.useCallback(() => { + toggleDrawer(); + }, []); + + const {className, i18nRegistry} = props; + const {isHidden: isMenuHidden} = useLatestState(drawer$); + const isMenuVisible = !isMenuHidden; + const classNames = mergeClassNames({ + [style.menuToggler]: true, + [style['menuToggler--isActive']]: isMenuVisible, + [className ?? '']: className && className.length + }); + + // + // ToDo: Replace the static 'Menu' aria-label with a label from the i18n service. + // + return ( + + ); +} + +export const MenuToggler = withNeosGlobals(StatelessMenuToggler as any); diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/MenuToggler/index.js b/packages/neos-ui/src/Containers/PrimaryToolbar/MenuToggler/index.js deleted file mode 100644 index 527fb3de97..0000000000 --- a/packages/neos-ui/src/Containers/PrimaryToolbar/MenuToggler/index.js +++ /dev/null @@ -1,65 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; -import mergeClassNames from 'classnames'; - -import Button from '@neos-project/react-ui-components/src/Button/'; -import {actions} from '@neos-project/neos-ui-redux-store'; -import {neos} from '@neos-project/neos-ui-decorators'; - -import style from './style.module.css'; - -@neos(globalRegistry => ({ - i18nRegistry: globalRegistry.get('i18n') -})) - -@connect(state => ({ - isMenuHidden: state?.ui?.drawer?.isHidden -}), { - toggleDrawer: actions.UI.Drawer.toggle -}) -export default class MenuToggler extends PureComponent { - static propTypes = { - i18nRegistry: PropTypes.object.isRequired, - - className: PropTypes.string, - isMenuHidden: PropTypes.bool.isRequired, - toggleDrawer: PropTypes.func.isRequired - }; - - handleToggle = () => { - const {toggleDrawer} = this.props; - - toggleDrawer(); - } - - render() { - const {className, isMenuHidden, i18nRegistry} = this.props; - const isMenuVisible = !isMenuHidden; - const classNames = mergeClassNames({ - [style.menuToggler]: true, - [style['menuToggler--isActive']]: isMenuVisible, - [className]: className && className.length - }); - - // - // ToDo: Replace the static 'Menu' aria-label with a label from the i18n service. - // - return ( - - ); - } -} diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/MenuToggler/index.ts b/packages/neos-ui/src/Containers/PrimaryToolbar/MenuToggler/index.ts new file mode 100644 index 0000000000..945d17c0ef --- /dev/null +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/MenuToggler/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {MenuToggler as default} from './MenuToggler'; diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx index 51a2d2b434..a618709358 100644 --- a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx @@ -14,7 +14,8 @@ import {connect} from 'react-redux'; import {actions, selectors} from '@neos-project/neos-ui-redux-store'; import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; import {neos} from '@neos-project/neos-ui-decorators'; -import {I18nRegistry, WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; +import {WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; +import type {I18nRegistry} from '@neos-project/neos-ui-i18n'; import {Button} from '@neos-project/react-ui-components'; import {WorkspaceSyncIcon} from './WorkspaceSyncIcon'; diff --git a/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js b/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js index 4a9798c932..2b05c5058e 100644 --- a/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js +++ b/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js @@ -4,9 +4,20 @@ import {createStore} from 'redux'; import {mount} from 'enzyme'; import PropertyGroup from './index'; import {WrapWithMockGlobalRegistry} from '@neos-project/neos-ui-editors/src/_lib/testUtils'; +import {setupI18n} from '@neos-project/neos-ui-i18n'; const store = createStore(state => state, {}); +beforeAll(() => { + setupI18n('en-US', 'one,other', { + 'Neos_Neos': { + 'Main': { + 'Foo group': 'Foo group' + } + } + }); +}); + test(`PropertyGroup > is rendered`, () => { const items = [ { diff --git a/packages/neos-ui/src/Containers/Root.js b/packages/neos-ui/src/Containers/Root.js index 242583db13..3a0cb78000 100644 --- a/packages/neos-ui/src/Containers/Root.js +++ b/packages/neos-ui/src/Containers/Root.js @@ -1,7 +1,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {Provider} from 'react-redux'; -import ErrorBoundary from './ErrorBoundary'; +import {ErrorBoundary} from '@neos-project/neos-ui-error'; import {DndProvider} from 'react-dnd'; import HTML5Backend from 'react-dnd-html5-backend'; import Neos from './Neos/index'; diff --git a/packages/neos-ui/src/System/index.js b/packages/neos-ui/src/System/index.ts similarity index 63% rename from packages/neos-ui/src/System/index.js rename to packages/neos-ui/src/System/index.ts index fca0607032..e3ae611a51 100644 --- a/packages/neos-ui/src/System/index.js +++ b/packages/neos-ui/src/System/index.ts @@ -1,9 +1,17 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ import {initializeJsAPI} from '@neos-project/neos-ui-backend-connector'; import fetchWithErrorHandling from '@neos-project/neos-ui-backend-connector/src/FetchWithErrorHandling/index'; +import {terminateDueToFatalInitializationError} from '@neos-project/neos-ui-error'; -import {terminateDueToFatalInitializationError} from './terminateDueToFatalInitializationError'; - -let initialData = null; +let initialData: any = null; function parseInitialData() { if (initialData) { return initialData; @@ -37,7 +45,7 @@ function parseInitialData() { } } -function getInlinedData(dataName) { +function getInlinedData(dataName: string): any { const initialData = parseInitialData(); if (dataName in initialData) { @@ -50,30 +58,32 @@ function getInlinedData(dataName) { `); } -export const appContainer = document.getElementById('appContainer'); -if (!appContainer) { +const appContainerOrNull = document.getElementById('appContainer'); +if (!appContainerOrNull) { terminateDueToFatalInitializationError(`

    This page is missing a container with the id #appContainer.

    `); } +export const appContainer = appContainerOrNull; -export const {csrfToken} = appContainer.dataset; -if (!csrfToken) { +const {csrfToken: csrfTokenOrNull} = appContainer.dataset; +if (!csrfTokenOrNull) { terminateDueToFatalInitializationError(`

    The container with the id #appContainer is missing an attribute data-csrf-token.

    `); } - +export const csrfToken = csrfTokenOrNull; fetchWithErrorHandling.setCsrfToken(csrfToken); -export const {env: systemEnv} = appContainer.dataset; -if (!systemEnv) { +const {env: systemEnvOrNull} = appContainer.dataset; +if (!systemEnvOrNull) { terminateDueToFatalInitializationError(`

    The container with the id #appContainer is missing an attribute data-env (eg. Production, Development, etc...).

    `); } +export const systemEnv = systemEnvOrNull; export const serverState = getInlinedData('initialState'); @@ -85,7 +95,34 @@ export const frontendConfiguration = getInlinedData('frontendConfiguration'); export const routes = getInlinedData('routes'); -export const menu = getInlinedData('menu'); +export const menu: { + label: string; + icon: string; + uri: string; + target: 'Window'; + children: { + icon: string; + label: string; + uri: string; + target: 'Window'; + isActive: boolean; + skipI18n: boolean; + }[] +}[] = getInlinedData('menu'); + +export const user: { + name: { + title: string; + firstName: string; + middleName: string; + lastName: string; + otherName: string; + fullName: string; + }; + preferences: { + interfaceLanguage: null | string; + }; +} = getInlinedData('user'); export const neos = initializeJsAPI(window, { systemEnv, diff --git a/packages/neos-ui/src/index.js b/packages/neos-ui/src/index.js index b54f1c2bf0..83fc54356c 100644 --- a/packages/neos-ui/src/index.js +++ b/packages/neos-ui/src/index.js @@ -10,6 +10,8 @@ import fetchWithErrorHandling from '@neos-project/neos-ui-backend-connector/src/ import {SynchronousMetaRegistry} from '@neos-project/neos-ui-extensibility/src/registry'; import backend from '@neos-project/neos-ui-backend-connector'; import {handleActions} from '@neos-project/utils-redux'; +import {showFlashMessage} from '@neos-project/neos-ui-error'; +import {initializeI18n} from '@neos-project/neos-ui-i18n'; import { appContainer, @@ -18,6 +20,7 @@ import { routes, serverState, menu, + user, nodeTypes } from './System'; import localStorageMiddleware from './localStorageMiddleware'; @@ -63,8 +66,7 @@ async function main() { await Promise.all([ loadNodeTypesSchema(), - loadTranslations(), - loadImpersonateStatus() + initializeI18n() ]); store.dispatch(actions.System.ready()); @@ -102,7 +104,17 @@ function initializeReduxState() { const persistedState = localStorage.getItem('persistedState') ? JSON.parse(localStorage.getItem('persistedState')) : {}; - const mergedState = merge({}, serverState, persistedState); + const mergedState = merge( + {}, + serverState, + // QUIRK ALERT: + // The `user` state used to be part of `initialState` (a.k.a. + // `serverState`) but has been moved to a separate key within + // `initialData`. It is still being merged at this point to + // keep downstream impact at a minimum. + {user}, + persistedState + ); store.dispatch(actions.System.init(mergedState)); } @@ -141,7 +153,11 @@ function initializeFetchWithErrorHandling() { message = exception.textContent; } - store.dispatch(actions.UI.FlashMessages.add('fetch error', message, 'error')); + showFlashMessage({ + id: 'fetch error', + severity: 'error', + message + }); }); } @@ -165,26 +181,6 @@ async function loadNodeTypesSchema() { nodeTypesRegistry.setRoles(roles); } -async function loadTranslations() { - const {getJsonResource} = backend.get().endpoints; - const i18nRegistry = globalRegistry.get('i18n'); - const translations = await getJsonResource(configuration.endpoints.translations); - - i18nRegistry.setTranslations(translations); -} - -async function loadImpersonateStatus() { - try { - const {impersonateStatus} = backend.get().endpoints; - const impersonateState = await impersonateStatus(); - if (impersonateState) { - store.dispatch(actions.User.Impersonate.fetchStatus(impersonateState)); - } - } catch (error) { - store.dispatch(actions.UI.FlashMessages.add('impersonateStatusError', error.message, 'error')); - } -} - function renderApplication() { ReactDOM.render( { // // Create edit preview mode registry @@ -191,12 +193,13 @@ manifest('main', {}, (globalRegistry, {routes}) => { // // Take care of message feedback // - const flashMessageFeedbackHandler = (feedbackPayload, {store}) => { - const {message, severity} = feedbackPayload; - const timeout = severity.toLowerCase() === 'success' ? 5000 : 0; + const flashMessageFeedbackHandler = (feedbackPayload) => { + const {message} = feedbackPayload; + const severity = feedbackPayload.severity.toLowerCase(); + const timeout = severity === 'success' ? 5000 : 0; const id = uuid.v4(); - store.dispatch(actions.UI.FlashMessages.add(id, message, severity, timeout)); + showFlashMessage({id, message, severity, timeout}); }; serverFeedbackHandlers.set('Neos.Neos.Ui:Success/Main', flashMessageFeedbackHandler); serverFeedbackHandlers.set('Neos.Neos.Ui:Error/Main', flashMessageFeedbackHandler); diff --git a/packages/react-ui-components/src/DropDown/__snapshots__/contents.spec.tsx.snap b/packages/react-ui-components/src/DropDown/__snapshots__/contents.spec.tsx.snap new file mode 100644 index 0000000000..7d7cadaeff --- /dev/null +++ b/packages/react-ui-components/src/DropDown/__snapshots__/contents.spec.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should not render when having no children. 1`] = ` + +`; diff --git a/packages/react-ui-components/src/DropDown/contents.spec.tsx b/packages/react-ui-components/src/DropDown/contents.spec.tsx index 205d9914ee..85bd3fb1a9 100644 --- a/packages/react-ui-components/src/DropDown/contents.spec.tsx +++ b/packages/react-ui-components/src/DropDown/contents.spec.tsx @@ -21,7 +21,7 @@ describe('', () => { it('should not render when having no children.', () => { const wrapper = shallow(); - expect(toJson(wrapper)).toBeFalsy(); + expect(toJson(wrapper)).toMatchSnapshot(); }); it('should allow the propagation of "className" with the "className" prop.', () => { diff --git a/packages/react-ui-components/src/DropDown/contents.tsx b/packages/react-ui-components/src/DropDown/contents.tsx index 9a1a757383..02b6d6d9bf 100644 --- a/packages/react-ui-components/src/DropDown/contents.tsx +++ b/packages/react-ui-components/src/DropDown/contents.tsx @@ -214,25 +214,22 @@ export default class ShallowDropDownContents extends PureComponent - {children} - - ); - - return scrollable - ? ReactDOM.createPortal(contents, document.body) - : contents; - } - return null; + const contents = ( +
      + {children} +
    + ); + + return scrollable + ? ReactDOM.createPortal(contents, document.body) + : contents; } } diff --git a/packages/react-ui-components/src/DropDown/index.ts b/packages/react-ui-components/src/DropDown/index.ts index 92f50750f8..477d3d8712 100644 --- a/packages/react-ui-components/src/DropDown/index.ts +++ b/packages/react-ui-components/src/DropDown/index.ts @@ -1,3 +1,12 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ import {themr} from '@friendsofreactjs/react-css-themr'; import identifiers from '../identifiers'; @@ -14,11 +23,8 @@ const StatelessDropDown = themr(identifiers.dropDown, style)(StatelessDropDownWr const DropDownHeader = themr(identifiers.dropDownHeader, style)(ContextDropDownHeader); const DropDownContents = themr(identifiers.dropDownContents, style)(ContextDropDownContents); -// @ts-ignore -DropDown.Header = DropDownHeader; -// @ts-ignore -DropDown.Contents = DropDownContents; -// @ts-ignore -DropDown.Stateless = StatelessDropDown; - -export default DropDown; +export default Object.assign(DropDown, { + Header: DropDownHeader, + Contents: DropDownContents, + Stateless: StatelessDropDown +}); diff --git a/packages/react-ui-components/src/global.d.ts b/packages/react-ui-components/src/global.d.ts index 35306c6fc9..31e44ff3e2 100644 --- a/packages/react-ui-components/src/global.d.ts +++ b/packages/react-ui-components/src/global.d.ts @@ -1 +1,2 @@ declare module '*.css'; +declare module '*.svg'; diff --git a/yarn.lock b/yarn.lock index ed4a228d78..c681ebe350 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2277,6 +2277,21 @@ __metadata: languageName: node linkType: hard +"@neos-project/framework-observable-react@workspace:*, @neos-project/framework-observable-react@workspace:packages/framework-observable-react": + version: 0.0.0-use.local + resolution: "@neos-project/framework-observable-react@workspace:packages/framework-observable-react" + dependencies: + "@neos-project/framework-observable": "workspace:*" + react: ^16.12.0 + languageName: unknown + linkType: soft + +"@neos-project/framework-observable@workspace:*, @neos-project/framework-observable@workspace:packages/framework-observable": + version: 0.0.0-use.local + resolution: "@neos-project/framework-observable@workspace:packages/framework-observable" + languageName: unknown + linkType: soft + "@neos-project/jest-preset-neos-ui@workspace:*, @neos-project/jest-preset-neos-ui@workspace:packages/jest-preset-neos-ui": version: 0.0.0-use.local resolution: "@neos-project/jest-preset-neos-ui@workspace:packages/jest-preset-neos-ui" @@ -2295,6 +2310,7 @@ __metadata: resolution: "@neos-project/neos-ts-interfaces@workspace:packages/neos-ts-interfaces" dependencies: "@neos-project/jest-preset-neos-ui": "workspace:*" + "@neos-project/neos-ui-i18n": "workspace:*" typescript: ^4.6.4 languageName: unknown linkType: soft @@ -2441,7 +2457,10 @@ __metadata: version: 0.0.0-use.local resolution: "@neos-project/neos-ui-error@workspace:packages/neos-ui-error" dependencies: + "@neos-project/framework-observable": "workspace:*" + "@neos-project/framework-observable-react": "workspace:*" "@neos-project/neos-ui-i18n": "workspace:*" + "@neos-project/react-ui-components": "workspace:*" classnames: ^2.2.3 react: ^16.12.0 languageName: unknown @@ -2498,8 +2517,6 @@ __metadata: resolution: "@neos-project/neos-ui-i18n@workspace:packages/neos-ui-i18n" dependencies: "@neos-project/jest-preset-neos-ui": "workspace:*" - "@neos-project/neos-ts-interfaces": "workspace:*" - "@neos-project/neos-ui-decorators": "workspace:*" "@neos-project/neos-ui-extensibility": "workspace:*" "@neos-project/utils-logger": "workspace:*" enzyme: ^3.8.0 @@ -2551,6 +2568,7 @@ __metadata: dependencies: "@neos-project/jest-preset-neos-ui": "workspace:*" "@neos-project/neos-ui-backend-connector": "workspace:*" + "@neos-project/neos-ui-error": "workspace:*" "@neos-project/neos-ui-extensibility": "workspace:*" "@neos-project/neos-ui-guest-frame": "workspace:*" "@neos-project/neos-ui-redux-store": "workspace:*" @@ -2614,6 +2632,8 @@ __metadata: "@fortawesome/free-solid-svg-icons": ^5.15.3 "@friendsofreactjs/react-css-themr": ~4.2.0 "@neos-project/debug-reason-for-rendering": "workspace:*" + "@neos-project/framework-observable": "workspace:*" + "@neos-project/framework-observable-react": "workspace:*" "@neos-project/jest-preset-neos-ui": "workspace:*" "@neos-project/neos-ts-interfaces": "workspace:*" "@neos-project/neos-ui-backend-connector": "workspace:*" @@ -5364,6 +5384,15 @@ __metadata: languageName: node linkType: hard +"cross-fetch@npm:^4.0.0": + version: 4.0.0 + resolution: "cross-fetch@npm:4.0.0" + dependencies: + node-fetch: ^2.6.12 + checksum: ecca4f37ffa0e8283e7a8a590926b66713a7ef7892757aa36c2d20ffa27b0ac5c60dcf453119c809abe5923fc0bae3702a4d896bfb406ef1077b0d0018213e24 + languageName: node + linkType: hard + "cross-spawn@npm:^6.0.5": version: 6.0.5 resolution: "cross-spawn@npm:6.0.5" @@ -11033,6 +11062,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.12": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: ^5.0.0 + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: d76d2f5edb451a3f05b15115ec89fc6be39de37c6089f1b6368df03b91e1633fd379a7e01b7ab05089a25034b2023d959b47e59759cb38d88341b2459e89d6e5 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 9.0.0 resolution: "node-gyp@npm:9.0.0" @@ -13174,6 +13217,7 @@ __metadata: "@neos-project/eslint-config-neos": ^2.6.1 "@typescript-eslint/eslint-plugin": ^5.44.0 "@typescript-eslint/parser": ^5.44.0 + cross-fetch: ^4.0.0 editorconfig-checker: ^4.0.2 esbuild: ~0.17.0 eslint: ^8.27.0