Skip to content

Commit

Permalink
chore: Render breadcrumbs slot in the skeleton state
Browse files Browse the repository at this point in the history
  • Loading branch information
just-boris committed Nov 8, 2024
1 parent 672e7f8 commit 6f91db5
Show file tree
Hide file tree
Showing 20 changed files with 273 additions and 100 deletions.
71 changes: 71 additions & 0 deletions src/app-layout/__tests__/skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
/* eslint-disable simple-import-sort/imports */
import React from 'react';

import { describeEachAppLayout, renderComponent } from './utils';
import AppLayout from '../../../lib/components/app-layout';
import BreadcrumbGroup from '../../../lib/components/breadcrumb-group';
import { getFunnelKeySelector } from '../../internal/analytics/selectors';

let widgetMockEnabled = false;
function createWidgetizedComponentMock(Implementation: React.ComponentType, Skeleton: React.ComponentType) {
return () => {
return function Widgetized(props: any) {
if (!widgetMockEnabled) {
return <Implementation {...props} />;
}
if (Skeleton) {
return <Skeleton {...props} />;
}
return null;
};
};
}

jest.mock('../../../lib/components/internal/widgets', () => ({
createWidgetizedComponent: createWidgetizedComponentMock,
}));

describeEachAppLayout({ themes: ['refresh-toolbar'] }, () => {
it('renders complete component by default', () => {
const { wrapper } = renderComponent(
<AppLayout
navigation="test nav"
breadcrumbs={<BreadcrumbGroup items={[{ text: 'Home', href: '' }]} />}
tools="test tools"
/>
);
expect(wrapper.findToolbar()).toBeTruthy();
expect(wrapper.findNavigation()).toBeTruthy();
expect(wrapper.findBreadcrumbs()).toBeTruthy();
expect(wrapper.find(getFunnelKeySelector('funnel-name'))).toBeTruthy();
expect(wrapper.findTools()).toBeTruthy();
expect(wrapper.findContentRegion()).toBeTruthy();
});

describe('in loading state', () => {
beforeEach(() => {
widgetMockEnabled = true;
});
afterEach(() => {
widgetMockEnabled = false;
});

it('renders skeleton parts only', () => {
const { wrapper } = renderComponent(
<AppLayout
navigation="test nav"
breadcrumbs={<BreadcrumbGroup items={[{ text: 'Home', href: '' }]} />}
tools="test tools"
/>
);
expect(wrapper.findToolbar()).toBeFalsy();
expect(wrapper.findNavigation()).toBeFalsy();
expect(wrapper.findBreadcrumbs()).toBeFalsy();
expect(wrapper.find(getFunnelKeySelector('funnel-name'))).toBeTruthy();
expect(wrapper.findTools()).toBeFalsy();
expect(wrapper.findContentRegion()).toBeTruthy();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';

import { BreadcrumbGroupImplementation } from '../../../../breadcrumb-group/implementation';
import { BreadcrumbGroupProps } from '../../../../breadcrumb-group/interfaces';
import { BreadcrumbsSlotContext } from '../../contexts';

import styles from './styles.css.js';

interface BreadcrumbsSlotProps {
ownBreadcrumbs: React.ReactNode;
discoveredBreadcrumbs: BreadcrumbGroupProps | null;
}

export function BreadcrumbsSlot({ ownBreadcrumbs, discoveredBreadcrumbs }: BreadcrumbsSlotProps) {
return (
<BreadcrumbsSlotContext.Provider value={{ isInToolbar: true }}>
<div className={styles['breadcrumbs-own']}>{ownBreadcrumbs}</div>
{discoveredBreadcrumbs && (
<div className={styles['breadcrumbs-discovered']}>
<BreadcrumbGroupImplementation
{...discoveredBreadcrumbs}
data-awsui-discovered-breadcrumbs={true}
__injectAnalyticsComponentMetadata={true}
/>
</div>
)}
</BreadcrumbsSlotContext.Provider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
*/

// backward compatibility before this commit: 7a4b7b3e3b1d50830383805a8f4ab6cd93c9701f
.breadcrumbs-own:not(:empty) + .breadcrumbs-discovered {
display: none;
}

.block-body-scroll {
overflow: hidden;
}
18 changes: 18 additions & 0 deletions src/app-layout/visual-refresh-toolbar/skeleton/toolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';

import { AppLayoutToolbarImplementationProps } from '../toolbar';
import { BreadcrumbsSlot } from './breadcrumbs';
import { ToolbarSlot } from './slot-wrappers';

export const ToolbarSkeleton = React.forwardRef<HTMLElement, AppLayoutToolbarImplementationProps>(
({ appLayoutInternals }: AppLayoutToolbarImplementationProps, ref) => (
<ToolbarSlot ref={ref}>
<BreadcrumbsSlot
ownBreadcrumbs={appLayoutInternals.breadcrumbs}
discoveredBreadcrumbs={appLayoutInternals.discoveredBreadcrumbs}
/>
</ToolbarSlot>
)
);
27 changes: 11 additions & 16 deletions src/app-layout/visual-refresh-toolbar/toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import clsx from 'clsx';

import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal';

import { BreadcrumbGroupImplementation } from '../../../breadcrumb-group/implementation';
import { createWidgetizedComponent } from '../../../internal/widgets';
import { AppLayoutProps } from '../../interfaces';
import { Focusable, FocusControlMultipleStates } from '../../utils/use-focus-control';
import { BreadcrumbsSlotContext } from '../contexts';
import { AppLayoutInternals } from '../interfaces';
import { BreadcrumbsSlot } from '../skeleton/breadcrumbs';
import { ToolbarSlot } from '../skeleton/slot-wrappers';
import { ToolbarSkeleton } from '../skeleton/toolbar';
import { DrawerTriggers, SplitPanelToggleProps } from './drawer-triggers';
import TriggerButton from './trigger-button';

Expand Down Expand Up @@ -48,7 +48,7 @@ export interface ToolbarProps {
onActiveGlobalDrawersChange?: ((drawerId: string) => void) | undefined;
}

interface AppLayoutToolbarImplementationProps {
export interface AppLayoutToolbarImplementationProps {
appLayoutInternals: AppLayoutInternals;
toolbarProps: ToolbarProps;
}
Expand Down Expand Up @@ -194,18 +194,10 @@ export function AppLayoutToolbarImplementation({
)}
{(breadcrumbs || discoveredBreadcrumbs) && (
<div className={clsx(styles['universal-toolbar-breadcrumbs'], testutilStyles.breadcrumbs)}>
<BreadcrumbsSlotContext.Provider value={{ isInToolbar: true }}>
<div className={styles['breadcrumbs-own']}>{breadcrumbs}</div>
{discoveredBreadcrumbs && (
<div className={styles['breadcrumbs-discovered']}>
<BreadcrumbGroupImplementation
{...discoveredBreadcrumbs}
data-awsui-discovered-breadcrumbs={true}
__injectAnalyticsComponentMetadata={true}
/>
</div>
)}
</BreadcrumbsSlotContext.Provider>
<BreadcrumbsSlot
ownBreadcrumbs={appLayoutInternals.breadcrumbs}
discoveredBreadcrumbs={appLayoutInternals.discoveredBreadcrumbs}
/>
</div>
)}
{((drawers && drawers.length > 0) || (hasSplitPanel && splitPanelToggleProps?.displayed)) && (
Expand All @@ -232,4 +224,7 @@ export function AppLayoutToolbarImplementation({
);
}

export const createWidgetizedAppLayoutToolbar = createWidgetizedComponent(AppLayoutToolbarImplementation);
export const createWidgetizedAppLayoutToolbar = createWidgetizedComponent(
AppLayoutToolbarImplementation,
ToolbarSkeleton
);
9 changes: 0 additions & 9 deletions src/app-layout/visual-refresh-toolbar/toolbar/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,3 @@
align-items: center;
}
}

// backward compatibility before this commit: 7a4b7b3e3b1d50830383805a8f4ab6cd93c9701f
.breadcrumbs-own:not(:empty) + .breadcrumbs-discovered {
display: none;
}

.block-body-scroll {
overflow: hidden;
}
7 changes: 6 additions & 1 deletion src/breadcrumb-group/__tests__/breadcrumb-item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ describe('BreadcrumbGroup Item', () => {

test('displays tooltip', () => {
const { container } = render(
<BreadcrumbItem item={{ text: 'Long Breadcrumb text', href: '#' }} isTruncated={true} />
<BreadcrumbItem
itemIndex={0}
totalCount={1}
item={{ text: 'Long Breadcrumb text', href: '#' }}
isTruncated={true}
/>
);
const elementAnchor = createWrapper(container).find(`.${breadcrumbItemStyles.anchor}`)!.getElement();
elementAnchor.focus();
Expand Down
35 changes: 25 additions & 10 deletions src/breadcrumb-group/__tests__/widgetized-breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { render } from '@testing-library/react';

import { BreadcrumbGroupProps } from '../../../lib/components/breadcrumb-group';
import { createWidgetizedBreadcrumbGroup } from '../../../lib/components/breadcrumb-group/implementation';
import { InternalBreadcrumbGroupProps } from '../../../lib/components/breadcrumb-group/interfaces';
import { BreadcrumbGroupSkeleton } from '../../../lib/components/breadcrumb-group/skeleton';
import { getFunnelNameSelector } from '../../../lib/components/internal/analytics/selectors';
import { FUNNEL_KEY_RESOURCE_TYPE, getFunnelKeySelector } from '../../../lib/components/internal/analytics/selectors';
import { useVisualRefresh } from '../../../lib/components/internal/hooks/use-visual-mode';
import { FunctionComponent } from '../../../lib/components/internal/widgets';
import createWrapper from '../../../lib/components/test-utils/dom';
import { describeWithAppLayoutFeatureFlagEnabled } from '../../internal/widgets/__tests__/utils';

Expand All @@ -21,16 +24,27 @@ function renderComponent(jsx: React.ReactElement) {
const defaultProps: BreadcrumbGroupProps = {
items: [
{ href: '#', text: 'Root' },
{ href: '#', text: 'Resource' },
{ href: '#', text: 'Page name' },
],
};

const WidgetizedBreadcrumbs = createWidgetizedBreadcrumbGroup(BreadcrumbGroupSkeleton);
const WidgetizedBreadcrumbs = createWidgetizedBreadcrumbGroup(
BreadcrumbGroupSkeleton as FunctionComponent<InternalBreadcrumbGroupProps<any>>
);

function getElementsText(elements: NodeListOf<Element>) {
return Array.from(elements).map(element => element.textContent);
}

function getFunnelNameElements(container: HTMLElement) {
return container.querySelectorAll(getFunnelNameSelector());
}

function getResourceTypeElements(container: HTMLElement) {
return container.querySelectorAll(getFunnelKeySelector(FUNNEL_KEY_RESOURCE_TYPE));
}

jest.mock('../../../lib/components/internal/hooks/use-visual-mode', () => ({
useVisualRefresh: jest.fn().mockReturnValue(false),
}));
Expand All @@ -43,8 +57,8 @@ describe('Classic design', () => {
test('should render normal layout by default', () => {
const { wrapper, container } = renderComponent(<WidgetizedBreadcrumbs {...defaultProps} />);
expect(wrapper).toBeTruthy();
expect(getFunnelNameElements(container).length).toEqual(1);
expect(getFunnelNameElements(container)[0]).toHaveTextContent('Page name');
expect(getElementsText(getResourceTypeElements(container))).toEqual(['Resource']);
expect(getElementsText(getFunnelNameElements(container))).toEqual(['Page name']);
});
});

Expand All @@ -56,22 +70,23 @@ describe('Refresh design', () => {
test('should render normal layout by default', () => {
const { wrapper, container } = renderComponent(<WidgetizedBreadcrumbs {...defaultProps} />);
expect(wrapper).toBeTruthy();
expect(getFunnelNameElements(container).length).toEqual(1);
expect(getFunnelNameElements(container)[0]).toHaveTextContent('Page name');
expect(getElementsText(getResourceTypeElements(container))).toEqual(['Resource']);
expect(getElementsText(getFunnelNameElements(container))).toEqual(['Page name']);
});

describeWithAppLayoutFeatureFlagEnabled(() => {
test('should render funnel name using loader', () => {
test('should render funnel markers using loader', () => {
const { wrapper, container } = renderComponent(<WidgetizedBreadcrumbs {...defaultProps} />);
expect(wrapper).toBeFalsy();
expect(getFunnelNameElements(container).length).toEqual(1);
expect(getFunnelNameElements(container)[0]).toHaveTextContent('Page name');
expect(getElementsText(getResourceTypeElements(container))).toEqual(['Resource']);
expect(getElementsText(getFunnelNameElements(container))).toEqual(['Page name']);
});

test('should not render funnel name if breadcrumbs list is empty', () => {
test('should not render funnel markers if breadcrumbs list is empty', () => {
const { wrapper, container } = renderComponent(<WidgetizedBreadcrumbs items={[]} />);
expect(wrapper).toBeFalsy();
expect(getFunnelNameElements(container).length).toEqual(0);
expect(getElementsText(getResourceTypeElements(container))).toEqual([]);
expect(getElementsText(getFunnelNameElements(container))).toEqual([]);
});
});
});
22 changes: 12 additions & 10 deletions src/breadcrumb-group/implementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from './analytics-metadata/interfaces';
import { BreadcrumbGroupProps, EllipsisDropdownProps, InternalBreadcrumbGroupProps } from './interfaces';
import { BreadcrumbItem } from './item/item';
import { BreadcrumbGroupSkeleton } from './skeleton';
import { getEventDetail, getItemsDisplayProperties } from './utils';

import analyticsSelectors from './analytics-metadata/styles.css.js';
Expand Down Expand Up @@ -191,21 +192,19 @@ export function BreadcrumbGroupImplementation<T extends BreadcrumbGroupProps.Ite
item={item}
onClick={onClick}
onFollow={onFollow}
isLast={isLast}
itemIndex={index}
totalCount={items.length}
isTruncated={itemsWidths.ghost[index] - itemsWidths.real[index] > 0}
/>
</li>
);
});

const hiddenBreadcrumbItems = items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li className={styles['ghost-item']} key={index} ref={node => setBreadcrumb('ghost', `${index}`, node)}>
<BreadcrumbItem item={item} isLast={isLast} isGhost={true} />
</li>
);
});
const hiddenBreadcrumbItems = items.map((item, index) => (
<li className={styles['ghost-item']} key={index} ref={node => setBreadcrumb('ghost', `${index}`, node)}>
<BreadcrumbItem item={item} itemIndex={index} totalCount={items.length} isGhost={true} />
</li>
));

const getEventItem = (e: CustomEvent<{ id: string }>) => {
const { id } = e.detail;
Expand Down Expand Up @@ -263,4 +262,7 @@ export function BreadcrumbGroupImplementation<T extends BreadcrumbGroupProps.Ite
);
}

export const createWidgetizedBreadcrumbGroup = createWidgetizedComponent(BreadcrumbGroupImplementation);
export const createWidgetizedBreadcrumbGroup = createWidgetizedComponent(
BreadcrumbGroupImplementation,
BreadcrumbGroupSkeleton
);
3 changes: 2 additions & 1 deletion src/breadcrumb-group/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useSetGlobalBreadcrumbs } from '../internal/plugins/helpers/use-global-
import { applyDisplayName } from '../internal/utils/apply-display-name.js';
import { BreadcrumbGroupProps } from './interfaces';
import { InternalBreadcrumbGroup } from './internal';
import { BreadcrumbGroupSkeleton } from './skeleton';

export { BreadcrumbGroupProps };

Expand All @@ -18,7 +19,7 @@ export default function BreadcrumbGroup<T extends BreadcrumbGroupProps.Item = Br
const baseComponentProps = useBaseComponent('BreadcrumbGroup');

if (registeredGlobally) {
return <></>;
return <BreadcrumbGroupSkeleton items={items} />;
}

return (
Expand Down
3 changes: 2 additions & 1 deletion src/breadcrumb-group/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ export type InternalBreadcrumbGroupProps<T extends BreadcrumbGroupProps.Item = B

export interface BreadcrumbItemProps<T extends BreadcrumbGroupProps.Item> {
item: T;
itemIndex: number;
totalCount: number;
isTruncated?: boolean;
isLast?: boolean;
isGhost?: boolean;
onClick?: CancelableEventHandler<BreadcrumbGroupProps.ClickDetail<T>>;
onFollow?: CancelableEventHandler<BreadcrumbGroupProps.ClickDetail<T>>;
Expand Down
Loading

0 comments on commit 6f91db5

Please sign in to comment.