diff --git a/.changeset/spicy-hotels-pull.md b/.changeset/spicy-hotels-pull.md new file mode 100644 index 00000000..0cc3b36a --- /dev/null +++ b/.changeset/spicy-hotels-pull.md @@ -0,0 +1,5 @@ +--- +"@pluginpal/webtools-core": minor +--- + +Make the 'languages' field for an URL pattern optional. Patterns without any languages set will be used as a fallback for localized content types. diff --git a/.changeset/strong-sheep-train.md b/.changeset/strong-sheep-train.md new file mode 100644 index 00000000..0edaefd2 --- /dev/null +++ b/.changeset/strong-sheep-train.md @@ -0,0 +1,5 @@ +--- +"@pluginpal/webtools-core": minor +--- + +Use database transactions in the bulk generation service. diff --git a/packages/core/admin/components/LanguageCheckboxes/index.tsx b/packages/core/admin/components/LanguageCheckboxes/index.tsx index 0ad81a67..f1d196a2 100644 --- a/packages/core/admin/components/LanguageCheckboxes/index.tsx +++ b/packages/core/admin/components/LanguageCheckboxes/index.tsx @@ -6,6 +6,7 @@ import { Field, FieldLabel, FieldError, + FieldHint, } from '@strapi/design-system'; import { request } from '@strapi/helper-plugin'; import { EnabledContentTypes } from '../../types/enabled-contenttypes'; @@ -13,13 +14,11 @@ import { EnabledContentTypes } from '../../types/enabled-contenttypes'; type Props = { selectedLanguages: string[]; onChange: (selectedLanguages: string[]) => any; - error?: any; }; const LanguageCheckboxes = ({ selectedLanguages, onChange, - error, }: Props) => { const [languages, setLanguages] = React.useState([]); const [loading, setLoading] = React.useState(false); @@ -41,7 +40,7 @@ const LanguageCheckboxes = ({ } return ( - + Select the language {languages.map((contentType) => ( @@ -64,6 +63,7 @@ const LanguageCheckboxes = ({ ))} + ); diff --git a/packages/core/admin/screens/Patterns/CreatePage/index.tsx b/packages/core/admin/screens/Patterns/CreatePage/index.tsx index daa2d3d2..a8a5c80f 100644 --- a/packages/core/admin/screens/Patterns/CreatePage/index.tsx +++ b/packages/core/admin/screens/Patterns/CreatePage/index.tsx @@ -229,11 +229,6 @@ const CreatePatternPage = () => { setFieldValue('languages', newLanguages)} selectedLanguages={values.languages} - error={ - errors.languages && touched.languages - ? errors.languages - : null - } /> diff --git a/packages/core/admin/screens/Patterns/EditPage/index.tsx b/packages/core/admin/screens/Patterns/EditPage/index.tsx index 76dce524..a0c94d5b 100644 --- a/packages/core/admin/screens/Patterns/EditPage/index.tsx +++ b/packages/core/admin/screens/Patterns/EditPage/index.tsx @@ -273,11 +273,6 @@ const EditPatternPage = () => { setFieldValue('languages', newLanguages)} selectedLanguages={values.languages} - error={ - errors.languages && touched.languages - ? errors.languages - : null - } /> diff --git a/packages/core/admin/screens/Patterns/EditPage/utils/schema.ts b/packages/core/admin/screens/Patterns/EditPage/utils/schema.ts index 121ff430..b3feb39d 100644 --- a/packages/core/admin/screens/Patterns/EditPage/utils/schema.ts +++ b/packages/core/admin/screens/Patterns/EditPage/utils/schema.ts @@ -5,11 +5,6 @@ const schema = yup.object().shape({ label: yup.string().required(translatedErrors.required), pattern: yup.string().required(translatedErrors.required), contenttype: yup.string().required(translatedErrors.required), - languages: yup.array().when('localized', { - is: true, - then: yup.array().min(1, 'Select at least one language'), - otherwise: yup.array().notRequired(), - }), }); export default schema; diff --git a/packages/core/server/admin-api/services/bulk-generate.ts b/packages/core/server/admin-api/services/bulk-generate.ts index 1ff2da72..6fc9d139 100644 --- a/packages/core/server/admin-api/services/bulk-generate.ts +++ b/packages/core/server/admin-api/services/bulk-generate.ts @@ -1,6 +1,9 @@ -import { Common } from '@strapi/types'; +import { Common, Attribute } from '@strapi/types'; +// import { Attribute } from '@strapi/strapi'; +import snakeCase from 'lodash/snakeCase'; import { getPluginService } from '../../util/getPluginService'; import { GenerationType } from '../../types'; +import { duplicateCheck } from './url-alias'; export interface GenerateParams { types: Common.UID.ContentType[], generationType: GenerationType } @@ -77,27 +80,23 @@ const generateUrlAliases = async (parms: GenerateParams) => { // Map over all the types sent in the request. await Promise.all(types.map(async (type) => { + const { collectionName, info } = strapi.contentTypes[type]; + const { singularName } = info; + const newTransaction = await strapi.db.connection.transaction(); + if (generationType === 'all') { // Delete all the URL aliases for the given type. - await getPluginService('url-alias').deleteMany({ - // @ts-ignore - locale: 'all', - filters: { - contenttype: type, - }, - }); + await newTransaction('wt_url_alias') + .where('contenttype', type) + .delete(); } if (generationType === 'only_generated') { - // Delete all the auto generated URL aliases of the given type. - await getPluginService('url-alias').deleteMany({ - // @ts-ignore - locale: 'all', - filters: { - contenttype: type, - generated: true, - }, - }); + // Delete all the URL aliases for the given type. + await newTransaction('wt_url_alias') + .where('contenttype', type) + .andWhere('generated', true) + .delete(); } let relations: string[] = []; @@ -115,16 +114,10 @@ const generateUrlAliases = async (parms: GenerateParams) => { })); // Query all the entities of the type that do not have a corresponding URL alias. - const entities = await strapi.entityService.findMany(type, { - filters: { - url_alias: null, - }, - locale: 'all', - // @ts-ignore - populate: { - ...relations.reduce((obj, key) => ({ ...obj, [key]: {} }), {}), - }, - }); + const entities = await newTransaction(collectionName) + .leftJoin(`${collectionName}_url_alias_links`, `${collectionName}.id`, `${collectionName}_url_alias_links.${snakeCase(singularName)}_id`) + .where(`${collectionName}_url_alias_links`, null) + .select('*') as Attribute.GetValues>[]; /** * @todo @@ -135,32 +128,35 @@ const generateUrlAliases = async (parms: GenerateParams) => { */ // For all those entities we will create a URL alias and connect it to the entity. // eslint-disable-next-line no-restricted-syntax - for (const entity of entities) { + await Promise.all(entities.map(async (entity) => { // @ts-ignore // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-unsafe-argument const urlPattern = await getPluginService('urlPatternService').findByUid(type, entity.locale); const generatedPath = getPluginService('urlPatternService').resolvePattern(type, entity, urlPattern); // eslint-disable-next-line no-await-in-loop - const newUrlAlias = await getPluginService('urlAliasService').create({ - url_path: generatedPath, + const urlPath = await duplicateCheck(generatedPath); + + const newUrlAlias = await newTransaction('wt_url_alias').insert({ generated: true, contenttype: type, // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment locale: entity.locale, - }); + url_path: urlPath, + }, '*') as unknown as Attribute.GetValues>[]; // eslint-disable-next-line no-await-in-loop - await strapi.entityService.update(type, entity.id, { - data: { - // @ts-ignore - url_alias: newUrlAlias.id, - }, - }); + await newTransaction(`${collectionName}_url_alias_links`) + .insert({ + [`${snakeCase(singularName)}_id`]: entity.id, + url_alias_id: newUrlAlias[0].id, + }); generatedCount += 1; - } + })); + + await newTransaction.commit(); })); return generatedCount; diff --git a/packages/core/server/admin-api/services/url-alias.ts b/packages/core/server/admin-api/services/url-alias.ts index 69159411..7bfd8e4e 100644 --- a/packages/core/server/admin-api/services/url-alias.ts +++ b/packages/core/server/admin-api/services/url-alias.ts @@ -6,7 +6,7 @@ import { getPluginService } from '../../util/getPluginService'; /** * Finds a path from the original path that is unique */ -const duplicateCheck = async ( +export const duplicateCheck = async ( originalPath: string, ignoreId?: Entity.ID, ext: number = -1, diff --git a/packages/core/server/admin-api/services/url-pattern.ts b/packages/core/server/admin-api/services/url-pattern.ts index 79a81d7f..4784c364 100644 --- a/packages/core/server/admin-api/services/url-pattern.ts +++ b/packages/core/server/admin-api/services/url-pattern.ts @@ -53,11 +53,21 @@ export default () => ({ filters: { contenttype: uid, }, + fields: ['pattern', 'languages'], }); if (langcode) { - patterns = patterns - .filter((pattern) => (pattern.languages as string).includes(langcode)); + const allPatterns = patterns; + + patterns = allPatterns + .filter((pattern) => (pattern.languages as any[]).includes(langcode)); + + // If no pattern is found for the given language, check if there is + // any pattern that is not language specific. We should use that as a fallback. + if (patterns.length === 0) { + patterns = allPatterns + .filter((pattern) => (pattern.languages as any[]).length === 0); + } } if (!patterns[0]) { diff --git a/packages/core/server/types/generated/contentTypes.d.ts b/packages/core/server/types/generated/contentTypes.d.ts index eeda7aba..6a51f312 100644 --- a/packages/core/server/types/generated/contentTypes.d.ts +++ b/packages/core/server/types/generated/contentTypes.d.ts @@ -362,47 +362,6 @@ export interface AdminTransferTokenPermission extends Schema.CollectionType { }; } -export interface ApiTestTest extends Schema.CollectionType { - collectionName: 'tests'; - info: { - singularName: 'test'; - pluralName: 'tests'; - displayName: 'test'; - }; - options: { - draftAndPublish: true; - }; - pluginOptions: { - webtools: { - enabled: true; - }; - }; - attributes: { - title: Attribute.String; - createdAt: Attribute.DateTime; - updatedAt: Attribute.DateTime; - publishedAt: Attribute.DateTime; - createdBy: Attribute.Relation<'api::test.test', 'oneToOne', 'admin::user'> & - Attribute.Private; - updatedBy: Attribute.Relation<'api::test.test', 'oneToOne', 'admin::user'> & - Attribute.Private; - url_alias: Attribute.Relation< - 'api::test.test', - 'oneToOne', - 'plugin::webtools.url-alias' - > & - Attribute.Unique & - Attribute.SetPluginOptions<{ - i18n: { - localized: true; - }; - }>; - sitemap_exclude: Attribute.Boolean & - Attribute.Private & - Attribute.DefaultTo; - }; -} - export interface PluginUploadFile extends Schema.CollectionType { collectionName: 'files'; info: { @@ -444,9 +403,12 @@ export interface PluginUploadFile extends Schema.CollectionType { folderPath: Attribute.String & Attribute.Required & Attribute.Private & - Attribute.SetMinMax<{ - min: 1; - }>; + Attribute.SetMinMax< + { + min: 1; + }, + number + >; createdAt: Attribute.DateTime; updatedAt: Attribute.DateTime; createdBy: Attribute.Relation< @@ -461,9 +423,6 @@ export interface PluginUploadFile extends Schema.CollectionType { 'admin::user' > & Attribute.Private; - sitemap_exclude: Attribute.Boolean & - Attribute.Private & - Attribute.DefaultTo; }; } @@ -485,9 +444,12 @@ export interface PluginUploadFolder extends Schema.CollectionType { attributes: { name: Attribute.String & Attribute.Required & - Attribute.SetMinMax<{ - min: 1; - }>; + Attribute.SetMinMax< + { + min: 1; + }, + number + >; pathId: Attribute.Integer & Attribute.Required & Attribute.Unique; parent: Attribute.Relation< 'plugin::upload.folder', @@ -506,9 +468,12 @@ export interface PluginUploadFolder extends Schema.CollectionType { >; path: Attribute.String & Attribute.Required & - Attribute.SetMinMax<{ - min: 1; - }>; + Attribute.SetMinMax< + { + min: 1; + }, + number + >; createdAt: Attribute.DateTime; updatedAt: Attribute.DateTime; createdBy: Attribute.Relation< @@ -526,6 +491,98 @@ export interface PluginUploadFolder extends Schema.CollectionType { }; } +export interface PluginContentReleasesRelease extends Schema.CollectionType { + collectionName: 'strapi_releases'; + info: { + singularName: 'release'; + pluralName: 'releases'; + displayName: 'Release'; + }; + options: { + draftAndPublish: false; + }; + pluginOptions: { + 'content-manager': { + visible: false; + }; + 'content-type-builder': { + visible: false; + }; + }; + attributes: { + name: Attribute.String & Attribute.Required; + releasedAt: Attribute.DateTime; + actions: Attribute.Relation< + 'plugin::content-releases.release', + 'oneToMany', + 'plugin::content-releases.release-action' + >; + createdAt: Attribute.DateTime; + updatedAt: Attribute.DateTime; + createdBy: Attribute.Relation< + 'plugin::content-releases.release', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + updatedBy: Attribute.Relation< + 'plugin::content-releases.release', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + }; +} + +export interface PluginContentReleasesReleaseAction + extends Schema.CollectionType { + collectionName: 'strapi_release_actions'; + info: { + singularName: 'release-action'; + pluralName: 'release-actions'; + displayName: 'Release Action'; + }; + options: { + draftAndPublish: false; + }; + pluginOptions: { + 'content-manager': { + visible: false; + }; + 'content-type-builder': { + visible: false; + }; + }; + attributes: { + type: Attribute.Enumeration<['publish', 'unpublish']> & Attribute.Required; + entry: Attribute.Relation< + 'plugin::content-releases.release-action', + 'morphToOne' + >; + contentType: Attribute.String & Attribute.Required; + locale: Attribute.String; + release: Attribute.Relation< + 'plugin::content-releases.release-action', + 'manyToOne', + 'plugin::content-releases.release' + >; + createdAt: Attribute.DateTime; + updatedAt: Attribute.DateTime; + createdBy: Attribute.Relation< + 'plugin::content-releases.release-action', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + updatedBy: Attribute.Relation< + 'plugin::content-releases.release-action', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + }; +} + export interface PluginWebtoolsUrlAlias extends Schema.CollectionType { collectionName: 'wt_url_alias'; info: { @@ -544,6 +601,9 @@ export interface PluginWebtoolsUrlAlias extends Schema.CollectionType { 'content-type-builder': { visible: false; }; + i18n: { + localized: true; + }; }; attributes: { url_path: Attribute.String & Attribute.Required & Attribute.Unique; @@ -563,9 +623,12 @@ export interface PluginWebtoolsUrlAlias extends Schema.CollectionType { 'admin::user' > & Attribute.Private; - sitemap_exclude: Attribute.Boolean & - Attribute.Private & - Attribute.DefaultTo; + localizations: Attribute.Relation< + 'plugin::webtools.url-alias', + 'oneToMany', + 'plugin::webtools.url-alias' + >; + locale: Attribute.String; }; } @@ -608,9 +671,6 @@ export interface PluginWebtoolsUrlPattern extends Schema.CollectionType { 'admin::user' > & Attribute.Private; - sitemap_exclude: Attribute.Boolean & - Attribute.Private & - Attribute.DefaultTo; }; } @@ -681,10 +741,13 @@ export interface PluginI18NLocale extends Schema.CollectionType { }; attributes: { name: Attribute.String & - Attribute.SetMinMax<{ - min: 1; - max: 50; - }>; + Attribute.SetMinMax< + { + min: 1; + max: 50; + }, + number + >; code: Attribute.String & Attribute.Unique; createdAt: Attribute.DateTime; updatedAt: Attribute.DateTime; @@ -851,20 +914,167 @@ export interface PluginUsersPermissionsUser extends Schema.CollectionType { 'admin::user' > & Attribute.Private; + }; +} + +export interface ApiCategoryCategory extends Schema.CollectionType { + collectionName: 'categories'; + info: { + singularName: 'category'; + pluralName: 'categories'; + displayName: 'Category'; + description: ''; + }; + options: { + draftAndPublish: true; + }; + pluginOptions: { + webtools: { + enabled: true; + }; + }; + attributes: { + test: Attribute.Relation< + 'api::category.category', + 'oneToOne', + 'api::test.test' + >; + title: Attribute.String; + createdAt: Attribute.DateTime; + updatedAt: Attribute.DateTime; + publishedAt: Attribute.DateTime; + createdBy: Attribute.Relation< + 'api::category.category', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + updatedBy: Attribute.Relation< + 'api::category.category', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; url_alias: Attribute.Relation< - 'plugin::users-permissions.user', + 'api::category.category', 'oneToOne', 'plugin::webtools.url-alias' > & - Attribute.Unique & + Attribute.Unique; + sitemap_exclude: Attribute.Boolean & + Attribute.Private & + Attribute.DefaultTo; + }; +} + +export interface ApiPrivateCategoryPrivateCategory + extends Schema.CollectionType { + collectionName: 'private_categories'; + info: { + singularName: 'private-category'; + pluralName: 'private-categories'; + displayName: 'Private category'; + description: ''; + }; + options: { + draftAndPublish: true; + }; + pluginOptions: { + webtools: { + enabled: true; + }; + }; + attributes: { + title: Attribute.String; + test: Attribute.Relation< + 'api::private-category.private-category', + 'oneToOne', + 'api::test.test' + >; + createdAt: Attribute.DateTime; + updatedAt: Attribute.DateTime; + publishedAt: Attribute.DateTime; + createdBy: Attribute.Relation< + 'api::private-category.private-category', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + updatedBy: Attribute.Relation< + 'api::private-category.private-category', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + url_alias: Attribute.Relation< + 'api::private-category.private-category', + 'oneToOne', + 'plugin::webtools.url-alias' + > & + Attribute.Unique; + sitemap_exclude: Attribute.Boolean & + Attribute.Private & + Attribute.DefaultTo; + }; +} + +export interface ApiTestTest extends Schema.CollectionType { + collectionName: 'tests'; + info: { + singularName: 'test'; + pluralName: 'tests'; + displayName: 'test'; + description: ''; + }; + options: { + draftAndPublish: true; + populateCreatorFields: true; + }; + pluginOptions: { + webtools: { + enabled: true; + }; + i18n: { + localized: true; + }; + }; + attributes: { + title: Attribute.String & Attribute.SetPluginOptions<{ i18n: { localized: true; }; }>; + category: Attribute.Relation< + 'api::test.test', + 'oneToOne', + 'api::category.category' + >; + private_category: Attribute.Relation< + 'api::test.test', + 'oneToOne', + 'api::private-category.private-category' + >; + createdAt: Attribute.DateTime; + updatedAt: Attribute.DateTime; + publishedAt: Attribute.DateTime; + createdBy: Attribute.Relation<'api::test.test', 'oneToOne', 'admin::user'>; + updatedBy: Attribute.Relation<'api::test.test', 'oneToOne', 'admin::user'>; + url_alias: Attribute.Relation< + 'api::test.test', + 'oneToOne', + 'plugin::webtools.url-alias' + > & + Attribute.Unique; sitemap_exclude: Attribute.Boolean & Attribute.Private & Attribute.DefaultTo; + localizations: Attribute.Relation< + 'api::test.test', + 'oneToMany', + 'api::test.test' + >; + locale: Attribute.String; }; } @@ -878,9 +1088,10 @@ declare module '@strapi/types' { 'admin::api-token-permission': AdminApiTokenPermission; 'admin::transfer-token': AdminTransferToken; 'admin::transfer-token-permission': AdminTransferTokenPermission; - 'api::test.test': ApiTestTest; 'plugin::upload.file': PluginUploadFile; 'plugin::upload.folder': PluginUploadFolder; + 'plugin::content-releases.release': PluginContentReleasesRelease; + 'plugin::content-releases.release-action': PluginContentReleasesReleaseAction; 'plugin::webtools.url-alias': PluginWebtoolsUrlAlias; 'plugin::webtools.url-pattern': PluginWebtoolsUrlPattern; 'plugin::webtools-addon-sitemap.sitemap': PluginWebtoolsAddonSitemapSitemap; @@ -888,6 +1099,9 @@ declare module '@strapi/types' { 'plugin::users-permissions.permission': PluginUsersPermissionsPermission; 'plugin::users-permissions.role': PluginUsersPermissionsRole; 'plugin::users-permissions.user': PluginUsersPermissionsUser; + 'api::category.category': ApiCategoryCategory; + 'api::private-category.private-category': ApiPrivateCategoryPrivateCategory; + 'api::test.test': ApiTestTest; } } } diff --git a/playground/src/api/private-category/content-types/private-category/schema.json b/playground/src/api/private-category/content-types/private-category/schema.json index 1706d632..d77ad47a 100644 --- a/playground/src/api/private-category/content-types/private-category/schema.json +++ b/playground/src/api/private-category/content-types/private-category/schema.json @@ -13,11 +13,19 @@ "pluginOptions": { "webtools": { "enabled": true + }, + "i18n": { + "localized": true } }, "attributes": { "title": { - "type": "string" + "type": "string", + "pluginOptions": { + "i18n": { + "localized": true + } + } }, "test": { "type": "relation", diff --git a/playground/types/generated/contentTypes.d.ts b/playground/types/generated/contentTypes.d.ts index 6a51f312..bd0f4938 100644 --- a/playground/types/generated/contentTypes.d.ts +++ b/playground/types/generated/contentTypes.d.ts @@ -983,9 +983,17 @@ export interface ApiPrivateCategoryPrivateCategory webtools: { enabled: true; }; + i18n: { + localized: true; + }; }; attributes: { - title: Attribute.String; + title: Attribute.String & + Attribute.SetPluginOptions<{ + i18n: { + localized: true; + }; + }>; test: Attribute.Relation< 'api::private-category.private-category', 'oneToOne', @@ -1015,6 +1023,12 @@ export interface ApiPrivateCategoryPrivateCategory sitemap_exclude: Attribute.Boolean & Attribute.Private & Attribute.DefaultTo; + localizations: Attribute.Relation< + 'api::private-category.private-category', + 'oneToMany', + 'api::private-category.private-category' + >; + locale: Attribute.String; }; }