/
githubmirror
/
strapi
Обзор
Документация
Войти
/
githubmirror
/
strapi
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
tests/utils/content-types.ts
798 строк
27 KB
RufusLeTerrible
Feat/e2e critical ctb add fields (#26559)
24 июл 2026, 18:30
Не верифицирован
24 июл 2026, 18:30
344c0a1
Код
Авторство
О чём код?
import { isBoolean, isNumber, isString, kebabCase, snakeCase } from 'lodash/fp'; import { waitForRestart } from './restart'; import pluralize from 'pluralize'; import { expect, type Page } from '@playwright/test'; import { clickAndWait, ensureCheckbox, findByRowColumn, navToHeader } from './shared'; import { rowHeight } from '@strapi/admin/admin/src/pages/Settings/pages/Roles/utils/constants'; export interface AddAttribute { type: string; name?: string; advanced?: AdvancedAttributeSettings; number?: { format: numberFormat }; date?: { format: dateFormat }; media?: { multiple: boolean }; enumeration?: { values: string[] }; component?: { useExisting?: string; options: Partial<AddComponentOptions> }; dz?: { components: AddComponentAttribute[]; }; relation?: { type: keyof typeof relationsMap; target: { name?: string; select?: string; }; }; } // keys are the relation types used by the RelationNaturePicker component // locatorText is the text that should be displayed for the relation type // inverted denotes the inverse relation type(s) export const relationsMap: Record< string, { locatorText: string; hasInverse: boolean; inverted?: boolean; pluralizeTarget?: boolean; pluralizeName?: boolean; } > = { oneWay: { locatorText: 'has one', hasInverse: false, pluralizeTarget: false, pluralizeName: false, }, oneToOne: { locatorText: 'has and belongs to one', hasInverse: true, pluralizeTarget: false, pluralizeName: false, }, oneToMany: { locatorText: 'belongs to many', hasInverse: true, pluralizeTarget: false, pluralizeName: true, }, manyToOne: { locatorText: 'has many', inverted: true, hasInverse: true, pluralizeTarget: true, pluralizeName: false, }, manyToMany: { locatorText: 'has and belongs to many', hasInverse: true, pluralizeTarget: true, pluralizeName: true, }, manyWay: { locatorText: 'has many', hasInverse: false, pluralizeTarget: true, pluralizeName: true, }, } as const; // Advanced Settings for all types // TODO: split this into settings based on the attribute type interface AdvancedAttributeSettings { required?: boolean; unique?: boolean; maximum?: number; minimum?: number; private?: boolean; default?: any; regexp?: string; condition?: { field: string; operator: 'is' | 'is not'; value: string | boolean; action: 'show' | 'hide'; }; } interface AddComponentAttribute extends AddAttribute { type: 'component'; } interface AddDynamicZoneAttribute extends AddAttribute { type: 'dz'; } interface AddRelationAttribute extends AddAttribute { type: 'relation'; } // Type guard function to check if an attribute is a ComponentAttribute function isComponentAttribute(attribute: AddAttribute): attribute is AddComponentAttribute { return attribute.type === 'component'; } function isDynamicZoneAttribute(attribute: AddAttribute): attribute is AddDynamicZoneAttribute { return attribute.type === 'dz'; } function isRelationAttribute(attribute: AddAttribute): attribute is AddRelationAttribute { return attribute.type === 'relation'; } type numberFormat = 'integer' | 'big integer' | 'decimal'; type dateFormat = 'date' | 'time' | 'datetime'; export interface CreateContentTypeOptions { name: string; singularId?: string; pluralId?: string; attributes: AddAttribute[]; } export interface CreateComponentOptions { name: string; icon: string; attributes: AddAttribute[]; categoryCreate?: string; categorySelect?: string; } export interface CategoryCreateOption { categoryCreate: string; categorySelect?: never; } export interface CategorySelectOption { categorySelect: string; categoryCreate?: never; } type AddComponentOptions = { repeatable: boolean; } & CreateComponentOptions; // lookup table for attribute types+subtypes so they can be found // buttonName is the header of the button clicked from the "Add Attribute" screen // listLabel is how they appear in the list of all attributes on the content type page // This is necessary because the labels used for each attribute type differ based on // their other attribute options export const typeMap = { text: { buttonName: 'Text', listLabel: 'Text' }, boolean: { buttonName: 'Boolean', listLabel: 'Boolean' }, blocks: { buttonName: 'Rich text (blocks)', listLabel: 'Rich text (blocks)' }, json: { buttonName: 'JSON', listLabel: 'JSON' }, number: { buttonName: 'Number', listLabel: 'Number' }, email: { buttonName: 'Email', listLabel: 'Email' }, date_date: { buttonName: 'Date', listLabel: 'Date' }, date_time: { buttonName: 'Date', listLabel: 'Time' }, date_datetime: { buttonName: 'Date', listLabel: 'Datetime' }, password: { buttonName: 'Password', listLabel: 'Password' }, media: { buttonName: 'Media', listLabel: 'Media' }, enumeration: { buttonName: 'Enumeration', listLabel: 'Enumeration' }, relation: { buttonName: 'Relation', listLabel: 'Relation' }, markdown: { buttonName: 'Rich text (Markdown)', listLabel: 'Rich text (Markdown)' }, component: { buttonName: 'Component', listLabel: 'Component' }, component_repeatable: { buttonName: 'Component', listLabel: 'Repeatable Component' }, dz: { buttonName: 'Dynamic Zone', listLabel: 'Dynamic Zone' }, }; const getAttributeIdentifiers = (attribute: AddAttribute) => { let type = attribute.type; if (attribute.component?.options?.repeatable) { type = 'component_repeatable'; } else if (attribute.date?.format) { type = 'date_' + attribute.date.format; } return typeMap[type]; }; // Select a component icon export const selectComponentIcon = async (page: Page, icon: string) => { // Test the search and avoiding needing to scroll to the icon const searchButton = page.getByRole('button', { name: 'Search icon button' }); await clickAndWait(page, searchButton); const searchInput = page.getByPlaceholder('Search for an icon'); await searchInput.fill(icon); // click the icon const iconResult = page.locator(`label:has(input[type="radio"][value="${icon}"])`); await clickAndWait(page, iconResult); // verify the correct icon was selected const isChecked = await iconResult.isChecked(); expect(isChecked).toBe(true); }; // open the component builder export const openComponentBuilder = async (page: Page) => { await clickAndWait(page, page.getByRole('link', { name: 'Content-Type Builder' })); await clickAndWait(page, page.getByRole('button', { name: 'Create new component' })); }; // The initial "create a component" screen from the content type builder nav // also supports "create a component" from within a dz export const fillCreateComponent = async (page: Page, options: Partial<CreateComponentOptions>) => { if (options.name) { const displayNameLocator = page.getByLabel('Display name'); if (await displayNameLocator.isVisible({ timeout: 0 })) { await displayNameLocator.fill(options.name); } else { const nameLocator = page.getByLabel('Name', { exact: true }); if (await nameLocator.isVisible({ timeout: 0 })) { await nameLocator.fill(options.name); } } } if (options.icon) { await selectComponentIcon(page, options.icon); } if (options.categoryCreate) { await page .getByLabel(/Select a category or enter a name to create a new one/i) .fill(options.categoryCreate); } if (options.categorySelect) { const displayName = kebabCase(options.categorySelect); await clickAndWait( page, page.getByLabel(/Select a category or enter a name to create a new one/i) ); await page.getByLabel(displayName).scrollIntoViewIfNeeded(); await clickAndWait(page, page.getByLabel(displayName)); } }; // The screen when a component is added as an attribute export const fillAddComponentAttribute = async ( page: Page, component: AddAttribute['component'] ) => { if (component.options.name) { await fillComponentName(page, component.options.name); } // if existing component, select it if (component.useExisting) { // open the list await page.getByRole('combobox', { name: 'component' }).click(); // select the item const item = page .locator('[role="presentation"] [role="option"]') .filter({ hasText: new RegExp(component.useExisting, 'i') }); await item.scrollIntoViewIfNeeded(); await item.click(); // close the select menu if (await page.getByText('component selected').isVisible({ timeout: 0 })) { await page.getByText('component selected').click({ force: true }); } } await selectComponentRepeatable(page, component.options.repeatable); }; function escapeRegExp(string: string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export const fillComponentName = async (page: Page, name: string) => { const displayNameLocator = page.getByLabel('Display name'); if (await displayNameLocator.isVisible({ timeout: 0 })) { await displayNameLocator.fill(name); } else { const nameLocator = page.getByLabel('Name', { exact: true }); if (await nameLocator.isVisible({ timeout: 0 })) { await nameLocator.fill(name); } } }; export const selectComponentRepeatable = async (page: Page, value: boolean) => { // Check if the "repeatable" options are present if (await page.locator('input[name="repeatable"]').first().isVisible({ timeout: 0 })) { const repeatableValue = value ? 'true' : 'false'; const radioButton = page.locator(`input[name="repeatable"][value="${repeatableValue}"]`); await radioButton.click({ force: true }); } }; function hasInverse(relation: AddAttribute['relation']): relation is AddAttribute['relation'] & { type: keyof typeof relationsMap; target: { name?: string; select?: string }; } { return relationsMap[relation?.type]?.hasInverse ?? false; } function isInverted(relation: AddAttribute['relation']): relation is AddAttribute['relation'] & { type: keyof typeof relationsMap; target: { name?: string; select?: string }; } { const relationType = relation?.type; if (!relationType) return false; const relationConfig = relationsMap[relationType]; return Boolean(relationConfig?.inverted); } export const addRelationAttribute = async ( page: Page, attribute: AddRelationAttribute, options?: AttributeOptions ) => { const { relation, name } = attribute; const target = relation?.target; const targetSelect = target?.select; const relationText = relationsMap[relation?.type]?.locatorText; // Click the correct relation type button // instead of using aria-label we need to use data-relation-type with the relation type itself await page.locator(`button[data-relation-type="${relation?.type}"]`).click(); // check that the button is now aria-pressed await expect(page.locator(`button[data-relation-type="${relation?.type}"]`)).toHaveAttribute( 'aria-pressed', 'true' ); // Select the relation type if `targetSelect` is provided const dialog = page.getByRole('dialog'); // Locate the dialog const relationTypePicker = dialog.locator('button[aria-haspopup="menu"]'); // Find the button inside it if (targetSelect) { await relationTypePicker.click(); await page.getByRole('menuitem', { name: targetSelect }).click(); } // Verify expected text in the relation type picker const expectedText = isInverted(relation) ? `${targetSelect} ${relationText}` : `${relationText} ${targetSelect}`; await expect(dialog).toContainText(expectedText); const nameFieldValue = await page.locator('input[name="name"]').inputValue(); const targetNameFieldValue = await page.locator('input[name="targetAttribute"]').inputValue(); // check that the name field is filled with the target name in the correct pluralization expect(nameFieldValue).toBe( snakeCase( relationsMap[relation?.type]?.pluralizeName ? pluralize(target?.select?.toLowerCase()) : target?.select?.toLowerCase() ) ); // verify the target field is filled with the correct pluralization if (options?.contentTypeName && hasInverse(relation)) { expect(targetNameFieldValue).toBe( snakeCase( relationsMap[relation?.type]?.pluralizeTarget ? pluralize(options.contentTypeName.toLowerCase()) : options.contentTypeName.toLowerCase() ) ); } // fill in target attribute or ensure it is disabled const targetAttributeInput = page.locator('input[name="targetAttribute"]'); if (hasInverse(relation)) { if (relation.target.name) { await targetAttributeInput.fill(relation.target.name); } } else { await expect(targetAttributeInput).toBeDisabled(); } // Fill in the "Name" field if provided if (name) { await page.locator('input[name="name"]').fill(name); } await clickAndWait(page, page.getByRole('button', { name: 'Finish' })); }; export const addComponentAttribute = async ( page: Page, attribute: AddComponentAttribute, options: any = {} ) => { const attrCompOptions = attribute.component.options; const useExistingLabel = attribute.component.useExisting ? 'false' : 'true'; await page.click(`label[for="${useExistingLabel}"]`); // if "select a component" if (await page.getByRole('button', { name: 'Select a component' }).isVisible({ timeout: 0 })) { await clickAndWait(page, page.getByRole('button', { name: 'Select a component' })); await fillAddComponentAttribute(page, attribute.component); } // if "configure a component" else if ( await page.getByRole('button', { name: 'Configure the component' }).isVisible({ timeout: 0 }) ) { await fillCreateComponent(page, { ...attrCompOptions, name: attribute.name }); await clickAndWait(page, page.getByRole('button', { name: 'Configure the component' })); } // if using an existing component else if (attribute.component.useExisting) { await fillAddComponentAttribute(page, attribute.component); } //?? else { await fillCreateComponent(page, { ...attrCompOptions, name: attribute.name }); } await fillComponentName(page, attribute.name); await selectComponentRepeatable(page, attribute.component?.options.repeatable); if (attrCompOptions.attributes) { const addFirstFieldButton = page.getByRole('button', { name: new RegExp('Add first field to the component', 'i'), }); const addAnotherFieldButton = page.getByRole('button', { name: new RegExp('Add another field', 'i'), }); if (await addFirstFieldButton.isVisible({ timeout: 0 })) { await clickAndWait(page, addFirstFieldButton); } else if (await addAnotherFieldButton.isVisible({ timeout: 0 })) { await clickAndWait(page, addAnotherFieldButton); } await addAttributes(page, attrCompOptions.attributes, { clickFinish: false, ...options }); // Inner addAttributes skips Finish on the last nested field when clickFinish is false; submit the component form here. await clickAndWait(page, page.getByRole('button', { name: 'Finish' })); } }; export const addDynamicZoneAttribute = async (page: Page, attribute: AddDynamicZoneAttribute) => { // Fill the name of the dynamic zone await page.getByLabel('Name', { exact: true }).fill(attribute.name); // Click the "Add components to the zone" button to start adding components await clickAndWait( page, page.getByRole('button', { name: new RegExp('Add components to the zone', 'i') }) ); // Creating a DZ does not show a Finish control on the DZ modal (see FormModalEndActions); the // add-component-to-DZ flow closes the modal when the component is done. Skip the outer Finish. await addAttributes(page, attribute.dz.components, { fromDz: attribute.name, clickFinish: false, }); }; // Add contentTypeName to options interface interface AttributeOptions { fromDz?: string; contentTypeName?: string; clickFinish?: boolean; } export const fillAttribute = async ( page: Page, attribute: AddAttribute, options?: AttributeOptions ) => { // check if we need to click the attribute button or if we're already on the attribute to fill const onFieldTypeSelection = await page .getByRole('heading', { name: /Select a field for your/i }) .isVisible({ timeout: 0 }); if (onFieldTypeSelection) { const tabPanel = page.getByRole('tabpanel'); // Target a button within tabPanel that contains a span with the exact text of attribute.type await clickAndWait( page, tabPanel.locator(`button:has(span)`, { hasText: new RegExp(`^${escapeRegExp(getAttributeIdentifiers(attribute).buttonName)}`, 'i'), }) ); } // components are handled separately if (isComponentAttribute(attribute)) { return await addComponentAttribute(page, attribute, options); } else if (isDynamicZoneAttribute(attribute)) { return await addDynamicZoneAttribute(page, attribute); } else if (isRelationAttribute(attribute)) { return await addRelationAttribute(page, attribute, options); } // Fill the input with the exact label "Name" await page.getByLabel('Name', { exact: true }).fill(attribute.name); // TODO: add a tool for handling Strapi pseudo-select lists so we don't have to handle it custom (and error-prone) each time like number and date if (attribute.number?.format) { const format = attribute.number.format; const list = page.getByText('Choose here', { exact: true }).first(); // open the list await clickAndWait(page, list); // click the targeted element await clickAndWait(page, page.getByText(new RegExp('^' + format, 'i')).first()); } if (attribute.date?.format) { const format = attribute.date.format; // open the list const list = page.getByText('Choose here', { exact: true }).first(); await clickAndWait(page, list); // select the item await clickAndWait(page, page.getByText(new RegExp('^' + format, 'i')).first()); } if (attribute.media?.multiple !== undefined) { // TODO: there has to be a better way; if not, improve the html so we can target better const multipleValue = attribute.media.multiple ? 'true' : 'false'; await clickAndWait(page, page.locator(`label[for="${multipleValue}"]`)); } if (attribute.enumeration?.values) { await page.locator('textarea[name="enum"]').fill(attribute.enumeration?.values.join('\n')); } if (attribute.advanced) { const advanced = attribute.advanced; await page.getByText('Advanced Settings').click(); if (isBoolean(advanced.required)) { const checkbox = page.getByRole('checkbox', { name: 'Required field' }); await ensureCheckbox(checkbox, advanced.required); } if (isString(advanced.regexp)) { await page.getByLabel('Regexp').fill(advanced.regexp); } if (isBoolean(advanced.unique)) { const checkbox = page.getByRole('checkbox', { name: 'Unique field' }); await ensureCheckbox(checkbox, advanced.unique); } if (isBoolean(advanced.private)) { const checkbox = page.getByRole('checkbox', { name: 'Private field' }); await ensureCheckbox(checkbox, advanced.private); } if (isNumber(advanced.maximum)) { const checkbox = page.getByRole('checkbox', { name: 'Maximum value' }); await ensureCheckbox(checkbox, true); await page.getByRole('textbox', { name: 'Maximum value' }).fill(advanced.maximum.toString()); } if (isNumber(advanced.minimum)) { const checkbox = page.getByRole('checkbox', { name: 'Minimum value' }); await ensureCheckbox(checkbox, true); await page.getByRole('textbox', { name: 'Minimum value' }).fill(advanced.minimum.toString()); } if (isString(advanced.default)) { await page.getByLabel('Default').fill(advanced.default); } if (advanced.condition) { const { field, operator, value, action } = advanced.condition; await page.getByRole('button', { name: 'Apply condition' }).click(); await page.locator('[name="conditions.field"]').click(); await page.getByRole('option', { name: field }).click(); await page.locator('[name="conditions.operator"]').click(); await page.getByRole('option', { name: operator, exact: true }).click(); await page.locator('[name="conditions.value"]').click(); await page.getByRole('option', { name: value.toString() }).click(); await page.locator('[name="conditions.action"]').click(); await page .getByRole('option') .filter({ hasText: new RegExp(`${action}`, 'i') }) .first() .click(); } } }; export const addAttributes = async ( page: Page, attributes: AddAttribute[], options?: AttributeOptions ) => { for (let i = 0; i < attributes.length; i++) { const attribute = attributes[i]; await fillAttribute(page, attribute, options); if (i < attributes.length - 1) { if (options?.fromDz) { // Locate the row containing the DZ name const dzRow = page.locator('div').filter({ hasText: options.fromDz }).first(); // Locate the next sibling row and find the "Add a component" button const nextRow = dzRow.locator('xpath=following-sibling::tr[1]'); const addComponentButton = nextRow.locator('button:has-text("Add a component")'); // Click the button await clickAndWait(page, addComponentButton); } else { // Regular attribute: click 'Add Another Field' await clickAndWait( page, page.getByRole('button', { name: new RegExp('^Add Another Field$', 'i'), exact: true }) ); } } else if (options?.clickFinish !== false) { // Some attribute flows finalize themselves and leave no "Finish" on the CT field list: // - relation: addRelationAttribute clicks Finish internally, // - new component: addComponentAttribute clicks Finish once its first field is added, // - dynamic zone: adding its components closes the modal. // Others (e.g. an existing `useExisting` component) do NOT, so the outer Finish is still // required. Rather than encode every case, click Finish only when it is actually present. const finishButton = page.getByRole('button', { name: 'Finish' }); if (await finishButton.isVisible({ timeout: 1000 })) { await clickAndWait(page, finishButton); } } } }; const saveAndVerifyContent = async ( page: Page, options: { name: string; attributes: AddAttribute[] } ) => { await clickAndWait(page, page.getByRole('button', { name: 'Save' })); await waitForRestart(page); // This must be case insensitive to cover up a minor display bug in the Strapi Admin where headers are always capitalized const header = page.getByRole('heading', { name: new RegExp(`^${options.name}$`, 'i'), }); await expect(header).toBeVisible(); for (let i = 0; i < options.attributes.length; i++) { const attribute = options.attributes[i]; const name = attribute.name || attribute.component?.options.name; const row = await page.getByLabel(name); if (!getAttributeIdentifiers(attribute).buttonName) { throw new Error('unknown type ' + attribute.type); } const { listLabel } = getAttributeIdentifiers(attribute); await expect(row).toContainText(listLabel, { ignoreCase: true, }); } // TODO: verify that it appears in the sidenav }; // Refactored method for creating a component export const createComponent = async (page: Page, options: CreateComponentOptions) => { await openComponentBuilder(page); await fillCreateComponent(page, options); await clickAndWait(page, page.getByRole('button', { name: 'Continue' })); await clickAndWait(page, page.getByRole('button', { name: 'Add new field' }).first()); await addAttributes(page, options.attributes, { contentTypeName: options.name }); await saveAndVerifyContent(page, options); }; // Helper function for creating content types const createContentType = async ( page: Page, options: CreateContentTypeOptions, type: 'single' | 'collection' ) => { const { name, singularId, pluralId } = options; const buttonName = type === 'single' ? 'Create new single type' : 'Create new collection type'; const headingName = type === 'single' ? 'Create a single type' : 'Create a collection type'; await page.getByRole('button', { name: buttonName }).click(); await expect(page.getByRole('heading', { name: headingName })).toBeVisible(); const displayName = page.getByLabel('Display name'); await displayName.fill(name); const singularIdField = page.getByLabel('API ID (Singular)'); await expect(singularIdField).toHaveValue(singularId || kebabCase(name)); if (singularId) { await singularIdField.fill(singularId); } const pluralIdField = page.getByLabel('API ID (Plural)'); await expect(pluralIdField).toHaveValue(pluralId || pluralize(kebabCase(name))); if (pluralId) { await pluralIdField.fill(pluralId); } await page.getByRole('button', { name: 'Continue' }).click(); await clickAndWait(page, page.getByRole('button', { name: 'Add new field' }).first()); await addAttributes(page, options.attributes, { contentTypeName: name }); await saveAndVerifyContent(page, options); }; // Refactored method for creating a single type export const createSingleType = async (page: Page, options: CreateContentTypeOptions) => { await createContentType(page, options, 'single'); }; // Refactored method for creating a collection type export const createCollectionType = async (page: Page, options: CreateContentTypeOptions) => { await createContentType(page, options, 'collection'); }; export const addAttributeToComponent = async ( page: Page, componentName: string, attribute: AddAttribute ) => { await clickAndWait(page, page.getByRole('link', { name: 'Content-Type Builder' })); await clickAndWait(page, page.getByRole('link', { name: componentName })); await clickAndWait( page, page.getByRole('button', { name: 'Add another field to this component' }) ); await addAttributes(page, [attribute]); await saveAndVerifyContent(page, { name: componentName, attributes: [attribute], }); }; export const addAttributesToContentType = async ( page: Page, ctName: string, attributes: AddAttribute[] ) => { await navToHeader(page, ['Content-Type Builder', ctName], ctName); await clickAndWait(page, page.getByRole('button', { name: 'Add another field', exact: true })); await addAttributes(page, attributes, { contentTypeName: ctName }); await page.getByRole('button', { name: 'Save' }).click(); await waitForRestart(page); }; export const removeAttributeFromComponent = async ( page: Page, componentName: string, attributeName: string ) => { await clickAndWait(page, page.getByRole('link', { name: 'Content-Type Builder' })); await clickAndWait(page, page.getByRole('link', { name: componentName })); await clickAndWait(page, page.getByRole('button', { name: 'Delete ' + attributeName })); await saveAndVerifyContent(page, { name: componentName, attributes: [] }); }; export const deleteComponent = async (page: Page, componentName: string) => { await clickAndWait(page, page.getByRole('link', { name: 'Content-Type Builder' })); await clickAndWait(page, page.getByRole('link', { name: componentName })); await clickAndWait(page, page.getByRole('button', { name: 'Edit', exact: true })); // need to accept the browser modal page.on('dialog', (dialog) => dialog.accept()); await clickAndWait(page, page.getByRole('button', { name: 'Delete', exact: true })); await clickAndWait(page, page.getByRole('button', { name: 'Save' })); await waitForRestart(page); };