From c86b01dafb99b5739b2aecbfc46001dca1479f8b Mon Sep 17 00:00:00 2001 From: markus-moser Date: Wed, 11 Dec 2024 15:30:43 +0100 Subject: [PATCH] Add URL slug data type --- assets/build/api/openapi-config.ts | 3 + .../js/src/core/app/config/services/index.ts | 2 + .../core/app/config/services/service-ids.ts | 1 + .../core/modules/document/hooks/use-sites.ts | 51 ++++++ .../core/modules/document/sites-slice.gen.ts | 56 +++++++ .../components/url-slug/url-slug.tsx | 145 ++++++++++++++++++ .../dynamic-type-object-data-url-slug.tsx | 35 +++++ .../modules/element/dynamic-types/index.ts | 2 + translations/studio.en.yaml | 3 + 9 files changed, 298 insertions(+) create mode 100644 assets/js/src/core/modules/document/hooks/use-sites.ts create mode 100644 assets/js/src/core/modules/document/sites-slice.gen.ts create mode 100644 assets/js/src/core/modules/element/dynamic-types/defintinitions/objects/data-related/components/url-slug/url-slug.tsx create mode 100644 assets/js/src/core/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-url-slug.tsx diff --git a/assets/build/api/openapi-config.ts b/assets/build/api/openapi-config.ts index 5cfac4273..61ef08b56 100644 --- a/assets/build/api/openapi-config.ts +++ b/assets/build/api/openapi-config.ts @@ -80,6 +80,9 @@ const config: ConfigFile = { }, '../../js/src/core/modules/data-object/unit-slice.gen.ts': { filterEndpoints: pathMatcher(/\/unit\//i) + }, + '../../js/src/core/modules/document/sites-slice.gen.ts': { + filterEndpoints: pathMatcher(/\/documents\/sites\//i) } }, exportName: 'api', diff --git a/assets/js/src/core/app/config/services/index.ts b/assets/js/src/core/app/config/services/index.ts index 03abb0d4b..4590657b4 100644 --- a/assets/js/src/core/app/config/services/index.ts +++ b/assets/js/src/core/app/config/services/index.ts @@ -99,6 +99,7 @@ import { DynamicTypeObjectDataEmail } from '@Pimcore/modules/element/dynamic-typ import { DynamicTypeObjectDataGender } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-gender' import { DynamicTypeObjectDataRgbaColor } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-rgba-color' import { DynamicTypeObjectDataCheckbox } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-checkbox' +import { DynamicTypeObjectDataUrlSlug } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-url-slug' import { DynamicTypeObjectDataDate } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-date' import { DynamicTypeObjectDataDatetime } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-datetime' import { DynamicTypeObjectDataDateRange } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-date-range' @@ -245,6 +246,7 @@ container.bind(serviceIds['DynamicTypes/ObjectData/Email']).to(DynamicTypeObject container.bind(serviceIds['DynamicTypes/ObjectData/Gender']).to(DynamicTypeObjectDataGender).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/RgbaColor']).to(DynamicTypeObjectDataRgbaColor).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/Checkbox']).to(DynamicTypeObjectDataCheckbox).inSingletonScope() +container.bind(serviceIds['DynamicTypes/ObjectData/UrlSlug']).to(DynamicTypeObjectDataUrlSlug).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/Date']).to(DynamicTypeObjectDataDate).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/Datetime']).to(DynamicTypeObjectDataDatetime).inSingletonScope() container.bind(serviceIds['DynamicTypes/ObjectData/DateRange']).to(DynamicTypeObjectDataDateRange).inSingletonScope() diff --git a/assets/js/src/core/app/config/services/service-ids.ts b/assets/js/src/core/app/config/services/service-ids.ts index 5ad8c5596..ae4749c58 100644 --- a/assets/js/src/core/app/config/services/service-ids.ts +++ b/assets/js/src/core/app/config/services/service-ids.ts @@ -134,6 +134,7 @@ export const serviceIds = { 'DynamicTypes/ObjectData/Gender': 'DynamicTypes/ObjectData/Gender', 'DynamicTypes/ObjectData/RgbaColor': 'DynamicTypes/ObjectData/RgbaColor', 'DynamicTypes/ObjectData/Checkbox': 'DynamicTypes/ObjectData/Checkbox', + 'DynamicTypes/ObjectData/UrlSlug': 'DynamicTypes/ObjectData/UrlSlug', 'DynamicTypes/ObjectData/Date': 'DynamicTypes/ObjectData/Date', 'DynamicTypes/ObjectData/Datetime': 'DynamicTypes/ObjectData/Datetime', 'DynamicTypes/ObjectData/DateRange': 'DynamicTypes/ObjectData/DateRange', diff --git a/assets/js/src/core/modules/document/hooks/use-sites.ts b/assets/js/src/core/modules/document/hooks/use-sites.ts new file mode 100644 index 000000000..e8f5cdbf2 --- /dev/null +++ b/assets/js/src/core/modules/document/hooks/use-sites.ts @@ -0,0 +1,51 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import { type Site, useDocumentsListAvailableSitesQuery } from '@Pimcore/modules/document/sites-slice.gen' + +export interface UseSitesReturn { + getSiteById: (siteId: number) => Site | undefined + getAllSites: () => Site[] + getSitesByIds: (ids: number[]) => Site[] + getRemainingSites: (ids: number[], filteredSiteIds?: number[]) => Site[] +} + +export const useSites = (): UseSitesReturn => { + const { data: sites } = useDocumentsListAvailableSitesQuery({ excludeMainSite: false }) + + const getSiteById = (siteId: number): Site | undefined => { + return sites?.items?.find(site => site.id === siteId) + } + + const getAllSites = (): Site[] => { + return sites?.items ?? [] + } + + const getSitesByIds = (ids: number[]): Site[] => { + return sites?.items?.filter(site => ids.includes(site.id)) ?? [] + } + + const getRemainingSites = (ids: number[], filteredSiteIds?: number[]): Site[] => { + const filteredSites = filteredSiteIds !== undefined && filteredSiteIds.length > 0 + ? getSitesByIds(filteredSiteIds) + : sites?.items + return filteredSites?.filter(site => !ids.includes(site.id)) ?? [] + } + + return { + getSiteById, + getAllSites, + getSitesByIds, + getRemainingSites + } +} diff --git a/assets/js/src/core/modules/document/sites-slice.gen.ts b/assets/js/src/core/modules/document/sites-slice.gen.ts new file mode 100644 index 000000000..16550d7bf --- /dev/null +++ b/assets/js/src/core/modules/document/sites-slice.gen.ts @@ -0,0 +1,56 @@ +import { api } from "../../app/api/pimcore/index"; +export const addTagTypes = ["Documents"] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + documentsListAvailableSites: build.query< + DocumentsListAvailableSitesApiResponse, + DocumentsListAvailableSitesApiArg + >({ + query: (queryArg) => ({ + url: `/pimcore-studio/api/documents/sites/list-available`, + params: { excludeMainSite: queryArg.excludeMainSite }, + }), + providesTags: ["Documents"], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as api }; +export type DocumentsListAvailableSitesApiResponse = /** status 200 List of available sites */ { + items: Site[]; +}; +export type DocumentsListAvailableSitesApiArg = { + /** Exclude main site from the list */ + excludeMainSite?: boolean; +}; +export type Site = { + /** AdditionalAttributes */ + additionalAttributes?: { + [key: string]: string | number | boolean | object | any[]; + }; + /** ID */ + id: number; + /** Domains */ + domains: string[]; + /** Domain */ + domain: string; + /** ID of the root */ + rootId?: number | null; + /** Root path */ + rootPath?: string | null; +}; +export type Error = { + /** Message */ + message: string; +}; +export type DevError = { + /** Message */ + message: string; + /** Details */ + details: string; +}; +export const { useDocumentsListAvailableSitesQuery } = injectedRtkApi; diff --git a/assets/js/src/core/modules/element/dynamic-types/defintinitions/objects/data-related/components/url-slug/url-slug.tsx b/assets/js/src/core/modules/element/dynamic-types/defintinitions/objects/data-related/components/url-slug/url-slug.tsx new file mode 100644 index 000000000..ffa7c2c3f --- /dev/null +++ b/assets/js/src/core/modules/element/dynamic-types/defintinitions/objects/data-related/components/url-slug/url-slug.tsx @@ -0,0 +1,145 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import React, { useEffect, useState } from 'react' +import { Input, List, Tooltip, Typography } from 'antd' +import { Flex } from '@Pimcore/components/flex/flex' +import { toCssDimension } from '@Pimcore/utils/css' +import { useSites } from '@Pimcore/modules/document/hooks/use-sites' +import { useTranslation } from 'react-i18next' +import { Dropdown } from '@Pimcore/components/dropdown/dropdown' +import { DropdownButton } from '@Pimcore/components/dropdown-button/dropdown-button' +import { IconButton } from '@Pimcore/components/icon-button/icon-button' + +export interface UrlSlugEntry { + slug: string + siteId: number +} + +export interface UrlSlugProps { + availableSites?: number[] | null + disabled?: boolean + domainLabelWidth?: number | null + width?: number | string | null + value?: UrlSlugEntry[] | null + onChange?: (value?: UrlSlugEntry[] | null) => void +} + +export const UrlSlug = (props: UrlSlugProps): React.JSX.Element => { + const [value, setValue] = useState(props.value ?? []) + const [errors, setErrors] = useState([]) + const { t } = useTranslation() + const { getSiteById, getRemainingSites } = useSites() + const { Text } = Typography + + useEffect(() => { + if (props.onChange !== undefined) { + props.onChange(value) + } + }, [value]) + + const validateSlug = (slug: string): boolean => { + if (slug !== '') { + if (!slug.startsWith('/') || slug.length < 2) { + return false + } + slug = slug.substring(1).replace(/\/$/, '') + const parts = slug.split('/') + for (const part of parts) { + if (part.length === 0) { + return false + } + } + } + return true + } + + const handleInputChange = (index: number, newSlug: string): void => { + const newValue = [...value] + + newValue[index].slug = newSlug + + const newErrors = [...errors] + newErrors[index] = !validateSlug(newSlug) + setValue(newValue) + setErrors(newErrors) + } + + const remainingSites = getRemainingSites(value.map(item => item.siteId), props.availableSites ?? undefined) + + const remainingSitesMenuItems = remainingSites.map(site => ({ + key: site.id, + label: site.domain, + onClick: () => { setValue([...value, { slug: '', siteId: site.id }]) } + })) + + return ( + <> + 0 && ( + + + + {t('url-slug.add-site')} + + + + ) } + renderItem={ (item: UrlSlugEntry, index: number) => ( + + +
+ { item.siteId === 0 ? t('fallback') : getSiteById(item.siteId)?.domain } +
+
+ { handleInputChange(index, e.target.value) } } + status={ errors[index] ? 'error' : undefined } + value={ item.slug } + /> + { errors[index] && ( + + {t('url-slug.invalid')} + + )} +
+ + { + const newValue = [...value] + newValue.splice(index, 1) + setValue(newValue) + } } + /> + +
+
+ ) } + size="small" + style={ { maxWidth: toCssDimension(props.width, undefined) } } + /> + + ) +} diff --git a/assets/js/src/core/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-url-slug.tsx b/assets/js/src/core/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-url-slug.tsx new file mode 100644 index 000000000..ffd82ca98 --- /dev/null +++ b/assets/js/src/core/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-url-slug.tsx @@ -0,0 +1,35 @@ +/** +* Pimcore +* +* This source file is available under two different licenses: +* - Pimcore Open Core License (POCL) +* - Pimcore Commercial License (PCL) +* Full copyright and license information is available in +* LICENSE.md which is distributed with this source code. +* +* @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) +* @license https://github.com/pimcore/studio-ui-bundle/blob/1.x/LICENSE.md POCL and PCL +*/ + +import React from 'react' +import { + type AbstractObjectDataDefinition, DynamicTypeObjectDataAbstract +} from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/dynamic-type-object-data-abstract' +import { + UrlSlug, type UrlSlugProps +} from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/components/url-slug/url-slug' + +export type UrlSlugObjectDataDefinition = AbstractObjectDataDefinition & UrlSlugProps + +export class DynamicTypeObjectDataUrlSlug extends DynamicTypeObjectDataAbstract { + id: string = 'urlSlug' + + getObjectDataComponent (props: UrlSlugObjectDataDefinition): React.ReactElement { + return ( + + ) + } +} diff --git a/assets/js/src/core/modules/element/dynamic-types/index.ts b/assets/js/src/core/modules/element/dynamic-types/index.ts index 819ed5714..537c8bb91 100644 --- a/assets/js/src/core/modules/element/dynamic-types/index.ts +++ b/assets/js/src/core/modules/element/dynamic-types/index.ts @@ -87,6 +87,7 @@ import { type DynamicTypeObjectDataEmail } from '@Pimcore/modules/element/dynami import { type DynamicTypeObjectDataGender } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-gender' import { type DynamicTypeObjectDataRgbaColor } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-rgba-color' import { type DynamicTypeObjectDataCheckbox } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-checkbox' +import { type DynamicTypeObjectDataUrlSlug } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-url-slug' import { type DynamicTypeObjectDataDate } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-date' import { type DynamicTypeObjectDataDatetime } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-datetime' import { type DynamicTypeObjectDataDateRange } from '@Pimcore/modules/element/dynamic-types/defintinitions/objects/data-related/types/dynamic-type-object-data-date-range' @@ -213,6 +214,7 @@ moduleSystem.registerModule({ objectDataRegistry.registerDynamicType(container.get(serviceIds['DynamicTypes/ObjectData/Gender'])) objectDataRegistry.registerDynamicType(container.get(serviceIds['DynamicTypes/ObjectData/RgbaColor'])) objectDataRegistry.registerDynamicType(container.get(serviceIds['DynamicTypes/ObjectData/Checkbox'])) + objectDataRegistry.registerDynamicType(container.get(serviceIds['DynamicTypes/ObjectData/UrlSlug'])) objectDataRegistry.registerDynamicType(container.get(serviceIds['DynamicTypes/ObjectData/Date'])) objectDataRegistry.registerDynamicType(container.get(serviceIds['DynamicTypes/ObjectData/Datetime'])) objectDataRegistry.registerDynamicType(container.get(serviceIds['DynamicTypes/ObjectData/DateRange'])) diff --git a/translations/studio.en.yaml b/translations/studio.en.yaml index 82d5240b1..7ef61130b 100644 --- a/translations/studio.en.yaml +++ b/translations/studio.en.yaml @@ -372,3 +372,6 @@ navigation.custom-reports: Custom Reports navigation.perspectives: Perspectives external-image.preview-placeholder: Add an external image image.dnd-target: Upload or drop an image here +fallback: Fallback +url-slug.add-site: Add Site +url-slug.invalid: Please enter a valid URL slug beginning with a '/' \ No newline at end of file