Skip to content

Commit

Permalink
feat(vue-demo): add dynamic breadcrumbs (#1369)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdanilowicz authored Dec 4, 2024
1 parent 2c337b5 commit 13c83be
Show file tree
Hide file tree
Showing 17 changed files with 237 additions and 45 deletions.
7 changes: 7 additions & 0 deletions .changeset/funny-hotels-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@shopware-pwa/composables-next": minor
---

Added `buildDynamicBreadcrumbs` method for building breadcrumbs structure
Added `pushBreadcrumb` method to push single breadcrumb at the top of the breadcrumbs list
Added `associations` option to the `search` method in `useProductSearch` composable
5 changes: 5 additions & 0 deletions .changeset/grumpy-news-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopware-pwa/helpers-next": minor
---

Added `getCmsBreadcrumbs` helper for building CMS breadcrumbs
5 changes: 5 additions & 0 deletions .changeset/rude-students-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"vue-demo-store": minor
---

Added dynamic breadcrumbs
17 changes: 11 additions & 6 deletions apps/docs/src/getting-started/page-elements/breadcrumbs.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ nav:
In this chapter you will learn how to

- Build breadcrumbs for static page
- How breadcrumbs are built for CMS pages
- Build dynamic breadcrumbs for category/product page

### Quick reference

- [useBreadcrumbs](/packages/composables.html#usebreadcrumbs) is a composable used for a breadcrumbs management with sharable state
- [getCategoryBreadcrumbs](/packages/helpers.html#getcategorybreadcrumbs) is a helper used for converting `Category` to the `Breadcrumb` object
- [getCmsBreadcrumbs](/packages/helpers.html#getcmsbreadcrumbs) is a helper used for building breadcrumbs for `Landing Pages`

## Building breadcrumbs for a static page

Expand All @@ -36,12 +37,16 @@ useBreadcrumbs([
]);
```

## Building breadcrumbs for CMS pages
## Building breadcrumbs for a category/product page

:::warning
Currently Shopware 6 API returns breadcrumbs without links.
It means that breadcrumbs for a product and category page, are just a plain text.
:::
```ts
// props.navigationId is a page id

const { buildDynamicBreadcrumbs } = useBreadcrumbs();
buildDynamicBreadcrumbs(props.navigationId);
```

## Building breadcrumbs for CMS pages - without additional request

Each CMS page contains the `Category` with `breadcrumb` array, which contains a list of names, like:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
<body>
<div class="center">
<h1 class="center">Live example</h1>
<pre id="result"><button id="loadContext">Load context via API Client</button></pre>
<pre
id="result"
><button id="loadContext">Load context via API Client</button></pre>
</div>
<script type="module" src="/main.ts"></script>
</body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
<body>
<div class="center">
<h1 class="center">Live example</h1>
<pre id="result"><button id="loadContext">Click to load context via API Client</button></pre>
<pre
id="result"
><button id="loadContext">Click to load context via API Client</button></pre>
</div>
<script type="module" src="/main.ts"></script>
</body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
<body>
<div class="center">
<h1 class="center">Live example</h1>
<pre id="result"><button id="loadContext">Load context via API Client</button></pre>
<pre
id="result"
><button id="loadContext">Load context via API Client</button></pre>
</div>
<script type="module" src="/main.ts"></script>
</body>
Expand Down
41 changes: 40 additions & 1 deletion packages/composables/src/useBreadcrumbs/useBreadcrumbs.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, beforeEach, vi } from "vitest";
import { useBreadcrumbs } from "./useBreadcrumbs";
import { useSetup } from "../_test";
import type { operations } from "#shopware";

describe("useBreadcrumbs", () => {
const consoleErrorSpy = vi.spyOn(console, "error");

beforeEach(() => {
consoleErrorSpy.mockImplementation(() => {});
});

it("should add breadcrumbs", async () => {
const { vm } = useSetup(() =>
useBreadcrumbs([
Expand All @@ -29,4 +36,36 @@ describe("useBreadcrumbs", () => {
vm.clearBreadcrumbs();
expect(vm.breadcrumbs.length).toBe(0);
});

it("should invoke buildDynamicBreadcrumbs", async () => {
const { vm, injections } = useSetup(() => useBreadcrumbs());
injections.apiClient.invoke.mockResolvedValue({
data: {
breadcrumbs: [
{
path: "test",
},
],
},
});
await vm.buildDynamicBreadcrumbs({
breadcrumbs: [{ path: "test" }],
} as unknown as operations["readBreadcrumb get /breadcrumb/{id}"]["response"]);

expect(vm.breadcrumbs[0].path).toBe("/test");
});

it("should push breadcrumb", async () => {
const { vm } = useSetup(() => useBreadcrumbs());
vm.pushBreadcrumb({
name: "Test",
path: "/",
});
expect(vm.breadcrumbs.length).toBe(1);
vm.pushBreadcrumb({
name: "Test",
path: "/",
});
expect(vm.breadcrumbs.length).toBe(2);
});
});
48 changes: 41 additions & 7 deletions packages/composables/src/useBreadcrumbs/useBreadcrumbs.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { computed } from "vue";
import type { ComputedRef } from "vue";
import { useContext } from "#imports";
import type { operations, Schemas } from "#shopware";

/**
* @internal
*/
export type Breadcrumb = {
name: string;
path?: string;
};
export type Breadcrumb =
| {
name: string;
path?: string;
}
| Schemas["Breadcrumb"];

/**
* @public
Expand All @@ -22,13 +25,25 @@ export type UseBreadcrumbsReturn = {
* List of breadcrumbs
*/
breadcrumbs: ComputedRef<Breadcrumb[]>;
/**
* Build breadcrumbs dynamically for a category by fetching them from the API
*
* @param {operations["readBreadcrumb get /breadcrumb/{id}"]["response"]} breadcrumbs
*/
buildDynamicBreadcrumbs(
breadcrumbs: operations["readBreadcrumb get /breadcrumb/{id}"]["response"],
): Promise<void>;
/**
* Add a breadcrumb to the breadcrumbs list
*
* @param {Breadcrumb} breadcrumb
*/
pushBreadcrumb(breadcrumb: Breadcrumb): void;
};

/**
* Composable for breadcrumbs management.
* Read the [guide](https://frontends.shopware.com//getting-started/page-elements/breadcrumbs.html#building-breadcrumbs-for-cms-pages).
*
* It's recommended to use [getCategoryBreadcrumbs](https://frontends.shopware.com/packages/helpers.html#getcategorybreadcrumbs) for category breadcrumbs.
* Read the [guide](https://frontends.shopware.com/getting-started/page-elements/breadcrumbs.html#building-breadcrumbs-for-cms-pages).
*
* @public
* @category CMS (Shopping Experiences)
Expand All @@ -48,8 +63,27 @@ export function useBreadcrumbs(
_breadcrumbs.value = [];
};

const pushBreadcrumb = (breadcrumb: Breadcrumb) => {
if (_breadcrumbs.value) _breadcrumbs.value.push(breadcrumb);
else _breadcrumbs.value = [breadcrumb];
};

const buildDynamicBreadcrumbs = async (
breadcrumbs: operations["readBreadcrumb get /breadcrumb/{id}"]["response"],
) => {
_breadcrumbs.value = breadcrumbs.breadcrumbs.map((breadcrumb) => {
// Adjust path to be compatible with the router
return {
...breadcrumb,
path: `/${breadcrumb.path}`,
};
});
};

return {
clearBreadcrumbs,
breadcrumbs: computed(() => _breadcrumbs.value),
buildDynamicBreadcrumbs,
pushBreadcrumb,
};
}
2 changes: 2 additions & 0 deletions packages/composables/src/useProductSearch/useProductSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { defu } from "defu";
type UseProductSearchReturnOptions = {
withCmsAssociations?: boolean;
criteria?: Partial<Schemas["Criteria"]>;
associations?: Partial<Schemas["Association"]>;
};

export type UseProductSearchReturn = {
Expand Down Expand Up @@ -36,6 +37,7 @@ export function useProductSearch(): UseProductSearchReturn {
const associations = defu(
options?.withCmsAssociations ? cmsAssociations : {},
options?.criteria,
{ associations: options?.associations ?? {} },
);
const result = await apiClient.invoke(
"readProductDetail post /product/{productId}",
Expand Down
14 changes: 14 additions & 0 deletions packages/helpers/src/cms/getCmsBreadcrumbs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { getCmsBreadcrumbs } from "./getCmsBreadcrumbs";

describe("getCmsBreadcrumbs", () => {
it("should return translated name", () => {
expect(
getCmsBreadcrumbs({
translated: {
name: "translated name",
},
}),
).toEqual([{ name: "translated name" }]);
});
});
19 changes: 19 additions & 0 deletions packages/helpers/src/cms/getCmsBreadcrumbs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Build the breadcrumbs for the CMS page
*
* @param page
* @returns
*/
export function getCmsBreadcrumbs<
T extends {
translated: {
name: string;
};
},
>(page: T): { name: string }[] {
return [
{
name: page.translated.name,
},
];
}
1 change: 1 addition & 0 deletions packages/helpers/src/cms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export * from "./buildUrlPrefix";
export * from "./layoutClasses";
export * from "./isMaintenanceMode";
export * from "./getCmsTranslate";
export * from "./getCmsBreadcrumbs";

/**
* Returns the main page object depending of the type of the CMS page.
Expand Down
1 change: 1 addition & 0 deletions packages/helpers/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe("helpers - test global API", () => {
"getCategoryImageUrl": [Function],
"getCategoryRoute": [Function],
"getCategoryUrl": [Function],
"getCmsBreadcrumbs": [Function],
"getCmsEntityObject": [Function],
"getCmsLayoutConfiguration": [Function],
"getCmsTranslate": [Function],
Expand Down
52 changes: 39 additions & 13 deletions templates/vue-demo-store/components/FrontendDetailPage.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,50 @@
<script setup lang="ts">
import { getCategoryBreadcrumbs } from "@shopware-pwa/helpers-next";
import { getProductName } from "@shopware-pwa/helpers-next";
const props = defineProps<{
navigationId: string;
}>();
const { search } = useProductSearch();
const { buildDynamicBreadcrumbs, pushBreadcrumb } = useBreadcrumbs();
const { apiClient } = useShopwareContext();
const { data: productResponse, error } = await useAsyncData(
const { data, error } = await useAsyncData(
`cmsProduct${props.navigationId}`,
async () => {
const productResponse = await search(props.navigationId, {
withCmsAssociations: true,
});
return productResponse;
const responses = await Promise.allSettled([
search(props.navigationId, {
withCmsAssociations: true,
associations: {
seoUrls: {},
},
}),
apiClient.invoke("readBreadcrumb get /breadcrumb/{id}", {
pathParams: {
id: props.navigationId,
},
}),
]);
for (const response of responses) {
if (response.status === "rejected") {
console.error("[FrontendDetailPage.vue]", response.reason.message);
}
}
return {
productResponse:
responses[0].status === "fulfilled" ? responses[0].value : null,
breadcrumbs:
responses[1].status === "fulfilled" ? responses[1].value : null,
};
},
);
const productResponse = ref(data.value?.productResponse);
if (data.value?.breadcrumbs) {
buildDynamicBreadcrumbs(data.value.breadcrumbs.data);
}
if (!productResponse.value) {
console.error("[FrontendDetailPage.vue]", error.value?.message);
Expand All @@ -24,13 +53,10 @@ if (!productResponse.value) {
useProductJsonLD(productResponse.value.product);
const breadcrumbs = getCategoryBreadcrumbs(
productResponse.value.product.seoCategory,
{
startIndex: 2,
},
);
useBreadcrumbs(breadcrumbs);
pushBreadcrumb({
name: getProductName({ product: productResponse.value.product }) ?? "",
path: `/${productResponse.value.product.seoUrls?.[0]?.seoPathInfo}`,
});
const { product } = useProduct(
productResponse.value.product,
Expand Down
6 changes: 6 additions & 0 deletions templates/vue-demo-store/components/FrontendLandingPage.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { getCmsBreadcrumbs } from "@shopware-pwa/helpers-next";
import { useLandingSearch } from "#imports";
import type { Schemas } from "#shopware";
Expand All @@ -18,6 +19,11 @@ const { data: landingResponse, error } = await useAsyncData(
},
);
if (landingResponse.value) {
const breadcrumbs = getCmsBreadcrumbs(landingResponse.value);
useBreadcrumbs(breadcrumbs);
}
if (!landingResponse?.value) {
console.error("[FrontendLandingPage.vue]", error.value?.message);
throw error.value;
Expand Down
Loading

0 comments on commit 13c83be

Please sign in to comment.