Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Source/JavaScript/engine/aggregateContributions.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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']);
});
});
1 change: 1 addition & 0 deletions Source/JavaScript/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './resolveComponentName';
export * from './computeSizeClass';
export * from './evaluateFlowArrangement';
export * from './evaluateFreeformArrangement';
export * from './aggregateContributions';
53 changes: 53 additions & 0 deletions Source/JavaScript/react/NavBar/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav data-scene-navbar="">
{items.map(item => <NavBarItem key={item.targetScreen} item={item} href={renderRoute(item)} />)}
</nav>
);
}

const groupNames = [...new Set(items.map(item => item.group ?? ''))];
return (
<nav data-scene-navbar="">
{groupNames.map(groupName => (
<section key={groupName} data-scene-navbar-group={groupName}>
{items.filter(item => (item.group ?? '') === groupName).map(item => <NavBarItem key={item.targetScreen} item={item} href={renderRoute(item)} />)}
</section>
))}
</nav>
);
}

function NavBarItem({ item, href }: { item: NavigationItem; href: string }) {
return <a href={href}>{item.label}</a>;
}
40 changes: 40 additions & 0 deletions Source/JavaScript/react/NavBar/extractNavigationItem.ts
Original file line number Diff line number Diff line change
@@ -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<ExternalComponent>;
if (!content.properties) {
return undefined;
}

const { label, targetScreen, routeParameterBindings, order, group } = content.properties as Record<string, unknown>;
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<string, BindingExpression> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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(<NavBar contributions={contributions} renderRoute={item => `/${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(<NavBar contributions={contributions} renderRoute={item => `/${item.targetScreen}`} />);
});

it('should skip it rather than render a broken link', () => {
screen.queryAllByRole('link').should.have.lengthOf(0);
});
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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;
});
});
});
5 changes: 5 additions & 0 deletions Source/JavaScript/react/NavBar/index.ts
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions Source/JavaScript/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
export * from './SceneElementView';
export * from './renderer';
export * from './core';
export * from './NavBar';
Loading