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
14 changes: 11 additions & 3 deletions Source/JavaScript/blueprint.default/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ shell versions win the bare names while PrimeReact's stay reachable as `PrimeRea
`GalleryScreenPreview` boots one gallery screen through the real path - real `Screen`, real
`resolveComponentName`, real `SceneElementView`, real registry. Nothing in it is a preview-only code path.

It resolves against `galleryPreviewProfile` rather than `galleryProfile`. Both describe the gallery's optional
widget vocabulary; only a host that explicitly lists `PrimeReact` loads those widgets. A profile naming
It resolves against `galleryProfile` when its registry implements every package that profile declares (PrimeReact by
every component the gallery borrows from it), and against `galleryPreviewProfile` otherwise, so a partial bundle
renders what exists instead of placeholders. Only a host that explicitly lists `PrimeReact` loads those widgets. A profile naming
only `Cratis.Blueprint.Default` resolves the shell and Cratis Components, renders the foundation templates,
and leaves templates requiring the optional package incompatible in the template catalog.

Expand All @@ -153,9 +154,16 @@ which the Cratis Breadcrumbs destination callback does not expose. Shell icons a
installed icon font. Layout modes, breakpoints, persistence, navigation policies and theme switching are
unchanged.

The shell and foundation need no `PrimeReactProvider`, widget preset or icon font. Storybook applies none
The shell and foundation need no `PrimeReactProvider` or widget preset. The optional `actionButton.icon`
property renders decorative PrimeIcons classes; hosts using it supply the unchanged PrimeIcons font.
The button's label remains its accessible name. Storybook applies no widget provider, preset or font
globally. Stories that preview optional widgets own their provider, preset and font imports instead.

Landing, ModuleWorkspace, FeatureSection and SliceSection use blueprint-owned headers and controls rather
than library-selected cards, text and buttons. Their primary and secondary actions keep their emphasis;
Landing's sign-in and start actions retain their target-screen navigation. FeatureSection keeps its
PrimeIcons. Headings are rendered once, including with the complete widget registry.

Older business templates still borrow widgets from `scene.primereact`. Their `templateProvenance` and the
gallery dependency vocabulary declare that requirement locally; a host opts into the package in its UI
profile when it chooses such a template. The shell layouts declare only their own component requirement.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { cleanup, screen } from '@testing-library/react';
import { renderFoundation } from '../for_foundation/given';

describe('when rendering an action icon', () => {
afterEach(cleanup);

it('should render the optional icon decoratively without changing the accessible name', () => {
renderFoundation('actionButton', { label: 'New', icon: 'pi pi-plus', emphasis: 'primary' });
const button = screen.getByRole('button', { name: 'New' });
expect(button.querySelector('i')?.className).toBe('pi pi-plus');
expect(button.querySelector('i')?.getAttribute('aria-hidden')).toBe('true');
});

it('should keep actions without an icon unchanged', () => {
renderFoundation('actionButton', { label: 'Cancel', emphasis: 'secondary' });
expect(screen.getByRole('button', { name: 'Cancel' }).querySelector('i')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
distinctComponentNames,
galleryComponentCatalog,
galleryDialogTemplates,
galleryPreviewProfile,
galleryProfile,
galleryScreens,
} from '../gallery';
Expand Down Expand Up @@ -61,4 +62,14 @@ describe('when resolving the component names they reference', () => {
.filter(name => !referenced.has(name))
.should.be.empty;
});

it('should let PrimeReact provide the shared names it declares in the full gallery profile', () => {
['text', 'button', 'card'].map(name => resolveComponentName(name, galleryProfile, galleryComponentCatalog)!.package)
.should.deep.equal(['PrimeReact', 'PrimeReact', 'PrimeReact']);
});

it('should let core provide the shared names in the partial-bundle preview profile', () => {
['text', 'button', 'card'].map(name => resolveComponentName(name, galleryPreviewProfile, galleryComponentCatalog)!.package)
.should.deep.equal(['core', 'core', 'core']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { cleanup, render, screen } from '@testing-library/react';
import { ComponentRegistry, corePackage } from '@cratis/scene.react';
import { defaultBlueprintComponents } from '../defaultBlueprintComponents';
import { defaultBlueprintName } from '../packageName';
import { GalleryScreenPreview, TemplatePreviewState, galleryComponentCatalog, galleryPreviewProfile, galleryProfile, previewProfileFor } from '../gallery';
import { primeReactComponentNames } from '../gallery/dependencyComponentNames';
import { resolveComponentName } from '@cratis/scene.engine';
import { provideBrowserApis } from '../for_templatePreviewStates/given';

const stand = () => null;
const partial: ComponentRegistry = { ...corePackage.components, ...defaultBlueprintComponents };
const primeReact = Object.fromEntries(primeReactComponentNames.map(name => [`PrimeReact:${name}`, stand]));
const full: ComponentRegistry = { ...partial, ...primeReact, 'Cratis.Components:dataPage': stand };
const sharedNames = ['text', 'button', 'card'];
const providers = (registry: ComponentRegistry) => sharedNames.map(name => resolveComponentName(name, previewProfileFor(registry), galleryComponentCatalog)!.package);

describe('when choosing the profile for a registry', () => {
beforeEach(provideBrowserApis);
afterEach(cleanup);

it('should use the full gallery profile when every package it declares is implemented', () => previewProfileFor(full).should.equal(galleryProfile));
it('should let PrimeReact provide the shared names it declares with the full registry', () => providers(full).should.deep.equal(['PrimeReact', 'PrimeReact', 'PrimeReact']));
it('should resolve differently from the partial registry for every shared name PrimeReact declares', () => primeReactComponentNames.filter(name => sharedNames.includes(name)).forEach(name => resolveComponentName(name, previewProfileFor(full), galleryComponentCatalog)!.package.should.not.equal(resolveComponentName(name, previewProfileFor(partial), galleryComponentCatalog)!.package)));
it('should keep the partial profile with core and the blueprint only', () => previewProfileFor(partial).should.equal(galleryPreviewProfile));
it('should let core provide the shared names with a partial registry', () => providers(partial).should.deep.equal(['core', 'core', 'core']));
it('should keep the partial profile when a listed package implements nothing', () => previewProfileFor({ ...partial, 'PrimeReact:text': stand, 'Cratis.Components:dataPage': stand }).should.equal(galleryPreviewProfile));

for (const name of ['Landing', 'ModuleWorkspace', 'FeatureSection', 'SliceSection']) {
it(`should render ${name} with a partial registry and no placeholders`, () => {
const { container } = render(<GalleryScreenPreview screenName={name} registry={partial} state={TemplatePreviewState.Populated} />);
(container.querySelector('[data-scene-unresolved-component]') === null).should.equal(true);
});
}

it('should render Landing without placeholders when PrimeReact has text but not card', () => {
const partlyLoaded = { ...partial, 'PrimeReact:text': stand, 'Cratis.Components:dataPage': stand };
previewProfileFor(partlyLoaded).should.equal(galleryPreviewProfile);
const { container } = render(<GalleryScreenPreview screenName='Landing' registry={partlyLoaded} state={TemplatePreviewState.Populated} />);
(container.querySelector('[data-scene-unresolved-component]') === null).should.equal(true);
});

it('should keep Landing headings singular and its actions independent of the complete widget registry', () => {
render(<GalleryScreenPreview screenName='Landing' registry={full} />);
for (const title of ['Every mode people expect', 'Themeable to the token', 'The pages you were going to build anyway']) {
expect(screen.getAllByText(title)).toHaveLength(1);
}
expect(screen.getByRole('button', { name: 'Sign in' }).getAttribute('data-emphasis')).toBe('secondary');
expect(screen.getAllByRole('button', { name: 'Start free' }).every(button => button.getAttribute('data-emphasis') === 'primary')).toBe(true);
});

it('should name the blueprint as a package of the full profile', () => galleryProfile.packages.should.include(defaultBlueprintName));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { SceneElementView } from '@cratis/scene.react';
import { galleryComponentCatalog, galleryPreviewRegistry, galleryScreenTemplates, resolveElementComponentNames } from '../gallery';
import { defaultBlueprintName } from '../packageName';

const profile = { name: 'Neutral', targetPlatform: 'web', packages: ['core', defaultBlueprintName] };

function renderTemplate(name: string) {
const template = galleryScreenTemplates.find(candidate => candidate.name === name)!;
const elements = Object.values(template.content ?? {}).flat().map(element => resolveElementComponentNames(element, profile, galleryComponentCatalog));
return render(<>{elements.map(element => <SceneElementView key={element.id} element={element} registry={galleryPreviewRegistry} resolveBinding={() => undefined} />)}</>);
}

describe('when rendering the neutral gallery templates', () => {
afterEach(() => {
cleanup();
window.location.hash = '';
});

it('should keep Landing actions named, distinguished and navigable without a widget library', () => {
const { container } = renderTemplate('Landing');
expect(container.querySelector('[data-scene-unresolved-component]')).toBeNull();
const signIn = screen.getByRole('button', { name: 'Sign in' });
expect(signIn.getAttribute('data-emphasis')).toBe('secondary');
fireEvent.click(signIn);
expect(window.location.hash).toBe('#/Login');
for (const start of screen.getAllByRole('button', { name: 'Start free' })) {
expect(start.getAttribute('data-emphasis')).toBe('primary');
window.location.hash = '';
fireEvent.click(start);
expect(window.location.hash).toBe('#/Register');
}
expect(screen.getByRole('button', { name: 'Talk to us' }).getAttribute('data-emphasis')).toBe('secondary');
for (const title of ['Ship the shell on day one', 'Every mode people expect', 'Themeable to the token', 'The pages you were going to build anyway']) {
expect(screen.getAllByText(title)).toHaveLength(1);
}
});

it('should keep ModuleWorkspace headings and content without inventing actions', () => {
const { container } = renderTemplate('ModuleWorkspace');
screen.getByRole('heading', { name: 'Operations' });
screen.getByText('Everything the operations module owns');
expect(screen.queryAllByRole('button')).toHaveLength(0);
expect(container.querySelector('[data-scene-unresolved-component]')).toBeNull();
});

it('should keep FeatureSection primary and secondary actions and their decorative icons', () => {
const { container } = renderTemplate('FeatureSection');
const primary = screen.getByRole('button', { name: 'New' });
const secondary = screen.getByRole('button', { name: 'Export' });
expect(primary.getAttribute('data-emphasis')).toBe('primary');
expect(secondary.getAttribute('data-emphasis')).toBe('secondary');
expect(primary.querySelector('i.pi-plus')?.getAttribute('aria-hidden')).toBe('true');
expect(secondary.querySelector('i.pi-download')?.getAttribute('aria-hidden')).toBe('true');
expect(container.querySelector('[data-scene-unresolved-component]')).toBeNull();
});

it('should keep SliceSection headings and content without inventing actions', () => {
const { container } = renderTemplate('SliceSection');
screen.getByRole('heading', { name: 'Adjustment' });
screen.getByText('One behavior, one surface');
expect(screen.queryAllByRole('button')).toHaveLength(0);
expect(container.querySelector('[data-scene-unresolved-component]')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ScenePackageBundle, corePackage, resolvePackageHost } from '@cratis/sce
import { primeReactPackage } from '@cratis/scene.primereact';
import { defaultBlueprint } from '../defaultBlueprint';
import { defaultBlueprintName } from '../packageName';
import { galleryComponentCatalog, galleryScreenTemplates, GalleryScreenPreview, TemplatePreviewState } from '../gallery';
import { galleryComponentCatalog, galleryDialogTemplates, galleryScreenTemplates, GalleryScreenPreview, TemplatePreviewState } from '../gallery';
import { provideBrowserApis } from './given';

const empty = (name: string, kind: PackageKind): ScenePackageBundle => ({
Expand Down Expand Up @@ -37,6 +37,14 @@ describe('when choosing the default or the widget mode', () => {

it('should not activate PrimeReact', () => host.bundles.some(bundle => bundle.manifest.name === 'PrimeReact').should.equal(false));
it('should offer every foundation template as compatible', () => host.templates.filter(entry => foundation.some(template => template.name === entry.name) && !entry.compatible).map(entry => entry.name).should.deep.equal([]));
it('should offer the neutral gallery screens and confirmation dialog without PrimeReact', () => {
const neutral = ['Landing', 'ModuleWorkspace', 'FeatureSection', 'SliceSection'];
neutral.every(name => foundation.some(template => template.name === name)).should.equal(true);
borrowing.map(template => template.name).should.not.include.members(neutral);
const confirm = host.templates.find(entry => entry.name === 'ConfirmDialog' && entry.package === defaultBlueprintName);
(confirm?.compatible === true).should.equal(true);
(galleryDialogTemplates.find(template => template.name === 'ConfirmDialog')!.metadata?.compatibility?.packages ?? []).length.should.equal(0);
});
it('should offer every widget-borrowing template as unavailable with an actionable message', () => {
const entries = host.templates.filter(entry => borrowing.some(template => template.name === entry.name));
entries.length.should.equal(borrowing.length);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { PrimeReactProvider } from '@primereact/core';
import { SceneElementView } from '@cratis/scene.react';
import { galleryComponentCatalog, galleryDialogTemplates, galleryPreviewProfile, queryResultsOf, resolveElementComponentNames } from '../gallery';
import { InteractionScope, SceneElementView, createBrowserDispatcher } from '@cratis/scene.react';
import { galleryComponentCatalog, galleryDialogTemplates, galleryPreviewProfile, galleryProfile, previewProfileFor, queryResultsOf, resolveElementComponentNames } from '../gallery';
import { foundationPack } from '../for_foundationTemplates/given';
import { packageBackedRegistry, provideBrowserApis } from './given';

describe('when previewing the dialog templates', () => {
beforeEach(provideBrowserApis);
afterEach(cleanup);

it('should dispatch CloseDialog when Cancel is clicked with the complete PrimeReact registry', async () => {
const registry = { ...packageBackedRegistry, 'Cratis.Components:dataPage': () => undefined };
const profile = previewProfileFor(registry);
expect(profile).toBe(galleryProfile);
const closeDialog = vi.fn();
const template = galleryDialogTemplates.find(candidate => candidate.name === 'FormDialog')!;
const elements = Object.values(template.content ?? {}).flat()
.map(element => resolveElementComponentNames(element, profile, galleryComponentCatalog));
render(<PrimeReactProvider>
<InteractionScope dispatcher={createBrowserDispatcher({ closeDialog })} attachments={[]}>
{elements.map(element => <SceneElementView key={element.id} element={element} registry={registry} resolveBinding={() => undefined} />)}
</InteractionScope>
</PrimeReactProvider>);

fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(closeDialog).toHaveBeenCalledTimes(1));
});

for (const template of galleryDialogTemplates) {
it(`should render ${template.name} through the real packages, with a named field and an action for every choice`, () => {
const elements = Object.values(template.content ?? {}).flat()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function ActionButtonControl({ element, interactions }: RegisteredCompone
const requested = readOptionalString(element, 'emphasis');
const emphasis = (emphases as readonly string[]).includes(requested ?? '') ? (requested as typeof emphases[number]) : 'primary';
const target = readOptionalString(element, 'targetScreen');
const icon = readOptionalString(element, 'icon');

return (
<span className='foundation foundation-action' data-scene-id={element.id}>
Expand All @@ -30,6 +31,7 @@ export function ActionButtonControl({ element, interactions }: RegisteredCompone
if (interactions?.onClick) interactions.onClick(event);
else if (target && typeof window !== 'undefined') window.location.hash = `#/${target}`;
}}>
{icon && <i className={icon} aria-hidden='true' />}
{readOptionalString(element, 'label') ?? ''}
</ActionButton>
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ export const foundationDescriptors: ComponentDescriptor[] = [
description: 'One action, drawn as every action of the foundation is, that is enabled, disabled with its reason, or hidden by the permission of the user.',
properties: [
text('label', 'Label', 'Content', 'What it does - a verb, never "OK".', { constraints: { required: true } }),
text('icon', 'Icon', 'Appearance', 'Optional decorative PrimeIcons classes; the label remains the accessible name.'),
choice('emphasis', 'Emphasis', 'Appearance', { primary: 'Primary', secondary: 'Secondary', quiet: 'Quiet', danger: 'Destructive' }, 'primary'),
rule('permission', 'Permission', 'Whether the action works, refuses with a reason or is not shown.'),
text('disabledReason', 'Reason when disabled', 'Permissions'),
Expand Down
Loading
Loading