diff --git a/.wordlist.txt b/.wordlist.txt index 2a4d3df05d..edbb1a803a 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -231,6 +231,7 @@ Codestyle Cognito CommercialB CommercialBundle +CommonJS ComponentFactory Composables ConfigJson @@ -1353,12 +1354,14 @@ cmss codebase codeblock codeblocks +codemod codemods codereview colorpicker compatability componentSectionRenderer composable +composables config configComponent configs @@ -1450,6 +1453,7 @@ dereference dereferenced describeFeatures destructure +destructured destructuring dev devenv @@ -1521,6 +1525,7 @@ externalReference failover fallback fallbacks +fallthrough fastly favicon fetchable @@ -1644,10 +1649,12 @@ init initialisms initializer initializers +inlines installable instantiation integrations interceptable +interop intl invalidations io @@ -1821,6 +1828,7 @@ otel otlp overridable overrideComponentSetup +overrideable oversales paas packagist @@ -2042,6 +2050,7 @@ snackbar snackbars snakeCase sortings +sourcemap sourcemaps sql src @@ -2087,6 +2096,8 @@ substring supervisord svg sw +swDefineOverride +swDefinePublic swSelect sw_extends swoole @@ -2159,8 +2170,11 @@ unhandled uninstallation uniqBy unminified +unmount +unmounting unprefixed unregister +unregisters unrequested unsecure unserialize @@ -2176,7 +2190,27 @@ uptime uri url urls +useCmsElement +useCmsState +useInlineSnippet +useListing +useMediaGridListener +useMediaSidebarModal +useNotification +useNotificationTranslation +usePlaceholder +usePosition +useRuleBetweenOperator +useRuleContainer +useSalutation +useSwContext +useSwPreviousState +useSwProps +useTranslateWithFallback +useUserSettings +useValidation useValidityRange +useVideoCover userAware userRecovery userland diff --git a/assets/administration-sfc-tutorial-block-location.png b/assets/administration-sfc-tutorial-block-location.png new file mode 100644 index 0000000000..6008b37bda Binary files /dev/null and b/assets/administration-sfc-tutorial-block-location.png differ diff --git a/assets/administration-sfc-tutorial-component.png b/assets/administration-sfc-tutorial-component.png new file mode 100644 index 0000000000..02fb97b8f8 Binary files /dev/null and b/assets/administration-sfc-tutorial-component.png differ diff --git a/assets/administration-sfc-tutorial-extended.png b/assets/administration-sfc-tutorial-extended.png new file mode 100644 index 0000000000..84bbe93e4d Binary files /dev/null and b/assets/administration-sfc-tutorial-extended.png differ diff --git a/assets/administration-sfc-tutorial-margin.png b/assets/administration-sfc-tutorial-margin.png new file mode 100644 index 0000000000..9dd34f10b7 Binary files /dev/null and b/assets/administration-sfc-tutorial-margin.png differ diff --git a/assets/administration-sfc-tutorial-plugin-installed.png b/assets/administration-sfc-tutorial-plugin-installed.png new file mode 100644 index 0000000000..2e4ea1921e Binary files /dev/null and b/assets/administration-sfc-tutorial-plugin-installed.png differ diff --git a/assets/administration-sfc-tutorial-static-banner.png b/assets/administration-sfc-tutorial-static-banner.png new file mode 100644 index 0000000000..8dfa333bfc Binary files /dev/null and b/assets/administration-sfc-tutorial-static-banner.png differ diff --git a/guides/plugins/plugins/administration/index.md b/guides/plugins/plugins/administration/index.md index a708d2ce05..3a1409dd09 100644 --- a/guides/plugins/plugins/administration/index.md +++ b/guides/plugins/plugins/administration/index.md @@ -23,6 +23,8 @@ This section follows a practical development workflow. Start with registering a For stable cross-version extension points, use the [Meteor Admin SDK](../../apps/administration/meteor-admin-sdk.md). The SDK can be used by both apps and plugins. +Components can also be written as native Vue [Single File Components](single-file-components/index.md). This is the experimental alternative, available since Shopware 6.7.16.0, to the Options API and TwigJS templates described in this section, which stay the stable way today. The fastest way in is the [SFC tutorial](single-file-components/tutorial/index.md). + ## Developer workflow When extending the Administration inside a plugin, follow this sequence: @@ -35,6 +37,7 @@ When extending the Administration inside a plugin, follow this sequence: 6. [Inject services](services-utilities/injecting-services.md) and [extend services](services-utilities/extending-services.md) 7. [Customize templates](templates-styling/writing-templates.md) and [add custom styles](templates-styling/add-custom-styles.md) 8. Manage state using [Vuex](data-handling-processing/using-vuex-state.md) or [Pinia](../../../upgrades-migrations/administration/pinia.md) +9. Optional: try the experimental [Single File Component extension system](single-file-components/index.md) by following its [tutorial](single-file-components/tutorial/index.md) Advanced topics such as [mixins and directives](mixins-directives/using-mixins.md) or [extending webpack](advanced-configuration/extending-webpack.md) are covered separately. diff --git a/guides/plugins/plugins/administration/mixins-directives/using-mixins.md b/guides/plugins/plugins/administration/mixins-directives/using-mixins.md index 3f07fbea74..537f329a6b 100644 --- a/guides/plugins/plugins/administration/mixins-directives/using-mixins.md +++ b/guides/plugins/plugins/administration/mixins-directives/using-mixins.md @@ -7,6 +7,10 @@ nav: # Using Mixins +::: info +Mixins are the stable way to share component logic today. A Single File Component cannot use them, so the experimental Single File Component system is adding composables for the commonly used mixins. See [Composables replacing mixins](../single-file-components/roadmap.md#composables-replacing-mixins) for the current state of that list. +::: + ## Overview This guide covers how to use an existing Administration mixin in a plugin. Generally, mixins behave the same as they do in Vue normally, differing only in the registration and the way mixins are included in a component. diff --git a/guides/plugins/plugins/administration/module-component-management/add-custom-component.md b/guides/plugins/plugins/administration/module-component-management/add-custom-component.md index abe002af76..f5c21d8a53 100644 --- a/guides/plugins/plugins/administration/module-component-management/add-custom-component.md +++ b/guides/plugins/plugins/administration/module-component-management/add-custom-component.md @@ -7,6 +7,10 @@ nav: # Add Custom Components +::: info +The Options API registration shown on this page is the stable way to build a component today. Since Shopware 6.7.16.0, a component can also be written as a native Vue [Single File Component](../single-file-components/index.md), the experimental alternative. See the [SFC tutorial](../single-file-components/tutorial/index.md) if you want to try it. +::: + ## Overview Since the Shopware 6 Administration is using [VueJS](https://vuejs.org/) as its framework, it also supports creating custom components. This guide will teach you how to register your own custom component with your plugin. @@ -156,5 +160,6 @@ You've now added a custom component, including a little template. However, there * [More about templates](../templates-styling/writing-templates.md) * [Add some styling to your component](../templates-styling/add-custom-styles.md) * [Use shortcuts for your component](../advanced-configuration/add-shortcuts.md) +* [Build the same component as an experimental Single File Component](../single-file-components/tutorial/build-your-own-component.md) Furthermore, what about [customizing other components](customizing-components.md), instead of creating new ones? diff --git a/guides/plugins/plugins/administration/module-component-management/customizing-components.md b/guides/plugins/plugins/administration/module-component-management/customizing-components.md index e89e79cc5f..e4a29439c2 100644 --- a/guides/plugins/plugins/administration/module-component-management/customizing-components.md +++ b/guides/plugins/plugins/administration/module-component-management/customizing-components.md @@ -6,6 +6,10 @@ nav: # Customizing Components +::: info +`Component.override()` with TwigJS blocks, as shown on this page, is the stable way to customize a component today. Its counterpart in the experimental Single File Component system, available since Shopware 6.7.16.0, is an `.override.vue` file using ``. See [Chapter 2: Your first override](../single-file-components/tutorial/your-first-override.md). +::: + ## Overview The Shopware 6 Administration allows you to override and extend components to change its content and its behavior. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/block-components/index.md b/guides/plugins/plugins/administration/single-file-components/api-reference/block-components/index.md new file mode 100644 index 0000000000..7f0fe64011 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/block-components/index.md @@ -0,0 +1,51 @@ +--- +nav: + title: Block components + position: 30 + +--- + +# Block components + + + +Two globally registered components carry the native block system. You write the tags without importing anything. + + + + +## The model + +A component **declares** an extension point by wrapping markup in ``. Its children are the default content, and with nothing registered against the name it renders as though the wrapper were not there. + +An override **contributes** to that point with ``. That tag renders nothing where it stands: it registers its content, and the matching `` renders it wherever that happens to be. + +```html + + +

Default content

+
+ + + + +

Mine, underneath

+
+``` + +## Names + +A block name is a plain string and nothing validates it, so a typo matches nothing and fails silently. Names must be unique per component. + +The convention is an owner prefix, then the path through the component, in `snake_case`: + +```text +sw_product_detail_base_price_form +swag_greeting_card_body +``` + +Core uses `sw_`; a plugin uses its own prefix. A block name you find in core is covered by the backwards-compatibility promise: it stays until the next major version, and if one is removed in a major, the changelog names it. When core converts a component to a Single File Component, its block names stay the same. + +## Chains + +Several overrides may target one block. They stack in registration order, and each one's `` renders the previous one's output rather than the original default. Leaving `` out replaces everything below it, which is how one plugin removes another plugin's contribution. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/block-components/sw-block-parent.md b/guides/plugins/plugins/administration/single-file-components/api-reference/block-components/sw-block-parent.md new file mode 100644 index 0000000000..e12d978260 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/block-components/sw-block-parent.md @@ -0,0 +1,47 @@ +--- +nav: + title: sw-block-parent + position: 70 + +--- + +# `sw-block-parent` + + + +```html + +``` + +Takes no props. Renders whatever the extension point held before this override: the default content, or the previous override in the chain. Globally registered, so you write the tag without importing anything. + +Leave it out and the original markup is replaced. + +## Position decides yours + +```html + + + +

I go after

+
+ + + +

I go before

+ +
+ + + +

The original content is gone

+
+``` + +## Render it once, unconditionally + +It claims its position in the chain when it is created, so a `v-if` on it, or a `v-for` around it, corrupts that chain. One `` per extending block, always rendered. + +## Chains + +When several overrides target one block they stack, and each one's `` renders the previous one's output rather than the original default. That is what keeps other plugins on the page when your own change is additive. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/block-components/sw-block.md b/guides/plugins/plugins/administration/single-file-components/api-reference/block-components/sw-block.md new file mode 100644 index 0000000000..d3aa1c2cb9 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/block-components/sw-block.md @@ -0,0 +1,61 @@ +--- +nav: + title: sw-block + position: 60 + +--- + +# `sw-block` + + + +The extension point. Globally registered, so you write the tag without importing anything. Which prop you pass decides what it does. + +| Prop | Type | Meaning | +| --------- | -------- | ------------------------------------------------------------------------------------ | +| `name` | `string` | **Declares** an extension point. Its children are the default content | +| `extends` | `string` | **Contributes** to the extension point of that name. Renders nothing where it stands | + +Write `name` (base components only) or `extends` (overrides only) as a static attribute and nothing else. The component has other props - your editor may offer them - and they are internal. The build rejects any other attribute or directive on the tag, including `class`, `v-if`, a bound `:extends`, `data`, `v-bind` and an authored `#default`. + +## Declaring an extension point + +```html + +

Default content

+
+``` + +With nothing registered against the name, this renders as though the wrapper were not there. + +## Contributing to one + +```html + + +

Mine, underneath

+
+``` + +The target block is replaced by what goes inside. To keep what was there, render [`sw-block-parent`](sw-block-parent). + +An `` belongs at the root of an override's template and cannot be nested inside another element. Where it sits does not affect where its content renders: it registers the content, and the matching `` renders it. + +## Names + +Block names are strings and nothing validates them, so a typo silently matches nothing. They must be unique per component. The convention is an owner prefix and then the path through the component, in `snake_case`: core uses `sw_`, a plugin uses its own. + +```text +sw_product_detail_base_price_form +swag_greeting_card_body +``` + +## Bindings in block content + +Your override's bindings are available inside its `` content and read like any other Vue template binding. Mutating one from the template - `@click="counter++"` - is not supported; wrap the mutation in a function and call that instead. + +## Order, when several extensions meet + +Native `` contributions render in registration order, and all of them render after a TwigJS override of the same block. A legacy override is therefore always the parent of a native one, whichever order the plugins load in. + + diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/index.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/index.md new file mode 100644 index 0000000000..2db2a375b0 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/index.md @@ -0,0 +1,90 @@ +--- +nav: + title: Composables + position: 20 + +--- + +# Composables + + + +All composables the Administration offers are experimental. Their names, options and return values can +still change without a deprecation, so note which ones you rely on and tell us when one of them does not +fit what you are building. + +## Override composables + +Three of them are provided by the build in every `.override.vue` file. Like the macros, you never import +them, and they exist only inside an override file. They are how an override reaches the component it overrides. + + + + + +A base component needs none of them: it declares its own props with `defineProps()` and its own events +with `defineEmits()`. + +## Administration composables + +Everything else is imported by name from the `shopware:composables` virtual module: + +```ts +import { useNotification } from 'shopware:composables'; +``` + +Each composable is also available as a default export of its own import path, named after it in `camelCase` +(`import useNotification from 'shopware:composables/useNotification'`). + +Each of them is the Composition API side of a mixin. A mixin declared its own props and read them off +`this`; a composable has neither, so whatever the mixin used to take from its host is passed in, and +getters keep it reactive: + +```ts +const { isBetween, betweenValue } = useRuleBetweenOperator({ + condition: () => props.condition, + ensureValueExist: () => emit('ensure-value-exist'), +}); +``` + +The mixins stay where they are, so an Options API component that has not been migrated keeps working. + + + +### Notifications + + + + +### User settings + + + +### Text and formatting + + + + + + +### Lists and forms + + + + + +### Rule builder + + + + +### Media + + + + + +### CMS + + + diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-cms-element.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-cms-element.md new file mode 100644 index 0000000000..aa4dc9ffd8 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-cms-element.md @@ -0,0 +1,53 @@ +--- +nav: + title: useCmsElement() + position: 260 + +--- + +# `useCmsElement()` + + + +```ts +import { useCmsElement } from 'shopware:composables'; + +function useCmsElement(options: { + element: () => RuntimeSlot; + defaultConfig?: () => Record | null; +}): ReturnType & { + cmsElements: ComputedRef>; + config: ComputedRef; + getConfigValue: (path: string) => unknown; + setConfigValue: (path: string, value: unknown) => void; + getDemoValue: (mappingPath: string) => unknown; +}; +``` + +The API a CMS element's editor component works against. + +```ts +const { config, getConfigValue, setConfigValue } = useCmsElement({ + element: () => props.element, + defaultConfig: () => props.defaultConfig ?? null, +}); + +function onMediaChange(mediaId: string): void { + setConfigValue('media.value', mediaId); +} +``` + +`config` is the element's config resolved against the element type's `defaultConfig` and the inherited +slot config, resolved on every read rather than written back into the element. So there is no +initialization step, and no lifecycle hook to hang one on. + +The element itself is read-only: `setConfigValue()` goes through the `cmsPage` store, which is what keeps +the rest of the editor in sync. Both config functions take a path relative to the element's config, so a +nested value is `'media.value'`, not `'config.media.value'`. + +Everything [`useCmsState()`](use-cms-state) returns comes back as well, because the mixin composed it and +components relied on that. + +`getDemoValue()` resolves a mapping path against the demo entity the editor is previewing with. + +Replaces the `cms-element` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-cms-state.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-cms-state.md new file mode 100644 index 0000000000..d6e0f3eaa2 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-cms-state.md @@ -0,0 +1,51 @@ +--- +nav: + title: useCmsState() + position: 250 + +--- + +# `useCmsState()` + + + +```ts +import { useCmsState } from 'shopware:composables'; + +function useCmsState(): { + cmsPageState: ComputedRef; + selectedBlock: WritableComputedRef | null>; + selectedSection: WritableComputedRef | null>; + currentDeviceView: ComputedRef; + isSystemDefaultLanguage: ComputedRef; + category: ComputedRef | null>; + product: ComputedRef | null>; + landingPage: ComputedRef | null>; + contentEntity: ComputedRef; + inheritedSlotConfig: ComputedRef; + getSlotConfigForLanguage: (languageId?: string | null) => SlotConfigMap | null; +}; +``` + +The CMS editor state a block, section or config panel works against: what is selected, which device view +is being previewed, and which entity the layout is being edited on. + +`selectedBlock` and `selectedSection` are writable - assigning one selects it, the same way the store +action does: + +```ts +const { selectedBlock, currentDeviceView } = useCmsState(); + +function select(): void { + selectedBlock.value = props.block; +} +``` + +`contentEntity` resolves to the category, product or landing page the layout belongs to, decided by the +current route. The three are also available individually, and each reads `null` rather than throwing when +its detail module is not loaded. + +`inheritedSlotConfig` is the slot config of the parent language merged field by field with the current +one, which is what makes a partly translated layout inherit the rest instead of shadowing it. + +Replaces the `cms-state` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-inline-snippet.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-inline-snippet.md new file mode 100644 index 0000000000..0d5ca3efbd --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-inline-snippet.md @@ -0,0 +1,33 @@ +--- +nav: + title: useInlineSnippet() + position: 130 + +--- + +# `useInlineSnippet()` + + + +```ts +import { useInlineSnippet } from 'shopware:composables'; + +function useInlineSnippet(): { + getInlineSnippet: (value: { [locale: string]: string }) => string | { [locale: string]: string }; +}; +``` + +Resolves an inline snippet: an object keyed by locale, the way a configuration field stores a label a +merchant translated themselves. + +```ts +const { getInlineSnippet } = useInlineSnippet(); + +// { 'de-DE': 'Marge', 'en-GB': 'Margin' } -> 'Margin' in an English Administration +const label = computed(() => getInlineSnippet(props.element.config.label.value)); +``` + +It tries the current locale, then the fallback locale, then the first entry that is not empty. When the +value is not an object at all it comes back unchanged, so a plain string passes through. + +Replaces the `sw-inline-snippet` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-listing.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-listing.md new file mode 100644 index 0000000000..a5adbdd1d2 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-listing.md @@ -0,0 +1,95 @@ +--- +nav: + title: useListing() + position: 170 + +--- + +# `useListing()` + + + +```ts +import { useListing } from 'shopware:composables'; + +function useListing(options: { + getList: () => void | Promise; + filters?: () => ListingFilter[]; + page?: number; + limit?: number; + total?: number; + sortBy?: string | null; + sortDirection?: string; + naturalSorting?: boolean; + term?: string; + selection?: Record; + disableRouteParams?: boolean; + searchConfigEntity?: string | null; + entitySearchable?: boolean; + freshSearchTerm?: boolean; + storeKey?: string; + filterCriteria?: unknown[]; +}): UseListingReturn; // see "What you get back" +``` + +Everything a list page needs: pagination, sorting, the search term, the active filters and the current +selection. Pagination, sorting and the search term are kept in sync with the route query, so they survive +a reload and a browser back. Selection and filter criteria are not. + +## Options + +`getList` is the only mandatory one. It is your own loader, and the composable calls it whenever the +listing state changes - so the composable owns the state, and you own the request. + +`filters` is the second callback, returning the filters the page offers. Every remaining option is the +initial value of a listing field, the values a component used to set in its own `data()`. + +`disableRouteParams: true` keeps the state out of the URL, which is what you want for a list inside a +modal or a tab rather than a page of its own. + +## What you get back + +The state as refs - `page`, `limit`, `total`, `sortBy`, `sortDirection`, `naturalSorting`, `term`, +`selection`, `filterCriteria`, `disableRouteParams`, `searchConfigEntity`, `entitySearchable`, +`freshSearchTerm`, `previousRouteName` and `storeKey`. + +The derived values - `maxPage`, `selectionCount`, `selectionArray`, `currentSortBy`, `routeName`, +`searchRankingFields`. + +The handlers a data grid expects - `onPageChange`, `onSearch`, `onSort`, `onSortColumn`, `onSwitchFilter`, +`onRefresh`, `updateSelection`, `resetListing`, `updateCriteria`, and `addQueryScores()` for search +ranking. + +The lower-level helpers - `getMainListingParams()`, which returns the current `page`, `limit`, `term` and +sorting from the route query (or from the state when `disableRouteParams` is set), `updateData`, +`updateRoute`, `isValidTerm` and `parseBooleanQueryParams`. + +Your loader reads the state refs and writes `total` back: + +```ts +import { Criteria } from 'shopware:data'; + +const productRepository = Shopware.Service('repositoryFactory').create('product'); + +const { page, limit, total, term, onPageChange, onSearch } = useListing({ + getList: () => loadProducts(), +}); + +async function loadProducts(): Promise { + const criteria = new Criteria(page.value, limit.value); + if (term.value) { + criteria.setTerm(term.value); + } + + const result = await productRepository.search(criteria); + total.value = result.total ?? 0; +} +``` + +## One difference to the mixin + +The first load runs in `onMounted()` rather than `created()`, because `getList` is a callback that can +only exist once the call has returned. A list page that relied on data being there before the first +render needs a look. + +Replaces the `listing` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-media-grid-listener.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-media-grid-listener.md new file mode 100644 index 0000000000..08475bb684 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-media-grid-listener.md @@ -0,0 +1,56 @@ +--- +nav: + title: useMediaGridListener() + position: 220 + +--- + +# `useMediaGridListener()` + + + +```ts +import { useMediaGridListener } from 'shopware:composables'; + +function useMediaGridListener(options: { + selectableItems: () => MediaGridItem[]; + onFolderChange: (folderId: string) => void; +}): { + selectedItems: ShallowRef; + listSelectionStartItem: ShallowRef; + mediaItemSelectionHandler: ComputedRef void>>; + isListSelect: ComputedRef; + isItemSelected: (item: MediaGridItem) => boolean; + showItemSelected: (item: MediaGridItem) => boolean; + clearSelection: () => void; + navigateToFolder: (event: { item: MediaGridItem }) => void; + showDetails: (gridItem: MediaGridItem) => void; + handleMediaItemClicked: (event: MediaGridItemEvent) => void; + handleMediaGridItemSelected: (event: MediaGridItemEvent) => void; + handleMediaGridItemUnselected: (event: { item: MediaGridItem }) => void; +}; +``` + +Turns the click and selection events of `sw-media-media-item` into a selection, with `ctrl`/`cmd` toggling +and `shift` ranges. + +Bind `mediaItemSelectionHandler` with `v-on` and the grid is wired up: + +```vue + +``` + +`selectableItems` is the order a `shift` range is resolved against, so hand back the items in the order +the grid renders them. `onFolderChange` is called when a folder is opened, where the mixin emitted an +event. + +`selectedItems` is a `shallowRef` on purpose: the selection is tracked by item identity, and a deep ref +would hand out reactive proxies that no longer compare equal to the items you passed in. + +Replaces the `media-grid-listener` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-media-sidebar-modal.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-media-sidebar-modal.md new file mode 100644 index 0000000000..80277e2840 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-media-sidebar-modal.md @@ -0,0 +1,53 @@ +--- +nav: + title: useMediaSidebarModal() + position: 230 + +--- + +# `useMediaSidebarModal()` + + + +```ts +import { useMediaSidebarModal } from 'shopware:composables'; + +function useMediaSidebarModal(options: { + onItemsDelete: (ids: string[]) => void; + onFolderItemsDissolve: (ids: string[]) => void; + onItemsMove: (ids: string[]) => void; +}): { + showModalReplace: Ref; + showModalDelete: Ref; + showFolderSettings: Ref; + showFolderDissolve: Ref; + showModalMove: Ref; + openModalReplace: () => void; + closeModalReplace: () => void; + openModalDelete: () => void; + closeModalDelete: () => void; + openFolderSettings: () => void; + closeFolderSettings: () => void; + openFolderDissolve: () => void; + closeFolderDissolve: () => void; + openModalMove: () => void; + closeModalMove: () => void; + deleteSelectedItems: (ids: string[]) => void; + onFolderDissolved: (ids: string[]) => void; + onFolderMoved: (ids: string[]) => void; +}; +``` + +The open and close state of the media sidebar's modals: replace, delete, folder settings, folder dissolve +and move. Each gets a `show*` ref and an `open*`/`close*` pair. + +Opening is guarded by the media ACL privileges - `media.editor` for replace, dissolve and move, +`media.deleter` for delete - so `openModalDelete()` on a user without the privilege simply does nothing. + +The three handlers at the end close their modal and report what it did a tick later, through the callbacks +you passed in. The delay matters: the modal is torn down by the same flag flip, and the caller's reaction +typically reloads the list the modal was rendered into. + +Where the mixin emitted events, this takes callbacks. + +Replaces the `media-sidebar-modal-mixin`. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-notification-translation.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-notification-translation.md new file mode 100644 index 0000000000..e89fad0d72 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-notification-translation.md @@ -0,0 +1,28 @@ +--- +nav: + title: useNotificationTranslation() + position: 50 + +--- + +# `useNotificationTranslation()` + + + +```ts +import { useNotificationTranslation } from 'shopware:composables'; + +function useNotificationTranslation(): { + getTranslatedTitle: (notification: NotificationType) => string; + getTranslatedMessage: (notification: NotificationType) => string; +}; +``` + +The rendering helpers the notification components share. A snippet key is translated and a plain string +passes through, so the same notification works either way. + +`getTranslatedMessage()` also sanitizes what it returns, down to `a`, `b`, `i`, `u`, `strong`, `em` and +`br`, with `href` and `target` as the only attributes. A notification message may therefore carry a link, +but nothing else. + +Replaces the `notification-translation` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-notification.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-notification.md new file mode 100644 index 0000000000..f8b7c2e563 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-notification.md @@ -0,0 +1,46 @@ +--- +nav: + title: useNotification() + position: 40 + +--- + +# `useNotification()` + + + +```ts +import { useNotification } from 'shopware:composables'; + +function useNotification(): { + createNotification: (notification: NotificationType) => string | null; + createNotificationSuccess: (config: NotificationType) => void; + createNotificationInfo: (config: NotificationType) => void; + createNotificationWarning: (config: NotificationType) => void; + createNotificationError: (config: NotificationType) => void; + createSystemNotification: (config: NotificationType) => void; + createSystemNotificationSuccess: (config: NotificationType) => void; + createSystemNotificationInfo: (config: NotificationType) => void; + createSystemNotificationWarning: (config: NotificationType) => void; + createSystemNotificationError: (config: NotificationType) => void; +}; +``` + +Creates the notifications that appear in the top right of the Administration. + +The four `createNotification*` variants fill in the variant and a default title, so a message is all you +have to pass: + +```ts +const { createNotificationSuccess } = useNotification(); + +createNotificationSuccess({ message: 'Margin recalculated' }); +``` + +The `createSystemNotification*` variants set `system: true` and the variant, but no default title. + +`createNotification()` is the one they all call, and the only one that returns something: the id of the +notification it created, or `null` for a success notification or one without a `message`. Every +notification except a success one is also kept in the notification center. + +Replaces the `notification` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-placeholder.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-placeholder.md new file mode 100644 index 0000000000..cbc6a076ec --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-placeholder.md @@ -0,0 +1,38 @@ +--- +nav: + title: usePlaceholder() + position: 150 + +--- + +# `usePlaceholder()` + + + +```ts +import { usePlaceholder } from 'shopware:composables'; + +function usePlaceholder(): { + placeholder: (entity: Entity, field: keyof Entity, fallbackSnippet: string) => string; +}; +``` + +Reads a translatable field off an entity the way the Administration displays it everywhere else - mostly +as the `placeholder` attribute of a translatable input. + +```ts +const { placeholder } = usePlaceholder(); +const { tWithFallback } = useTranslateWithFallback(); + +const namePlaceholder = computed(() => + placeholder(product.value, 'name', tWithFallback('sw-product.detail.placeholderName')), +); +``` + +Four steps, in order: the field on the entity itself, then the parent language's translation, then the +entity's `translated` object, and the fallback you passed as the last resort. That is what makes an +inherited name show up greyed out in a child language instead of showing nothing. + +The fallback is returned as it is, so pass translated text, not a snippet key. + +Replaces the `placeholder` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-position.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-position.md new file mode 100644 index 0000000000..49d43066eb --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-position.md @@ -0,0 +1,42 @@ +--- +nav: + title: usePosition() + position: 180 + +--- + +# `usePosition()` + + + +```ts +import { usePosition } from 'shopware:composables'; + +function usePosition(): { + getNewPosition: (repository: Repository, criteria: Criteria, context: ApiContext, field?: string) => Promise; + lowerPositionValue: (collection: EntityCollection, selectedItem: Entity, field?: string) => EntityCollection; + raisePositionValue: (collection: EntityCollection, selectedItem: Entity, field?: string) => EntityCollection; + changePosition: (collection: EntityCollection, selectedItem: Entity, field?: string, direction?: string) => EntityCollection; + getSiblingIndex: (collection: EntityCollection, selectedItem: Entity, field?: string, direction?: string) => number; + getSibling: (collection: EntityCollection, selectedItem: Entity, field?: string, direction?: string) => Entity | null; + renumberPositions: (collection: EntityCollection, startIndex?: number, field?: string) => EntityCollection; +}; +``` + +Helpers for the `position` integers that order an entity collection. + +```ts +const { getNewPosition, raisePositionValue } = usePosition(); + +const position = await getNewPosition(repository, new Criteria(1, 1), Shopware.Context.api); +``` + +`getNewPosition()` aggregates the current maximum and returns it plus one, starting at `1` for an empty +collection. It adds that aggregation and a sorting to the `criteria` you pass. `lowerPositionValue()` and +`raisePositionValue()` swap an item with its neighbour, and `renumberPositions()` renumbers the whole +collection from `startIndex`, which defaults to `0`. + +Every function takes an optional `field` argument that defaults to `'position'`, so a collection ordered +by a differently named field works too. They sort the collection in place and hand it back. + +Replaces the `position` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-rule-between-operator.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-rule-between-operator.md new file mode 100644 index 0000000000..d66aff06d0 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-rule-between-operator.md @@ -0,0 +1,37 @@ +--- +nav: + title: useRuleBetweenOperator() + position: 210 + +--- + +# `useRuleBetweenOperator()` + + + +```ts +import { useRuleBetweenOperator } from 'shopware:composables'; + +function useRuleBetweenOperator(options: { + condition: () => RuleCondition | null | undefined; + ensureValueExist: () => void; +}): { + isBetween: ComputedRef; + betweenValue: WritableComputedRef<{ from: string | null; to: string | null }>; +}; +``` + +Renders a condition's `between` operator as a from/to pair on a date or datetime field. + +```ts +const { isBetween, betweenValue } = useRuleBetweenOperator({ + condition: () => props.condition, + ensureValueExist: () => emit('ensure-value-exist'), +}); +``` + +`betweenValue` is writable, and writing calls `ensureValueExist()` first, the way the mixin reached into +its host to do. A condition whose `renderedFieldValue` is not an object reads as `{ from: null, to: null }` +rather than throwing. + +Replaces the `rule-between-operator` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-rule-container.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-rule-container.md new file mode 100644 index 0000000000..6c89d07eeb --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-rule-container.md @@ -0,0 +1,44 @@ +--- +nav: + title: useRuleContainer() + position: 200 + +--- + +# `useRuleContainer()` + + + +```ts +import { useRuleContainer } from 'shopware:composables'; + +function useRuleContainer(options: { + condition: () => RuleConditionNode; + level: () => number; + disabled: () => boolean; + onAddPlaceholder: () => void; +}): { + conditionDataProviderService: ComputedRef; + childAssociationField: ComputedRef; + createCondition: (conditionData: unknown, parentId: string | null, position: number) => RuleConditionNode; + insertNodeIntoTree: (parentCondition: RuleConditionNode, childToInsert: RuleConditionNode) => void; + removeNodeFromTree: (parentCondition: RuleConditionNode, childToRemove: RuleConditionNode) => void; + containerRowClass: ComputedRef; + nextPosition: ComputedRef; +}; +``` + +The state a condition container inside `sw-condition-tree` works against - what you need when building a +custom container for the rule builder. + +The tree provides the condition service and the three tree-editing functions; the composable injects them +and adds `containerRowClass`, which alternates the background by nesting level and carries the disabled +state. + +`onAddPlaceholder` is called when the container runs out of children, which is how a container keeps an +empty row for the next condition. + +`conditionDataProviderService` and `childAssociationField` stay refs here. The Options API unwrapped them +on the instance proxy, so a migrated template that used them without `.value` needs one. + +Replaces the `ruleContainer` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-salutation.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-salutation.md new file mode 100644 index 0000000000..af7ee69336 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-salutation.md @@ -0,0 +1,33 @@ +--- +nav: + title: useSalutation() + position: 160 + +--- + +# `useSalutation()` + + + +```ts +import { useSalutation } from 'shopware:composables'; + +function useSalutation(): { + salutation: (entity: SalutationFilterEntityType, fallbackSnippet?: string) => string; +}; +``` + +Formats the salutation of a customer-like entity - anything carrying a `salutation` association along with +`title`, `firstName` and `lastName`. + +```ts +const { salutation } = useSalutation(); +const { tWithFallback } = useTranslateWithFallback(); + +const greeting = computed(() => salutation(customer.value, tWithFallback('sw-customer.detail.noSalutation'))); +``` + +It resolves the `salutation` filter on each call, so it behaves exactly as the filter does in a template. +The fallback is returned as it is, so pass translated text, not a snippet key. + +Replaces the `salutation` mixin. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-sw-context.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-sw-context.md new file mode 100644 index 0000000000..7e555b029a --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-sw-context.md @@ -0,0 +1,30 @@ +--- +nav: + title: useSwContext() + position: 30 + +--- + +# `useSwContext()` + + + +```ts +function useSwContext(): T; +``` + +Provided by the build in every `.override.vue` file; you never import it. Returns the overridden component's Vue `SetupContext`: `emit`, `attrs`, `slots` and `expose`. + +```ts +const context = useSwContext(); +const previousState = useSwPreviousState(); + +function onSave(): void { + previousState.onSave(); + context.emit('saved'); +} +``` + +Use it to emit the component's own events from an override, or to read the slots it was given. + +A base component does not need this: it declares its events with `defineEmits()` and its slots with `defineSlots()`. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-sw-previous-state.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-sw-previous-state.md new file mode 100644 index 0000000000..cf1e76f679 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-sw-previous-state.md @@ -0,0 +1,46 @@ +--- +nav: + title: useSwPreviousState() + position: 10 + +--- + +# `useSwPreviousState()` + + + +```ts +function useSwPreviousState>(): T; +``` + +Provided by the build in every `.override.vue` file; you never import it. Returns the state of the component being overridden. + +## What you get + +Everything that component exposes: its `data`, `computed`, `methods` and `props` while it is still an Options API component, or the names it passed to [`swDefinePublic()`](../macros/sw-define-public) once it has been migrated. + +```ts +const previousState = useSwPreviousState(); + +previousState.product.value; // read a value +previousState.save(); // run the original implementation of a method +``` + +Calling a method is how an override builds on behaviour instead of discarding it - the equivalent of `this.$super()` in the Options API. + +## Reading values + +Values are refs, methods are plain functions. Read a value with `.value` in your script; a template unwraps it as it does any ref. + +```ts +const name = previousState.product.value?.name; +``` + +## With several overrides + +Each override sees the state as the ones registered before it left it, so a chain of overrides composes: + +```ts +// second override in the chain +const greeting = computed(() => `${previousState.greeting.value} and again`); +``` diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-sw-props.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-sw-props.md new file mode 100644 index 0000000000..9bb11a55f0 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-sw-props.md @@ -0,0 +1,26 @@ +--- +nav: + title: useSwProps() + position: 20 + +--- + +# `useSwProps()` + + + +```ts +function useSwProps>(): T; +``` + +Provided by the build in every `.override.vue` file; you never import it. Returns the props the overridden component was given. + +```ts +const props = useSwProps(); + +const label = computed(() => `Editing ${props.name}`); +``` + +Read only. Props come from whoever renders the component, so [`swDefineOverride()`](../macros/sw-define-override) rejects a prop name with a console error. To change what a prop-derived value produces, override the binding that derives it. + +A base component does not need this: it declares its own props with `defineProps()`. diff --git a/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-translate-with-fallback.md b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-translate-with-fallback.md new file mode 100644 index 0000000000..dfbf4885b1 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/api-reference/composables/use-translate-with-fallback.md @@ -0,0 +1,34 @@ +--- +nav: + title: useTranslateWithFallback() + position: 140 + +--- + +# `useTranslateWithFallback()` + + + +```ts +import { useTranslateWithFallback } from 'shopware:composables'; + +function useTranslateWithFallback(): { + tWithFallback: (key: string) => string; +}; +``` + +Translates a snippet key against the active locale and, when there is no entry there, against the +fallback locale. + +A plugin cannot import `vue-i18n`, so ` +``` + + + + +## Start here + +The fastest way in is to build something. The tutorial takes you from an empty directory to a plugin that warns a merchant when a product's profit margin is too low, on the product detail page: + + + +![The finished plugin on the product detail page](../../../../../assets/administration-sfc-tutorial-extended.png) + +Four pages back it up: + + + + + + +## Give us feedback + +Tell us what you tried to extend and where the system got in your way. The roadmap has [the details and the channel](roadmap#give-us-feedback). diff --git a/guides/plugins/plugins/administration/single-file-components/internals.md b/guides/plugins/plugins/administration/single-file-components/internals.md new file mode 100644 index 0000000000..bd747a66fd --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/internals.md @@ -0,0 +1,706 @@ +--- +nav: + title: Internals + position: 100 + +--- + +# Internals + + + +:::danger +This page describes internals. Nothing on it is API: the generated code and every name in it change without a deprecation, and an extension must not depend on them. Write against the [API reference](api-reference/) instead. +::: + +Vue has no extension mechanism of the kind this page describes. Nothing in Vue lets one file +replace a binding of another, or lets a plugin drop markup into the middle of another component's template. + +Shopware adds that mechanism at build time. Before Vue sees your `.vue` file, a transform rewrites it into +ordinary Composition API code that talks to the Administration's extension system, and hands that to +Vue. Vue compiles a normal component and never learns that an extension system exists. + +What follows is how that rewrite works, and what the block components do once the result is running. + +## The short version + +### At build time + +1. A Vite plugin picks up the `.vue` file and decides from its name whether it is a base component or + an override, and which component it belongs to. +2. The transform rewrites the script. A base component's bindings are put behind a middleware, so an + override can replace them. An override's whole body becomes a callback for the extension system to + call. +3. The transform rewrites the template, adding the bindings `sw-block` needs to carry state between + the two. + +### At runtime + +1. Every override component is mounted once at boot. That registers its `setup()` and each of its + `` slots. It renders none of them. +2. When a base component is set up, its own `setup()` runs first, then the `setup()` of each override, + one after another, all inside the base component's own setup. +3. Each `sw-block` of the base renders the last contribution registered for its component and name, and each + `` inside it renders the one before, recursively down to the base's own content. +4. Because an override's markup is rendered by the base component, and its `setup()` has been folded + into the base component's setup too, the bindings Vue would normally connect to a template cannot + reach it. The transform routes them through a generated `#default="{ ... }"` destructuring + instead, which is why it still feels like ordinary Vue from the outside. + +## How a `.vue` file becomes a component + +Two Vite plugins and one transform, all of them in +`src/Administration/Resources/app/administration/build/`. + +### The filename is the identity + +There is no registration argument to get wrong, because the transform reads the identity off the path: + +| File | Mode | Component | +| ------------------------------------- | -------- | ------------------------ | +| `swag-margin-hint.vue` | base | `swag-margin-hint` | +| `swag-margin-hint/index.vue` | base | `swag-margin-hint` | +| `sw-product-detail-base.override.vue` | override | `sw-product-detail-base` | +| `swag-margin-hint/index.override.vue` | override | `swag-margin-hint` | + +That name is baked into the generated code as a string literal, which is why renaming the file changes +what the component is called and what an override targets. + +### Registration is not symmetric + +An **override** needs no registration at all. `shopware-vite-plugin-override-component` walks your +extension for `*.override.vue` files, sorts them, and rewrites your entry file to import and register +each one: + +```js +/* Start: auto generated by Shopware */ +import _swOverride0 from './component/swag-margin-hint/swag-margin-hint.override.vue'; +Shopware.Component.registerOverrideComponent(_swOverride0); +/* End: auto generated by Shopware */ +``` + +The files are sorted because a directory walk follows filesystem order, which differs between machines, +and the import numbering follows the walk. Sorting keeps the generated entry identical across builds. + +A **new base component** is different. Nothing scans for it. Usually an `import` is enough; to use its +tag by name, you register it yourself: + +```ts +Shopware.Component.register('swag-margin-hint', async () => { + const component = (await import('./swag-margin-hint.vue')).default; + + return { ...component, _renderedBySfcTemplate: true } as never; +}); +``` + +`_renderedBySfcTemplate` is needed because a production build inlines the render function into +`setup()`, where the component factory does not find it, and refuses to build the component without +the flag. + +A base `.vue` file that *replaces* an existing component needs no registration either - the component +is already registered, and the filename is what makes your file the one that is loaded. + +### The transform runs behind a virtual file + +`shopware-vite-plugin-shopware-setup` runs with `enforce: 'pre'`, ahead of Vue's own SFC plugin. It +redirects the import to a virtual id - `.shopware-setup.vue` - and serves the rewritten +source from there. + +The detour keeps sourcemaps correct. Vue compiles the rewritten file under a path of its own, +while the sourcemap the transform emits points every generated line back at the line you wrote, so a +stack trace and a breakpoint land in your file rather than in generated code. + +### The same transform, three times + +The transform is exposed through a CommonJS entry point, because it has three consumers that all need +it synchronously: + +| Consumer | What it gives you | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| The Vite build | The code that actually runs | +| The Jest transformer | The Administration's own tests; extension tests are not supported yet, see the [roadmap](roadmap#tooling) | +| The `valid-shopware-setup` ESLint rule | The same error in your editor, before you build | + +So an invalid file fails the same way in all three places, with the same message. There is no separate +linter that can drift out of sync with the compiler. + + + +## The base transform + +A base component keeps its body. Nothing is hoisted, nothing is wrapped, every Vue macro stays where +you put it. The transform only renames and appends. + +Everything below is the output of the real transform on this file, with blank lines collapsed: + +```vue + + + + +``` + +### 1. Every top-level binding is renamed + +```ts +const __swSetupAuthor_margin = computed(() => 0.15); +const __swSetupAuthor_variant = computed(() => (__swSetupAuthor_margin.value < warnBelow ? 'critical' : 'positive')); +``` + +Your `margin` becomes `__swSetupAuthor_margin`, and every reference to it is renamed with it. The +`__swSetup` prefix is reserved - the transform rejects it in author code - which is what makes a +collision impossible. + +`warnBelow` is untouched. It comes from `defineProps()`, which stays native, so props never take part +in the rename. + +### 2. The macro becomes a footer + +```ts +const { + margin, + variant, + message, +} = Shopware.Component.attachOverrides({ + name: 'swag-margin-hint', + public: { + margin: __swSetupAuthor_margin, + variant: __swSetupAuthor_variant, + message: __swSetupAuthor_message, + }, + private: {}, +}); +``` + +The `swDefinePublic()` call itself is deleted. What it leaves behind is the `public` map, and the +`defineExpose()` described below. Every other top-level binding lands in `private`: + +```ts +// a binding you did not publish +private: { + count: __swSetupAuthor_count, +}, +``` + +The difference between the two maps is what an override may replace. Both are handed over, because a +private binding still has to be readable by the template - it just cannot be overridden. + +Then the original names are declared again, by destructuring the return. So the template still writes +`variant` and `message`, but reads them through the extension system rather than from your `const` +directly. + +That destructuring is the middleware from the short version: your file is untouched above the footer, +every name it exposes now passes through a layer in between, and that layer is what an override +replaces. + +This is also why a top-level binding must not share a declared prop's name. The extension system +strips declared prop keys from what it returns, so the destructured binding would be `undefined`. + +The footer ends with a generated `defineExpose()`: + +```ts +defineExpose({ + ...Shopware.Component.getExposedProps(), + margin, + variant, + message, +}); +``` + +It exposes the published names, after any override has replaced them, plus the component's props as +read-only refs. So a parent that holds a template ref to the component reads the same things from it +that it could read from an Options API component. That is why you never write `defineExpose()` +yourself - the build rejects it. + +### 3. The template gains two attributes + +```vue + +``` + +`sw-internal-component-name` is the component the file belongs to, taken from its filename. A block is +identified by that component name *and* its block name, the same way Twig scopes its blocks. So a block +called `swag_margin_hint_banner` in another component never picks up overrides meant for this one. + +`$dataScope` is the component's state, resolved for this instance. It is what `sw-block` passes to +every contributor of the block - see [Blocks at runtime](#blocks-at-runtime). + +Authoring either attribute yourself is rejected, and so is any other attribute or directive on +`sw-block`. The transform owns them completely, so what a block receives is always exactly what it +wired. + +### The whole file + +
+swag-margin-hint.vue, after the transform + +```vue + + + +``` + +
+ +It is still a plain ` +``` + +### 1. The body becomes a callback + +```ts +Shopware.Component.overrideComponentSetup()('swag-margin-hint', (__swSetupPreviousState, __swSetupProps, __swSetupContext) => { + // your code +}); +``` + +Your whole ` + + + +``` + + + +The file is still a component, and it is still mounted - but only so that the two registrations +happen. The callback registers the state replacement; rendering the template registers the block +content. Neither produces visible markup of its own. + +## Blocks at runtime + +`sw-block` is an ordinary Vue component with two modes, and a module-level registry between them. + +### The registry + +One reactive object, shared by the whole application, mapping a block to the slot functions registered +for it: + +```ts +const blockContext: Record = reactive({}); +``` + +The key is the component name plus the block name, `'swag-margin-hint swag_margin_hint_banner'`, built +from the `sw-internal-component-name` the transform stamped on the block. That is why an override has to +be named after the component that renders the block. + +An extension never touches it. The two modes of `sw-block` are its only writer and its only reader. + +### `extends` registers and renders nothing + +```ts +if (props.extends) { + const extendsKey = componentBlockKey(props.swInternalComponentName, props.extends); + const overrideSlot: Slot = (data) => slots.default?.(data) ?? []; + + addBlock(extendsKey, overrideSlot); + onBeforeUnmount(() => removeBlock(extendsKey, overrideSlot)); + + return { template: null }; +} +``` + +That is the whole contribution path. `` hands its **default slot** to the registry and +renders nothing at its own position - which is why an override's markup appears where the base +component declared the block, not where you wrote it. + +It registers a small wrapper rather than the slot function itself. Vue hands the component a new slot +function whenever the surrounding scope changes, and the wrapper always calls the current one. + +### `name` calls the chain + +```ts +const nativeBlocks = getBlocks(componentBlockKey(props.swInternalComponentName, props.name)); +const blocksAndParent = [ + slots.default ?? (() => []), + ...shimSlots, + ...nativeBlocks, +]; +const blocksNodes = blocksAndParent.map((block) => block?.(props.data)); + +const lastNode = blocksNodes.pop(); +providedParents.value = blocksNodes; +return lastNode; +``` + +Each contributor is a function, and the block calls it with `props.data`. + +`props.data` is the `:data="$dataScope"` the transform generated - the base component's own state. So +an override's markup is executed by the base component, during the base component's render, with the +base component's state as its slot argument. Everything the override's template can see arrives +through that one argument, its own locals included. + +The chain is then resolved by position: + +| Position | Who | +| -------- | ---------------------------------------------------------------- | +| First | The block's own default content | +| Middle | Legacy Twig overrides, through the [shim](#twig-interop) | +| Last | Native `` contributions, in registration order | + +The **last** entry is what the block renders. Everything before it is provided to the descendants +under an injection key, for `sw-block-parent` to pick up. + +So a Twig override of the same block always sits earlier in the chain than a native one, whatever order +the plugins load in: the native override's `` renders it. + +### `sw-block-parent` pops one entry + +```ts +const parents = inject(parentsInjectionKey, null); +const initialParents = parents?.value; +const initialParent = initialParents?.pop(); +const parentIndex = initialParents ? initialParents.length : -1; +``` + +Each `` claims one entry from the stack, once, in its own `setup()`, and remembers +the index it claimed. Later renders read the current node at that index. + +Claiming happens at creation, which is the reason for the rule: a `` must render +unconditionally and exactly once per extending block. Put it in a `v-if` or a `v-for` and the claims +no longer line up with the stack, and the chain renders the wrong content. + +### `name` is static + +Changing the `name` prop after mount is not supported - the shim slots are built once in `setup()` and +the registry binding is made once. A development build warns when the prop changes; a production build +does not. + +## Twig interop + +Most of the Administration still ships an `index.js` and a `.html.twig`, while converted components +ship a `.vue`. Both directions of the mix work, and each is a shim. + +### A Twig base, extended by a `.vue` override + +The component's Twig template is compiled with an `sw-block` wrapped around each `{% block %}` that a +`.vue` override extends, so what was a TwigJS extension point becomes a native one: + +```twig +{# what the component ships #} +{% block sw_product_detail_base_price_form %} + ... +{% endblock %} +``` + +```html + + + ... + +``` + +Your override then contributes to it like it would to any other block, with no sign that the other +side is Twig: + +```vue + +``` + +The second half is state. The component's own state lives on an Options API instance, and your +override's lives in a `setup()`. A bridge sits between them: it presents the Options API instance as +the previous state your override reads, and applies what your override returns back onto the +instance. + +```ts +const previousState = useSwPreviousState(); + +previousState.product.value; // a `data` property of the Options API component +previousState.onSave(); // one of its `methods` +``` + +So `useSwPreviousState()` gives you the `data`, `computed` and `methods` of a component that has never +heard of the Composition API. + +### A `.vue` base, extended by a Twig override + +The same in reverse. A base component declares its blocks natively: + +```vue + +``` + +and a Twig override of that block + +```twig +{% block swag_margin_hint_banner %} + {% parent %} +

Added from a Twig override

+{% endblock %} +``` + +joins the chain as though it had been written as one: + +```html + + +

Added from a Twig override

+
+``` + +`{% parent %}` becomes ``, and the bridge presents the base component's setup state +to the override as the `this` a Twig template expects. + +### The `v-if` shim + +Wrapping a `{% block %}` in a component has one consequence worth naming. A Twig template is free +to open a `v-if` in one block and close it with a `v-else` in another: + +```twig +{% block sw_example_toolbar_save %} + Save +{% endblock %} + +{% block sw_example_toolbar_hint %} + Read only +{% endblock %} +``` + +That worked because the blocks were text: the compiled template was one flat piece of markup, and the +two branches ended up as siblings. Once each block is its own component, they are not siblings any +more, and Vue only pairs a `v-else` with a `v-if` that directly precedes it. + +So the conditionals are compiled into helper calls instead of into plain `v-if` directives. Each +branch records its result in a chain shared by the block, and a later `v-else` asks the chain what the +earlier branches decided rather than relying on being adjacent to them. The chain is segmented in +render order - the block's own content, then Twig contributions, then native ones - so a branch can +see the results of everything rendered before it, wherever that happened to be. + +A condition split across Twig blocks therefore keeps working after the component is converted, with +no change to the template that wrote it. + +### What the shims do not cover + +They translate the shapes templates actually take, not every shape a template could take. Ordinary +Twig comes through - blocks, `{% parent %}`, nesting, a condition split across blocks. A template that +plays games with the block structure itself can still fall through, and there is no shim that will +rescue it. + +A block whose content mixes a named slot template with other markup cannot host a native extension +point at all. Your `.vue` override of that block is ignored, and a development build warns with +`[TemplateFactory] The block "…" cannot host a native extension point: …`. + + diff --git a/guides/plugins/plugins/administration/single-file-components/roadmap.md b/guides/plugins/plugins/administration/single-file-components/roadmap.md new file mode 100644 index 0000000000..018c2309d3 --- /dev/null +++ b/guides/plugins/plugins/administration/single-file-components/roadmap.md @@ -0,0 +1,148 @@ +--- +nav: + title: Roadmap + position: 90 + +--- + +# Roadmap + + + +Where the Single File Component extension system stands: what you can build with it today, what is +still being worked on, and where to tell us when something does not fit. + +## What experimental means here + +The system is available since Shopware 6.7.16.0. There is no feature flag to enable and nothing to opt into - a `.vue` file +in your plugin is compiled by the extension build as it is. + +Experimental means the API can still change, at any time and without a deprecation. The plan is for it +to become a stable, deprecation-protected API in **6.9**. How much it still has to change depends on +what you run into and on how the Administration's own migration goes. + +Until the API is declared stable, an SFC extension written today may need changes with every Shopware +update. + +## Timeline + +| When | What happens | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 6.7.16 | The extension system is available, and experimental. | +| 6.8 | The Administration's `@private` components, which are not part of the public extension contract, are converted and run in production for the first time. | +| 6.9 | Planned: the extension system becomes a stable API, and a first handful of public components are converted with it. | +| Later | The remaining components follow. The shims keep working for a while after that. | + +Most of the work is converting the Administration's own components, and that is why the experimental +phase lasts as long as it does: every converted component can break an existing extension, and those +breaks should surface during the experimental phase rather than after the API is declared stable. + +## What is supported today + +### Vue's own macros + +Base components compile as ordinary ` + + +``` + +## The override shrinks + +All the override still decides is *where*: + +```vue + + + + +``` + +`useSwPreviousState()` is gone from this file, and so is every line of arithmetic. + +## Checkpoint + +Reload the product. The banner looks as it did in Chapter 3 except for one thing: it no longer sits flush against the price fields. That is the gap [Chapter 2](your-first-override#checkpoint) promised, and it comes from the component's own `