Files
strapi/tests/utils/content-types.ts

794 lines
27 KiB
TypeScript

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 &&
// Creating a DZ closes the modal when components are added; there is no Finish on the CT field list.
!isDynamicZoneAttribute(attribute)
) {
await clickAndWait(page, page.getByRole('button', { name: 'Finish' }));
}
}
};
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);
};