diff --git a/Source/JavaScript/blueprint.default/README.md b/Source/JavaScript/blueprint.default/README.md index 07bf95ef..ac30ac6f 100644 --- a/Source/JavaScript/blueprint.default/README.md +++ b/Source/JavaScript/blueprint.default/README.md @@ -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. @@ -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. diff --git a/Source/JavaScript/blueprint.default/for_FoundationInteractions/when_rendering_an_action_icon.tsx b/Source/JavaScript/blueprint.default/for_FoundationInteractions/when_rendering_an_action_icon.tsx new file mode 100644 index 00000000..0730bff5 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_FoundationInteractions/when_rendering_an_action_icon.tsx @@ -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(); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts b/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts index 2646bad8..a4725a3a 100644 --- a/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts +++ b/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts @@ -9,6 +9,7 @@ import { distinctComponentNames, galleryComponentCatalog, galleryDialogTemplates, + galleryPreviewProfile, galleryProfile, galleryScreens, } from '../gallery'; @@ -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']); + }); }); diff --git a/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx new file mode 100644 index 00000000..f60ca423 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx @@ -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(); + (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(); + (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(); + 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)); +}); diff --git a/Source/JavaScript/blueprint.default/for_previewProfile/when_rendering_the_neutral_gallery_templates.tsx b/Source/JavaScript/blueprint.default/for_previewProfile/when_rendering_the_neutral_gallery_templates.tsx new file mode 100644 index 00000000..d22802b7 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_previewProfile/when_rendering_the_neutral_gallery_templates.tsx @@ -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 => 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(); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx index 840793b0..448757aa 100644 --- a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx +++ b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx @@ -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 => ({ @@ -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); diff --git a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_previewing_the_dialog_templates.tsx b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_previewing_the_dialog_templates.tsx index e572e540..001643f2 100644 --- a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_previewing_the_dialog_templates.tsx +++ b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_previewing_the_dialog_templates.tsx @@ -1,10 +1,10 @@ // 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'; @@ -12,6 +12,24 @@ 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( + + {elements.map(element => undefined} />)} + + ); + + 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() diff --git a/Source/JavaScript/blueprint.default/foundation/ActionButtonControl.tsx b/Source/JavaScript/blueprint.default/foundation/ActionButtonControl.tsx index 340e0859..594d8ec9 100644 --- a/Source/JavaScript/blueprint.default/foundation/ActionButtonControl.tsx +++ b/Source/JavaScript/blueprint.default/foundation/ActionButtonControl.tsx @@ -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 ( @@ -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 &&