diff --git a/Source/JavaScript/engine/aggregateContributions.ts b/Source/JavaScript/engine/aggregateContributions.ts new file mode 100644 index 0000000..15c340e --- /dev/null +++ b/Source/JavaScript/engine/aggregateContributions.ts @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Contribution } from '@cratis/scene.model'; + +/** + * Aggregates every {@link Contribution} targeting a given contribution point, in render order - part of + * Cratis/Scene#2. A widget bound to a contribution point is never wired to a fixed source: call this + * again whenever the underlying contribution set changes and it recomputes, the same way a projection + * recomputes a read model from events. + * + * Resolving *which* contribution point a `contribute to` declaration targets (nearest enclosing, or an + * explicit override) already happened before a {@link Contribution} exists in this model - see its own + * doc comment - so there is no ambiguity left to resolve here, only ordering. + * + * @param contributions Every contribution in scope, targeting any contribution point. + * @param contributionPointName The name of the contribution point to aggregate. + * @returns The matching contributions, ordered by `order` ascending; contributions with no `order` sort after every ordered one, in their original relative order. + */ +export function aggregateContributions(contributions: Contribution[], contributionPointName: string): Contribution[] { + return contributions + .map((contribution, index) => ({ contribution, index })) + .filter(({ contribution }) => contribution.contributionPointName === contributionPointName) + .sort((a, b) => { + if (a.contribution.order === undefined && b.contribution.order === undefined) { + return a.index - b.index; + } + + if (a.contribution.order === undefined) { + return 1; + } + + if (b.contribution.order === undefined) { + return -1; + } + + return a.contribution.order - b.contribution.order; + }) + .map(({ contribution }) => contribution); +} diff --git a/Source/JavaScript/engine/for_aggregateContributions/when_aggregating_by_contribution_point.ts b/Source/JavaScript/engine/for_aggregateContributions/when_aggregating_by_contribution_point.ts new file mode 100644 index 0000000..da9fbad --- /dev/null +++ b/Source/JavaScript/engine/for_aggregateContributions/when_aggregating_by_contribution_point.ts @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Contribution } from '@cratis/scene.model'; +import { aggregateContributions } from '../index'; + +function contribution(contributionPointName: string, id: string, order?: number): Contribution { + return { contributionPointName, content: { id, properties: {} }, order }; +} + +describe('when aggregating by contribution point', () => { + it('should exclude contributions targeting a different contribution point', () => { + const result = aggregateContributions([contribution('Navigation', 'a'), contribution('Toolbar', 'b')], 'Navigation'); + result.map(item => item.content.id).should.deep.equal(['a']); + }); + + it('should sort ordered contributions ascending by order', () => { + const result = aggregateContributions([contribution('Navigation', 'second', 20), contribution('Navigation', 'first', 10)], 'Navigation'); + result.map(item => item.content.id).should.deep.equal(['first', 'second']); + }); + + it('should sort unordered contributions after every ordered one', () => { + const result = aggregateContributions([contribution('Navigation', 'unordered'), contribution('Navigation', 'ordered', 10)], 'Navigation'); + result.map(item => item.content.id).should.deep.equal(['ordered', 'unordered']); + }); + + it('should preserve original relative order among unordered contributions', () => { + const result = aggregateContributions([contribution('Navigation', 'first'), contribution('Navigation', 'second')], 'Navigation'); + result.map(item => item.content.id).should.deep.equal(['first', 'second']); + }); +}); diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index b6638e3..a650624 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -10,3 +10,4 @@ export * from './resolveComponentName'; export * from './computeSizeClass'; export * from './evaluateFlowArrangement'; export * from './evaluateFreeformArrangement'; +export * from './aggregateContributions'; diff --git a/Source/JavaScript/react/NavBar/NavBar.tsx b/Source/JavaScript/react/NavBar/NavBar.tsx new file mode 100644 index 0000000..5e23c17 --- /dev/null +++ b/Source/JavaScript/react/NavBar/NavBar.tsx @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Contribution, NavigationItem } from '@cratis/scene.model'; +import { aggregateContributions } from '@cratis/scene.engine'; +import { extractNavigationItem } from './extractNavigationItem'; + +export interface NavBarProps { + /** Every contribution in scope - `NavBar` aggregates the ones targeting `Navigation` itself. */ + contributions: Contribution[]; + + /** + * Turns a {@link NavigationItem}'s target screen and route parameters into a concrete route - a URL + * path, a query string, or a native deep link. Owning this as a caller-supplied function, rather than + * a fixed URL scheme, is what makes `NavBar` usable unmodified across renderers (Cratis/Scene#2). + */ + renderRoute: (item: NavigationItem) => string; +} + +/** + * Renders the aggregated `Navigation` contributions from across the current element tree - part of + * Cratis/Scene#2. `NavBar` is not wired to a fixed source: pass it whatever contributions are currently + * in scope and it recomputes, the same way any contribution-point consumer does. + */ +export function NavBar({ contributions, renderRoute }: NavBarProps) { + const items = aggregateContributions(contributions, 'Navigation') + .map(extractNavigationItem) + .filter((item): item is NavigationItem => item !== undefined); + + const hasGroups = items.some(item => item.group !== undefined); + if (!hasGroups) { + return ( + + ); + } + + const groupNames = [...new Set(items.map(item => item.group ?? ''))]; + return ( + + ); +} + +function NavBarItem({ item, href }: { item: NavigationItem; href: string }) { + return {item.label}; +} diff --git a/Source/JavaScript/react/NavBar/extractNavigationItem.ts b/Source/JavaScript/react/NavBar/extractNavigationItem.ts new file mode 100644 index 0000000..40a1397 --- /dev/null +++ b/Source/JavaScript/react/NavBar/extractNavigationItem.ts @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BindingExpression, Contribution, ExternalComponent, NavigationItem } from '@cratis/scene.model'; + +/** + * Reads a {@link NavigationItem} out of a {@link Contribution} to the built-in `Navigation` contribution + * point. `Contribution.content` carries an {@link ExternalComponent} whose open `properties` bag holds the + * navigation-specific values (`label`, `targetScreen`, `routeParameterBindings`, `order`, `group`) - the + * same names as {@link NavigationItem}'s own fields. This property-bag contract is Scene#2's own choice, + * made without a real Screenplay-to-Scene translation to confirm it against (Stage#37 hasn't been built + * yet) - whoever builds that seam should either produce contributions matching this contract or this + * function should move to match whatever Stage#37 actually emits. + * + * @param contribution The contribution to read. + * @returns The extracted {@link NavigationItem}, or `undefined` when `contribution.content` isn't an {@link ExternalComponent} or is missing a required property. + */ +export function extractNavigationItem(contribution: Contribution): NavigationItem | undefined { + const content = contribution.content as Partial; + if (!content.properties) { + return undefined; + } + + const { label, targetScreen, routeParameterBindings, order, group } = content.properties as Record; + if (typeof label !== 'string' || typeof targetScreen !== 'string') { + return undefined; + } + + return { + label, + targetScreen, + routeParameterBindings: isBindingExpressionRecord(routeParameterBindings) ? routeParameterBindings : {}, + order: typeof order === 'number' ? order : contribution.order, + group: typeof group === 'string' ? group : undefined, + }; +} + +function isBindingExpressionRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/Source/JavaScript/react/NavBar/for_NavBar/when_rendering_navigation_contributions.tsx b/Source/JavaScript/react/NavBar/for_NavBar/when_rendering_navigation_contributions.tsx new file mode 100644 index 0000000..e5e8bae --- /dev/null +++ b/Source/JavaScript/react/NavBar/for_NavBar/when_rendering_navigation_contributions.tsx @@ -0,0 +1,62 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { render, screen } from '@testing-library/react'; +import { Contribution, ExternalComponent, HorizontalAlignment, VerticalAlignment, Visibility } from '@cratis/scene.model'; +import { NavBar } from '../NavBar'; + +function navigationContribution(id: string, properties: Record, order?: number): Contribution { + const content: ExternalComponent = { + id, + name: id, + properties, + visibility: Visibility.Visible, + isEnabled: true, + opacity: 1, + size: {}, + zIndex: 0, + minimumSize: {}, + maximumSize: {}, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + horizontalAlignment: HorizontalAlignment.Stretch, + verticalAlignment: VerticalAlignment.Stretch, + componentName: 'core:navigation-item', + slots: {}, + }; + return { contributionPointName: 'Navigation', content, order }; +} + +describe('when rendering navigation contributions', () => { + describe('and no item declares a group', () => { + const contributions = [ + navigationContribution('adjustments', { label: 'Adjustments', targetScreen: 'Adjustments' }, 20), + navigationContribution('invoices', { label: 'Invoices', targetScreen: 'InvoiceList' }, 10), + ]; + + beforeEach(() => { + render( `/${item.targetScreen}`} />); + }); + + it('should render every item as a link to its rendered route', () => { + screen.getByRole('link', { name: 'Invoices' }).getAttribute('href')!.should.equal('/InvoiceList'); + screen.getByRole('link', { name: 'Adjustments' }).getAttribute('href')!.should.equal('/Adjustments'); + }); + + it('should render items in aggregated order', () => { + const links = screen.getAllByRole('link').map(link => link.textContent); + links.should.deep.equal(['Invoices', 'Adjustments']); + }); + }); + + describe('and a contribution cannot be extracted into a navigation item', () => { + const contributions = [navigationContribution('broken', { label: 'Broken' })]; + + beforeEach(() => { + render( `/${item.targetScreen}`} />); + }); + + it('should skip it rather than render a broken link', () => { + screen.queryAllByRole('link').should.have.lengthOf(0); + }); + }); +}); diff --git a/Source/JavaScript/react/NavBar/for_extractNavigationItem/when_extracting_from_a_navigation_contribution.ts b/Source/JavaScript/react/NavBar/for_extractNavigationItem/when_extracting_from_a_navigation_contribution.ts new file mode 100644 index 0000000..f4642f9 --- /dev/null +++ b/Source/JavaScript/react/NavBar/for_extractNavigationItem/when_extracting_from_a_navigation_contribution.ts @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Contribution, ExternalComponent, HorizontalAlignment, NavigationItem, VerticalAlignment, Visibility } from '@cratis/scene.model'; +import { extractNavigationItem } from '../extractNavigationItem'; + +function navigationContribution(properties: Record, order?: number): Contribution { + const content: ExternalComponent = { + id: 'nav-item', + name: 'nav-item', + properties, + visibility: Visibility.Visible, + isEnabled: true, + opacity: 1, + size: {}, + zIndex: 0, + minimumSize: {}, + maximumSize: {}, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + horizontalAlignment: HorizontalAlignment.Stretch, + verticalAlignment: VerticalAlignment.Stretch, + componentName: 'core:navigation-item', + slots: {}, + }; + return { contributionPointName: 'Navigation', content, order }; +} + +describe('when extracting from a navigation contribution', () => { + describe('and the required properties are present', () => { + let result: NavigationItem | undefined; + + beforeEach(() => { + result = extractNavigationItem(navigationContribution({ label: 'Invoices', targetScreen: 'InvoiceList', group: 'Sales' }, 10)); + }); + + it('should extract the label', () => result!.label.should.equal('Invoices')); + it('should extract the target screen', () => result!.targetScreen.should.equal('InvoiceList')); + it('should extract the group', () => result!.group!.should.equal('Sales')); + it('should fall back to the contribution order when the properties bag has none', () => result!.order!.should.equal(10)); + }); + + describe('and the label is missing', () => { + it('should return undefined', () => { + (extractNavigationItem(navigationContribution({ targetScreen: 'InvoiceList' })) === undefined).should.be.true; + }); + }); + + describe('and the target screen is missing', () => { + it('should return undefined', () => { + (extractNavigationItem(navigationContribution({ label: 'Invoices' })) === undefined).should.be.true; + }); + }); +}); diff --git a/Source/JavaScript/react/NavBar/index.ts b/Source/JavaScript/react/NavBar/index.ts new file mode 100644 index 0000000..877e1ef --- /dev/null +++ b/Source/JavaScript/react/NavBar/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './NavBar'; +export * from './extractNavigationItem'; diff --git a/Source/JavaScript/react/index.ts b/Source/JavaScript/react/index.ts index 899f547..33e681d 100644 --- a/Source/JavaScript/react/index.ts +++ b/Source/JavaScript/react/index.ts @@ -4,3 +4,4 @@ export * from './SceneElementView'; export * from './renderer'; export * from './core'; +export * from './NavBar';