Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Navigation layout plugin implementation #883

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
6 changes: 1 addition & 5 deletions libs/base/ui/action/link/src/link.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Icons } from '@spryker-oryx/ui/icon';
import { Size } from '@spryker-oryx/utilities';
import { html, LitElement, TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';

Check warning on line 3 in libs/base/ui/action/link/src/link.component.ts

View workflow job for this annotation

GitHub Actions / test-lint

'property' is defined but never used
import { when } from 'lit/directives/when.js';
import { ColorType, LinkComponentAttributes, LinkType } from './link.model';

Expand All @@ -17,10 +16,7 @@

protected render(): TemplateResult {
return html`
${when(
this.icon,
() => html`<oryx-icon .type=${this.icon} .size=${Size.Md}></oryx-icon>`
)}
${when(this.icon, () => html`<oryx-icon .type=${this.icon}></oryx-icon>`)}
<slot></slot>
`;
}
Expand Down
14 changes: 10 additions & 4 deletions libs/base/ui/action/link/src/styles/storefront.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ export const storefrontLinkStyles = css`
:host {
position: relative;
display: inline-flex;
width: var(--oryx-link-width);
}

:host([icon]) {
--oryx-icon-size: 16px;
--oryx-icon-size: var(--oryx-link-icon-size, 16px);

align-items: baseline;
gap: 8px;
Expand All @@ -21,16 +22,21 @@ export const storefrontLinkStyles = css`

oryx-icon {
position: relative;
inset-block-start: 3px;
inset-block-start: calc((var(--oryx-icon-size, 24px) / 4));
}

::slotted(a) {
text-decoration: none;
color: currentColor;
width: var(--oryx-link-width);
padding: var(--oryx-link-padding);
}

:host(:hover) ::slotted(a) {
text-decoration: solid underline currentColor 1px;
text-decoration: var(
--oryx-link-decoration,
solid underline currentColor 1px
);
text-underline-offset: 5px;
}

Expand All @@ -52,6 +58,6 @@ export const storefrontLinkStyles = css`
:host([color='primary']),
:host([color='primary']:hover:not(:active)),
:host(:active) {
color: var(--oryx-color-primary-10);
color: var(--oryx-link-color, var(--oryx-color-primary-10));
}
`;
1 change: 1 addition & 0 deletions libs/base/ui/graphical/icon/src/icon.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export enum IconTypes {
ModeDark = 'dark_mode',
ModeLight = 'light_mode',
Orders = 'shop_two',
History = 'history',
Parcel = 'deployed_code',
Printer = 'print',
Products = 'inventory_2',
Expand Down
31 changes: 31 additions & 0 deletions libs/domain/content/link/link.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ContentLinkContent, ContentLinkOptions } from './link.model';

class MockSemanticLinkService implements Partial<LinkService> {
get = vi.fn().mockReturnValue(of('/page'));
isCurrent = vi.fn().mockReturnValue(of(false));
}

const mockCategoryService = {
Expand Down Expand Up @@ -100,6 +101,36 @@ describe('ContentLinkComponent', () => {
it('should render the url', () => {
expect(element).toContainElement('a[href="/test"]');
});

describe('when the current route exactly matches the link', () => {
beforeEach(async () => {
semanticLinkService.isCurrent.mockReturnValue(of(true));
element = await fixture(
html`<oryx-content-link
.options=${{ type: RouteType.Page }}
></oryx-content-link>`
);
});

it('should have "current" attribute set', async () => {
expect(element.hasAttribute('current')).toBe(true);
});
});

describe('when the current route does not match the link', () => {
beforeEach(async () => {
semanticLinkService.isCurrent.mockReturnValue(of(false));
element = await fixture(
html`<oryx-content-link
.options=${{ type: RouteType.Page }}
></oryx-content-link>`
);
});

it('should not have "current" attribute set', async () => {
expect(element.hasAttribute('current')).toBe(false);
});
});
});

describe('when url nor type is provided', () => {
Expand Down
25 changes: 20 additions & 5 deletions libs/domain/content/link/link.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { ContentMixin } from '@spryker-oryx/experience';
import { ProductCategoryService, ProductService } from '@spryker-oryx/product';
import { RouteType } from '@spryker-oryx/router';
import { LinkService } from '@spryker-oryx/site';
import { computed, hydrate } from '@spryker-oryx/utilities';
import { computed, elementEffect, hydrate } from '@spryker-oryx/utilities';
import { LitElement, TemplateResult, html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { when } from 'lit/directives/when.js';
import { map } from 'rxjs';
import { map, of } from 'rxjs';
import { ContentLinkContent, ContentLinkOptions } from './link.model';

@hydrate()
Expand All @@ -23,19 +23,34 @@ export class ContentLinkComponent extends ContentMixin<

protected $link = computed(() => {
const { url, type, id, params } = this.$options();
if (url) return url;
if (url) return of(url);
if (type) return this.semanticLinkService.get({ type: type, id, params });
return null;
return of(null);
});

protected $isCurrent = computed(() => {
const link = this.$link();
if (link) return this.semanticLinkService.isCurrent(link);
return of(false);
});

@elementEffect()
protected reflectCurrentRoute = (): void => {
const current = this.$isCurrent();
this.toggleAttribute('current', current);
};

protected override render(): TemplateResult | void {
const { button, icon, singleLine, color } = this.$options();

if (button) {
return html`<oryx-button>${this.renderLink(true)}</oryx-button>`;
return html`<oryx-button part="link" }
>${this.renderLink(true)}</oryx-button
>`;
}

return html`<oryx-link
part="link"
.color=${color}
?singleLine=${singleLine}
.icon=${icon}
Expand Down
1 change: 1 addition & 0 deletions libs/domain/product/src/mocks/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './mock-category.service';
export * from './mock-product.providers';
export * from './mock-product.service';
export * from './product-list';
Expand Down
23 changes: 23 additions & 0 deletions libs/domain/product/src/mocks/src/mock-category.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Observable, of } from 'rxjs';
import { ProductCategory } from '../../models';
import { ProductCategoryService } from '../../services';

export class MockProductCategoryService
implements Partial<ProductCategoryService>
{
static mockProductsCategories: ProductCategory[] = [
{ id: '2', name: 'Cameras & Camcorders', order: 0 },
{ id: '5', name: 'Computer', order: 0 },
{ id: '9', name: 'Smart Wearables', order: 0 },
{ id: '11', name: 'Telecom & Navigation', order: 0 },
];

get(id: string): Observable<ProductCategory> {
const productCategory =
MockProductCategoryService.mockProductsCategories.find(
(p) => p.id === id
) as ProductCategory;

return of(productCategory);
}
}
6 changes: 6 additions & 0 deletions libs/domain/product/src/mocks/src/mock-product.providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DefaultProductListPageService,
DefaultProductListService,
ProductAdapter,
ProductCategoryService,
ProductImageService,
ProductListAdapter,
ProductListPageService,
Expand All @@ -14,6 +15,7 @@ import {
ProductRelationsListService,
ProductService,
} from '@spryker-oryx/product';
import { MockProductCategoryService } from './mock-category.service';
import { MockProductService } from './mock-product.service';
import { MockProductListAdapter } from './product-list';
import { MockProductRelationsListService } from './product-relations/mock-product-relations-list.service';
Expand Down Expand Up @@ -51,4 +53,8 @@ export const mockProductProviders: Provider[] = [
provide: ProductRelationsListService,
useClass: MockProductRelationsListService,
},
{
provide: ProductCategoryService,
useClass: MockProductCategoryService,
},
];
64 changes: 62 additions & 2 deletions libs/domain/site/src/services/link/default-link.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { createInjector, destroyInjector } from '@spryker-oryx/di';
import { RouterService, RouteType } from '@spryker-oryx/router';
import { RouteType, RouterService } from '@spryker-oryx/router';
import { Observable, of } from 'rxjs';
import { DefaultLinkService } from './default-link.service';
import { LinkService, LinkOptions } from './link.service';
import { LinkOptions, LinkService } from './link.service';

const mockRouterService = {
getRoutes: vi.fn(),
currentRoute: vi.fn(),
};

describe('DefaultLinkService', () => {
Expand Down Expand Up @@ -167,4 +168,63 @@ describe('DefaultLinkService', () => {
});
});
});

describe('isCurrent method', () => {
const callback = vi.fn();

describe('when the current route exactly matches the provided URL', () => {
beforeEach(async () => {
mockRouterService.currentRoute.mockReturnValue(of('/about'));
service.isCurrent('/about', true).subscribe(callback);
});

it('should resolve to true', () => {
expect(callback).toHaveBeenCalledWith(true);
});
});

describe('when the current route starts with the provided URL', () => {
beforeEach(() => {
mockRouterService.currentRoute.mockReturnValue(of('/about-us'));
service.isCurrent('/about').subscribe(callback);
});

it('should resolve to true', () => {
expect(callback).toHaveBeenCalledWith(true);
});
});

describe('when the current route does not match the provided URL', () => {
beforeEach(() => {
mockRouterService.currentRoute.mockReturnValue(of('/contact'));
service.isCurrent('/about').subscribe(callback);
});

it('should resolve to false', () => {
expect(callback).toHaveBeenCalledWith(false);
});
});

describe('when the current route exactly matches but exactMatch is false', () => {
beforeEach(() => {
mockRouterService.currentRoute.mockReturnValue(of('/about'));
service.isCurrent('/about', false).subscribe(callback);
});

it('should resolve to true', () => {
expect(callback).toHaveBeenCalledWith(true);
});
});

describe('when the current route starts with but exactMatch is false', () => {
beforeEach(() => {
mockRouterService.currentRoute.mockReturnValue(of('/about'));
service.isCurrent('/about/us', false).subscribe(callback);
});

it('should resolve to true', () => {
expect(callback).toHaveBeenCalledWith(false);
});
});
});
});
18 changes: 18 additions & 0 deletions libs/domain/site/src/services/link/default-link.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ export class DefaultLinkService implements LinkService {
);
}

isCurrent(url: string, exactMatch?: boolean): Observable<boolean> {
return this.routerService.currentRoute().pipe(
switchMap((currentRoute) => {
if (!currentRoute || !isRouterPath({ path: currentRoute })) {
return throwError(() => new Error('Current route is not available'));
}

const currentUrl = `${this.baseRoute}${currentRoute}`;

if (exactMatch) {
return of(currentUrl === url);
} else {
return of(currentUrl.startsWith(url));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there may be some edge cases, where startsWith may not always work as expected

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what edge cases?

}
})
);
}

private getUrlParams(params: Record<string, string>): string {
const encodedParams = Object.fromEntries(
Object.entries(params).map(([k, v]) => [k, encodeURIComponent(v)])
Expand Down
13 changes: 13 additions & 0 deletions libs/domain/site/src/services/link/link.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ export interface LinkOptions {

export interface LinkService {
get(link: LinkOptions): Observable<string | undefined>;

/**
* Checks if the current route matches or starts with the specified URL.
* Asynchronously retrieves the current route and performs the check based on
* the exactMatch option.
*
* @param {string} url - The URL to check against the current route.
* @param {boolean} exactMatch - If true, checks for an exact match;
* otherwise, checks if the current route starts with the specified URL.
* @returns {Observable<boolean>} An observable that emits `true` if the current
* route matches or starts with the specified URL, and `false` otherwise.
*/
isCurrent(url: string, exactMatch?: boolean): Observable<boolean>;
}

export const LinkService = 'oryx.LinkService';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ColumnLayoutPluginToken,
FlexLayoutPluginToken,
GridLayoutPluginToken,
NavigationLayoutPluginToken,
SplitLayoutPluginToken,
TextLayoutPluginToken,
} from './types';
Expand Down Expand Up @@ -87,6 +88,13 @@ export const layoutPluginsProviders: Provider[] = [
asyncClass: () =>
import('./types/text/text-layout.plugin').then((m) => m.TextLayoutPlugin),
},
{
provide: NavigationLayoutPluginToken,
asyncClass: () =>
import('./types/navigation/navigation-layout.plugin').then(
(m) => m.NavigationLayoutPlugin
),
},
{
provide: StickyLayoutPluginToken,
asyncClass: () =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export {};

declare global {
export interface LayoutStylesProperties {
/**
Expand All @@ -24,5 +22,18 @@ declare global {
* Rounds the corners of an element's outer border edge.
*/
radius?: string | number;

/**
* Sets the shadow elevation of the element. The shadow elevation can be used to create a
* 3D effect. The elevation sets the 3D depth of the element.
*/
shadow?: ShadowElevation;
}
}

export const enum ShadowElevation {
Flat = 'flat',
Raised = 'raised',
Floating = 'floating',
Hovering = 'hovering',
}
Loading
Loading