enhancement(ctb): add aria data to relations buttons (#23076)

* test(e2e): add test for creating relations in ctb (#23078)
This commit is contained in:
Ben Irvin 2025-03-10 14:20:56 +01:00 committed by GitHub
parent 0ce7d7ba34
commit 5017d5e420
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 484 additions and 70 deletions

View File

@ -108,8 +108,11 @@ export const RelationNaturePicker = ({
}}
padding={2}
type="button"
aria-label={formatMessage({ id: getTrad(`relation.${relation}`) })}
aria-pressed={relationType === relation}
data-relation-type={relation}
>
<Asset key={relation} />
<Asset key={relation} aria-hidden="true" />
</IconWrapper>
);
})}

View File

@ -25,28 +25,121 @@ test.describe('Create collection type with all field types', () => {
await resetFiles();
});
test('Can create a collection type with all field types (except relations)', async ({ page }) => {
const advancedRequired = { required: true };
const advancedRegex = { required: true, regexp: '^(?!.*fail).*' };
test('Can create a collection type with all field types', async ({ page }) => {
const attributes: AddAttribute[] = [
{ type: 'text', name: 'testtext' },
{ type: 'boolean', name: 'testboolean' },
{ type: 'blocks', name: 'testblocks' },
{ type: 'json', name: 'testjson' },
{ type: 'number', name: 'testinteger', number: { format: 'integer' } },
{ type: 'number', name: 'testbiginteger', number: { format: 'big integer' } },
{ type: 'number', name: 'testdecimal', number: { format: 'decimal' } },
{ type: 'email', name: 'testemail' },
{ type: 'date', name: 'testdateonlydate', date: { format: 'date' } },
{ type: 'date', name: 'testdatetime', date: { format: 'time' } },
{ type: 'date', name: 'testdatedatetime', date: { format: 'datetime' } },
{ type: 'password', name: 'testpassword' },
{ type: 'media', name: 'testmediasingle', media: { multiple: false } },
{ type: 'media', name: 'testmediamultiple', media: { multiple: true } },
{ type: 'text', name: 'testtext', advanced: advancedRegex },
{ type: 'boolean', name: 'testboolean', advanced: advancedRequired },
{ type: 'blocks', name: 'testblocks', advanced: advancedRequired },
{ type: 'json', name: 'testjson', advanced: advancedRequired },
{
type: 'number',
name: 'testinteger',
number: { format: 'integer' },
advanced: advancedRequired,
},
{
type: 'number',
name: 'testbiginteger',
number: { format: 'big integer' },
advanced: advancedRequired,
},
{
type: 'number',
name: 'testdecimal',
number: { format: 'decimal' },
advanced: advancedRequired,
},
{ type: 'email', name: 'testemail', advanced: advancedRequired },
{
type: 'date',
name: 'testdateonlydate',
date: { format: 'date' },
advanced: advancedRequired,
},
{ type: 'date', name: 'testdatetime', date: { format: 'time' }, advanced: advancedRequired },
{
type: 'date',
name: 'testdatedatetime',
date: { format: 'datetime' },
advanced: advancedRequired,
},
{ type: 'password', name: 'testpassword', advanced: advancedRequired },
{
type: 'media',
name: 'testmediasingle',
media: { multiple: false },
advanced: advancedRequired,
},
{
type: 'media',
name: 'testmediamultiple',
media: { multiple: true },
advanced: advancedRequired,
},
{
type: 'relation',
name: 'testonewayrelation',
relation: {
type: 'oneWay',
target: { select: 'Article', name: 'testonewayrelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'relation',
name: 'testonetoonerelation',
relation: {
type: 'oneToOne',
target: { select: 'Article', name: 'testonetoonerelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'relation',
name: 'testonetomanyrelation',
relation: {
type: 'oneToMany',
target: { select: 'Article', name: 'testonetomanyrelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'relation',
name: 'testmanytoonerelation',
relation: {
type: 'manyToOne',
target: { select: 'Article', name: 'testmanytoonerelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'relation',
name: 'testmanytomanyrelation',
relation: {
type: 'manyToMany',
target: { select: 'Article', name: 'testmanytomanyrelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'relation',
name: 'testmanywayrelation',
relation: {
type: 'manyWay',
target: { select: 'Article', name: 'testmanywayrelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'enumeration',
name: 'testenumeration',
enumeration: { values: ['first', 'second', 'third'] },
advanced: advancedRequired,
},
{ type: 'markdown', name: 'testmarkdown' },
{ type: 'markdown', name: 'testmarkdown', advanced: advancedRequired },
// New single component with a new category
{
type: 'component',
@ -57,7 +150,13 @@ test.describe('Create collection type with all field types', () => {
name: 'testnewcomponentnewcategory',
icon: 'alien',
categoryCreate: 'testcategory',
attributes: [{ type: 'text', name: 'testnewcompotext' }],
attributes: [
{
type: 'text',
name: 'testnewcompotext',
advanced: advancedRegex,
},
],
},
},
},
@ -71,7 +170,13 @@ test.describe('Create collection type with all field types', () => {
name: 'testnewcomponentrepeatable',
icon: 'moon',
categorySelect: 'testcategory',
attributes: [{ type: 'text', name: 'testexistingcompotext' }],
attributes: [
{
type: 'text',
name: 'testexistingcompotext',
advanced: advancedRegex,
},
],
},
},
},
@ -104,7 +209,13 @@ test.describe('Create collection type with all field types', () => {
name: 'testnewcomponentnewcategory',
icon: 'paint',
categoryCreate: 'testcategory',
attributes: [{ type: 'text', name: 'testdzcompotext' }],
attributes: [
{
type: 'text',
name: 'testdzcompotext',
advanced: advancedRegex,
},
],
},
},
},

View File

@ -37,30 +37,85 @@ test.describe('Create a new component', () => {
await createComponent(page, options);
});
test('Can create a component with every attribute type permutation (except relations)', async ({
page,
}) => {
const attributes = [
{ type: 'text', name: 'testtext' },
{ type: 'boolean', name: 'testboolean' },
{ type: 'blocks', name: 'testblocks' },
{ type: 'json', name: 'testjson' },
{ type: 'number', name: 'testinteger', number: { format: 'integer' } },
{ type: 'number', name: 'testbiginteger', number: { format: 'big integer' } },
{ type: 'number', name: 'testdecimal', number: { format: 'decimal' } },
{ type: 'email', name: 'testemail' },
{ type: 'date', name: 'testdateonlydate', date: { format: 'date' } },
{ type: 'date', name: 'testdatetime', date: { format: 'time' } },
{ type: 'date', name: 'testdatedatetime', date: { format: 'datetime' } },
{ type: 'password', name: 'testpassword' },
{ type: 'media', name: 'testmediasingle', media: { multiple: false } },
{ type: 'media', name: 'testmediamultiple', media: { multiple: true } },
const advancedRequired = { required: true };
const advancedRegex = { required: true, regexp: '^(?!.*fail).*' };
test('Can create a component with all field types', async ({ page }) => {
const attributes: AddAttribute[] = [
{ type: 'text', name: 'testtext', advanced: advancedRegex },
{ type: 'boolean', name: 'testboolean', advanced: advancedRequired },
{ type: 'blocks', name: 'testblocks', advanced: advancedRequired },
{ type: 'json', name: 'testjson', advanced: advancedRequired },
{
type: 'number',
name: 'testinteger',
number: { format: 'integer' },
advanced: advancedRequired,
},
{
type: 'number',
name: 'testbiginteger',
number: { format: 'big integer' },
advanced: advancedRequired,
},
{
type: 'number',
name: 'testdecimal',
number: { format: 'decimal' },
advanced: advancedRequired,
},
{ type: 'email', name: 'testemail', advanced: advancedRequired },
{
type: 'date',
name: 'testdateonlydate',
date: { format: 'date' },
advanced: advancedRequired,
},
{ type: 'date', name: 'testdatetime', date: { format: 'time' }, advanced: advancedRequired },
{
type: 'date',
name: 'testdatedatetime',
date: { format: 'datetime' },
advanced: advancedRequired,
},
{ type: 'password', name: 'testpassword', advanced: advancedRequired },
{
type: 'media',
name: 'testmediasingle',
media: { multiple: false },
advanced: advancedRequired,
},
{
type: 'media',
name: 'testmediamultiple',
media: { multiple: true },
advanced: advancedRequired,
},
{
type: 'relation',
name: 'comptestonewayrelation',
relation: {
type: 'oneWay',
target: { select: 'Article', name: 'comptestonewayrelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'relation',
name: 'comptestmanywayrelation',
relation: {
type: 'manyWay',
target: { select: 'Article', name: 'comptestmanywayrelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'enumeration',
name: 'testenumeration',
enumeration: { values: ['first', 'second', 'third'] },
advanced: advancedRequired,
},
{ type: 'markdown', name: 'testmarkdown' },
{ type: 'markdown', name: 'testmarkdown', advanced: advancedRequired },
// new single component with new category
{
type: 'component',
@ -103,9 +158,7 @@ test.describe('Create a new component', () => {
},
},
},
// TODO: test relations
// { type: 'relation', name: 'testrelation' },
] satisfies AddAttribute[];
];
const options = {
name: 'ArticlesComponent',

View File

@ -21,6 +21,7 @@ test.describe('Update a new component', () => {
const addedAttribute = {
type: 'text',
name: 'addedtext',
advanced: { required: true, regexp: '^(?!.*fail).*' },
};
const componentAttributeName = 'mycomponentname';

View File

@ -29,28 +29,85 @@ test.describe('Create single type with all field types', () => {
await resetFiles();
});
test('Can create a collection type with all field types (except relations)', async ({ page }) => {
const advancedRequired = { required: true };
const advancedRegex = { required: true, regexp: '^(?!.*fail).*' };
test('Can create a collection type with all field types', async ({ page }) => {
const attributes: AddAttribute[] = [
{ type: 'text', name: 'testtext' },
{ type: 'boolean', name: 'testboolean' },
{ type: 'blocks', name: 'testblocks' },
{ type: 'json', name: 'testjson' },
{ type: 'number', name: 'testinteger', number: { format: 'integer' } },
{ type: 'number', name: 'testbiginteger', number: { format: 'big integer' } },
{ type: 'number', name: 'testdecimal', number: { format: 'decimal' } },
{ type: 'email', name: 'testemail' },
{ type: 'date', name: 'testdateonlydate', date: { format: 'date' } },
{ type: 'date', name: 'testdatetime', date: { format: 'time' } },
{ type: 'date', name: 'testdatedatetime', date: { format: 'datetime' } },
{ type: 'password', name: 'testpassword' },
{ type: 'media', name: 'testmediasingle', media: { multiple: false } },
{ type: 'media', name: 'testmediamultiple', media: { multiple: true } },
{ type: 'text', name: 'testtext', advanced: advancedRegex },
{ type: 'boolean', name: 'testboolean', advanced: advancedRequired },
{ type: 'blocks', name: 'testblocks', advanced: advancedRequired },
{ type: 'json', name: 'testjson', advanced: advancedRequired },
{
type: 'number',
name: 'testinteger',
number: { format: 'integer' },
advanced: advancedRequired,
},
{
type: 'number',
name: 'testbiginteger',
number: { format: 'big integer' },
advanced: advancedRequired,
},
{
type: 'number',
name: 'testdecimal',
number: { format: 'decimal' },
advanced: advancedRequired,
},
{ type: 'email', name: 'testemail', advanced: advancedRequired },
{
type: 'date',
name: 'testdateonlydate',
date: { format: 'date' },
advanced: advancedRequired,
},
{ type: 'date', name: 'testdatetime', date: { format: 'time' }, advanced: advancedRequired },
{
type: 'date',
name: 'testdatedatetime',
date: { format: 'datetime' },
advanced: advancedRequired,
},
{ type: 'password', name: 'testpassword', advanced: advancedRequired },
{
type: 'media',
name: 'testmediasingle',
media: { multiple: false },
advanced: advancedRequired,
},
{
type: 'media',
name: 'testmediamultiple',
media: { multiple: true },
advanced: advancedRequired,
},
{
type: 'relation',
name: 'testonewayrelation',
relation: {
type: 'oneWay',
target: { select: 'Article', name: 'testonewayrelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'relation',
name: 'testmanywayrelation',
relation: {
type: 'manyWay',
target: { select: 'Article', name: 'testmanywayrelationtarget' },
},
advanced: advancedRequired,
},
{
type: 'enumeration',
name: 'testenumeration',
enumeration: { values: ['first', 'second', 'third'] },
advanced: advancedRequired,
},
{ type: 'markdown', name: 'testmarkdown' },
{ type: 'markdown', name: 'testmarkdown', advanced: advancedRequired },
// New single component with a new category
{
type: 'component',
@ -61,7 +118,13 @@ test.describe('Create single type with all field types', () => {
name: 'testnewcomponentnewcategory',
icon: 'alien',
categoryCreate: 'testcategory',
attributes: [{ type: 'text', name: 'testnewcompotext' }],
attributes: [
{
type: 'text',
name: 'testnewcompotext',
advanced: { required: true, regexp: '^(?!.*fail).*' },
},
],
},
},
},
@ -75,7 +138,13 @@ test.describe('Create single type with all field types', () => {
name: 'testnewcomponentrepeatable',
icon: 'moon',
categorySelect: 'testcategory',
attributes: [{ type: 'text', name: 'testexistingcompotext' }],
attributes: [
{
type: 'text',
name: 'testexistingcompotext',
advanced: { required: true, regexp: '^(?!.*fail).*' },
},
],
},
},
},
@ -108,7 +177,13 @@ test.describe('Create single type with all field types', () => {
name: 'testnewcomponentnewcategory',
icon: 'paint',
categoryCreate: 'testcategory',
attributes: [{ type: 'text', name: 'testdzcompotext' }],
attributes: [
{
type: 'text',
name: 'testdzcompotext',
advanced: { required: true, regexp: '^(?!.*fail).*' },
},
],
},
},
},

View File

@ -1,12 +1,12 @@
import { isBoolean, isNumber, isString, kebabCase } from 'lodash/fp';
import { isBoolean, isNumber, isString, kebabCase, snakeCase } from 'lodash/fp';
import { waitForRestart } from './restart';
import pluralize from 'pluralize';
import { expect, Locator, type Page } from '@playwright/test';
import { expect, type Page } from '@playwright/test';
import { clickAndWait, ensureCheckbox, findByRowColumn, navToHeader } from './shared';
export interface AddAttribute {
type: string;
name: string;
name?: string;
advanced?: AdvancedAttributeSettings;
number?: { format: numberFormat };
date?: { format: dateFormat };
@ -16,8 +16,67 @@ export interface AddAttribute {
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 {
@ -38,6 +97,10 @@ 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';
@ -46,7 +109,9 @@ function isDynamicZoneAttribute(attribute: AddAttribute): attribute is AddDynami
return attribute.type === 'dz';
}
// Enumeration needs "values"
function isRelationAttribute(attribute: AddAttribute): attribute is AddRelationAttribute {
return attribute.type === 'relation';
}
type numberFormat = 'integer' | 'big integer' | 'decimal';
type dateFormat = 'date' | 'time' | 'datetime';
@ -230,6 +295,99 @@ export const selectComponentRepeatable = async (page: Page, value: boolean) => {
}
};
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 page.getByRole('button', { name: 'Finish' }).click();
};
export const addComponentAttribute = async (
page: Page,
attribute: AddComponentAttribute,
@ -305,7 +463,18 @@ export const addDynamicZoneAttribute = async (page: Page, attribute: AddDynamicZ
}
};
export const fillAttribute = async (page: Page, attribute: AddAttribute, options?: any) => {
// 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 })
@ -327,6 +496,8 @@ export const fillAttribute = async (page: Page, attribute: AddAttribute, options
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"
@ -404,7 +575,7 @@ export const fillAttribute = async (page: Page, attribute: AddAttribute, options
export const addAttributes = async (
page: Page,
attributes: AddAttribute[],
options?: { fromDz?: string } // fromDz is now a string for DZ name
options?: AttributeOptions
) => {
for (let i = 0; i < attributes.length; i++) {
const attribute = attributes[i];
@ -473,7 +644,7 @@ export const createComponent = async (page: Page, options: CreateComponentOption
await fillCreateComponent(page, options);
await clickAndWait(page, page.getByRole('button', { name: 'Continue' }));
await addAttributes(page, options.attributes);
await addAttributes(page, options.attributes, { contentTypeName: options.name });
await saveAndVerifyContent(page, options);
};
@ -508,7 +679,7 @@ const createContentType = async (
}
await page.getByRole('button', { name: 'Continue' }).click();
await addAttributes(page, options.attributes);
await addAttributes(page, options.attributes, { contentTypeName: name });
await saveAndVerifyContent(page, options);
};
@ -551,7 +722,7 @@ export const addAttributesToContentType = async (
await clickAndWait(page, page.getByRole('button', { name: 'Add another field', exact: true }));
await addAttributes(page, attributes);
await addAttributes(page, attributes, { contentTypeName: ctName });
await page.getByRole('button', { name: 'Save' }).click();