Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/private-apis/src/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const CORE_MODULES_USING_PRIVATE_APIS = [
'@wordpress/upload-media',
'@wordpress/global-styles-ui',
'@wordpress/ui',
'@wordpress/user-post-types',
'@wordpress/user-taxonomies',
'@wordpress/views',
];

Expand Down
4 changes: 3 additions & 1 deletion packages/user-post-types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
"@wordpress/notices": "file:../notices",
"@wordpress/ui": "file:../ui"
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/ui": "file:../ui",
"@wordpress/url": "file:../url"
},
"publishConfig": {
"access": "public"
Expand Down
86 changes: 84 additions & 2 deletions packages/user-post-types/src/fields/general.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
/**
* WordPress dependencies
*/
import { privateApis as componentsPrivateApis } from '@wordpress/components';
import { store as coreStore } from '@wordpress/core-data';
import { resolveSelect, useSelect } from '@wordpress/data';
import { useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import type { Field, Form } from '@wordpress/dataviews';
import type {
DataFormControlProps,
Field,
FieldValidity,
Form,
} from '@wordpress/dataviews';
// eslint-disable-next-line @wordpress/use-recommended-components -- Used here because it supports rendering as a `span` via the `render` prop to avoid invalid HTML.
import { Badge, Notice, Stack } from '@wordpress/ui';
import { cleanForSlug } from '@wordpress/url';

/**
* Internal dependencies
*/
import { unlock } from '../lock-unlock';
import { SUPPORT_FEATURES, usePublicTaxonomies } from '../utils';
import type { PostTypeFormData, SupportFeature } from '../types';

const { ValidatedInputControl } = unlock( componentsPrivateApis );

const SUPPORT_LABELS: Record< SupportFeature, string > = {
title: __( 'Title' ),
editor: __( 'Editor' ),
Expand Down Expand Up @@ -203,6 +213,23 @@ export const statusField: Field< PostTypeFormData > = {
enableSorting: false,
};

const SLUG_MAX_LENGTH = 20;
// Slug field has required + pattern + maxLength + a custom (slug-taken) check.
// Surface them in priority order: structural rules first, async slug-taken last.
// `required` only overrides the native browser message when our rule supplies
// one of its own.
function getSlugCustomValidity( validity?: FieldValidity ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we look at reusing some of those when they're literally the same between post types and taxonomies?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah.. We'll probably need a shared content-types package or something that might contain both user taxonomies and post types. I updated the tracking issue with this.

if ( validity?.required?.message ) {
return validity.required;
}
if ( validity?.pattern ) {
return validity.pattern;
}
if ( validity?.maxLength ) {
return validity.maxLength;
}
return validity?.custom;
}
export function useSlugField(
originalSlug?: string,
currentValue?: string
Expand Down Expand Up @@ -239,7 +266,8 @@ export function useSlugField(
),
isValid: {
required: true,
pattern: '^[a-z0-9_\\-]{1,20}$',
pattern: '^[a-z0-9_\\-]+$',
maxLength: SLUG_MAX_LENGTH,
custom: async ( value: PostTypeFormData ) => {
const slug = value.slug;
if ( originalSlug !== undefined && slug === originalSlug ) {
Expand All @@ -266,6 +294,60 @@ export function useSlugField(
: null;
},
},
Edit: function SlugEdit( {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire component also looks pretty reusable, would be nice to see how to avoid the duplication

data,
field,
onChange,
hideLabelFromVision,
markWhenOptional,
validity,
}: DataFormControlProps< PostTypeFormData > ) {
const { label, description, getValue, setValue, isValid } =
field;
const value =
( getValue( { item: data } ) as string | undefined ) ?? '';
const handleChange = ( newValue: string ) =>
onChange( setValue( { item: data, value: newValue } ) );
const onFocus = () => {
if ( data.id !== undefined || data.slug ) {
return;
}
const singular = data.config.labels.singular_name?.trim();
if ( ! singular ) {
return;
}
const cleaned = cleanForSlug( singular );
// On a fresh record fill the input from the singular label.
// Skip auto-fill if cleanForSlug retained non-ASCII to match
// the server's sanitize_key charset.
if ( /[^a-z0-9_-]/.test( cleaned ) ) {
return;
}
const trimmed = cleaned
.slice( 0, SLUG_MAX_LENGTH )
// Slicing can introduce a trailing hyphen — strip it.
.replace( /-+$/, '' );
if ( trimmed ) {
handleChange( trimmed );
}
Comment on lines +311 to +332

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the issues of this logic is that if we use a singular label matching an existing post type (like Post, it will preflll the slug with post and error out since it exists. We might need to improve that handling so we don't suggest a slug that matches an existing one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added it in the tracking issue for a follow up.

@im3dabasia im3dabasia May 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can solve this in one of two ways. First, we can validate before suggesting a slug. This way, if the slug that is about to be suggested is already registered, we can simply leave it blank for the user to fill in.

The second option, which I feel is more practical, is to suffix the suggested slug with a number if it already exists. For example, post could become post-n. This would require multiple validations because, if post has already been used five times, we would end up checking post, post-2, post-3, post-4, and finally post-5, which is what we would eventually suggest to the user. This approach would require us to determine the next available suffix number. Ref

Personally, I would choose the second option if we must suggest a slug. Otherwise, a simpler approach would be to leave it blank and let the user input the slug manually, while we validate it each time they type one in. This would be cleaner to implement, though the UX implications might need some consideration.

I am looking forward to hearing your thoughts. Once we reach some consensus, I would be happy to implement this.

cc: @ntsekouras @tyxla

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@im3dabasia I see you've opened a PR for that, let's discuss there: #78341

};
return (
<ValidatedInputControl
__next40pxDefaultSize
required={ !! isValid.required }
markWhenOptional={ markWhenOptional }
customValidity={ getSlugCustomValidity( validity ) }
label={ label }
value={ value }
help={ description }
onChange={ handleChange }
onFocus={ onFocus }
hideLabelFromVision={ hideLabelFromVision }
pattern={ isValid.pattern?.constraint }
maxLength={ isValid.maxLength?.constraint }
/>
);
},
filterBy: false,
enableSorting: false,
} ),
Expand Down
10 changes: 10 additions & 0 deletions packages/user-post-types/src/lock-unlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* WordPress dependencies
*/
import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';

export const { lock, unlock } =
__dangerousOptInToUnstableAPIsOnlyForCoreModules(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a shame we need to do this solely for using ValidatedInputControl.

@mirka are we planning to stabilize the validated form components soon?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the validated components are currently mainly used through @wordpress/dataviews, I was hoping to keep them private, and replace them with validated versions of @wordpress/ui form components when they're available.

'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
'@wordpress/user-post-types'
);
4 changes: 3 additions & 1 deletion packages/user-post-types/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
{ "path": "../i18n" },
{ "path": "../icons" },
{ "path": "../notices" },
{ "path": "../ui" }
{ "path": "../private-apis" },
{ "path": "../ui" },
{ "path": "../url" }
]
}
4 changes: 3 additions & 1 deletion packages/user-taxonomies/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
"@wordpress/notices": "file:../notices",
"@wordpress/ui": "file:../ui"
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/ui": "file:../ui",
"@wordpress/url": "file:../url"
},
"publishConfig": {
"access": "public"
Expand Down
86 changes: 84 additions & 2 deletions packages/user-taxonomies/src/fields/general.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
/**
* WordPress dependencies
*/
import { privateApis as componentsPrivateApis } from '@wordpress/components';
import { store as coreStore } from '@wordpress/core-data';
import { resolveSelect, useSelect } from '@wordpress/data';
import { useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import type { Field, Form } from '@wordpress/dataviews';
import type {
DataFormControlProps,
Field,
FieldValidity,
Form,
} from '@wordpress/dataviews';
// eslint-disable-next-line @wordpress/use-recommended-components -- Used here because it supports rendering as a `span` via the `render` prop to avoid invalid HTML.
import { Badge, Notice, Stack } from '@wordpress/ui';
import { cleanForSlug } from '@wordpress/url';

/**
* Internal dependencies
*/
import { unlock } from '../lock-unlock';
import { usePublicPostTypes } from '../utils';
import type { TaxonomyFormData } from '../types';
import { booleanField } from './utils';

const { ValidatedInputControl } = unlock( componentsPrivateApis );

export const titleField: Field< TaxonomyFormData > = {
id: 'title',
label: __( 'Title' ),
Expand Down Expand Up @@ -102,6 +112,23 @@ export const statusField: Field< TaxonomyFormData > = {
enableSorting: false,
};

const SLUG_MAX_LENGTH = 32;
// Slug field has required + pattern + maxLength + a custom (slug-taken) check.
// Surface them in priority order: structural rules first, async slug-taken last.
// `required` only overrides the native browser message when our rule supplies
// one of its own.
function getSlugCustomValidity( validity?: FieldValidity ) {
if ( validity?.required?.message ) {
return validity.required;
}
if ( validity?.pattern ) {
return validity.pattern;
}
if ( validity?.maxLength ) {
return validity.maxLength;
}
return validity?.custom;
}
export function useSlugField(
originalSlug?: string,
currentValue?: string
Expand Down Expand Up @@ -138,7 +165,8 @@ export function useSlugField(
),
isValid: {
required: true,
pattern: '^[a-z0-9_\\-]{1,32}$',
pattern: '^[a-z0-9_\\-]+$',
maxLength: SLUG_MAX_LENGTH,
custom: async ( value: TaxonomyFormData ) => {
const slug = value.slug;
if ( originalSlug !== undefined && slug === originalSlug ) {
Expand All @@ -164,6 +192,60 @@ export function useSlugField(
: null;
},
},
Edit: function SlugEdit( {
data,
field,
onChange,
hideLabelFromVision,
markWhenOptional,
validity,
}: DataFormControlProps< TaxonomyFormData > ) {
const { label, description, getValue, setValue, isValid } =
field;
const value =
( getValue( { item: data } ) as string | undefined ) ?? '';
const handleChange = ( newValue: string ) =>
onChange( setValue( { item: data, value: newValue } ) );
const onFocus = () => {
if ( data.id !== undefined || data.slug ) {
return;
}
const singular = data.config.labels.singular_name?.trim();
if ( ! singular ) {
return;
}
const cleaned = cleanForSlug( singular );
// On a fresh record fill the input from the singular label.
// Skip auto-fill if cleanForSlug retained non-ASCII to match
// the server's sanitize_key charset.
if ( /[^a-z0-9_-]/.test( cleaned ) ) {
return;
}
const trimmed = cleaned
.slice( 0, SLUG_MAX_LENGTH )
// Slicing can introduce a trailing hyphen — strip it.
.replace( /-+$/, '' );
if ( trimmed ) {
handleChange( trimmed );
}
};
Comment on lines +209 to +231

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above wrt generating a slug that matches an existing taxonomy

return (
<ValidatedInputControl
__next40pxDefaultSize
required={ !! isValid.required }
markWhenOptional={ markWhenOptional }
customValidity={ getSlugCustomValidity( validity ) }
label={ label }
value={ value }
help={ description }
onChange={ handleChange }
onFocus={ onFocus }
hideLabelFromVision={ hideLabelFromVision }
pattern={ isValid.pattern?.constraint }
maxLength={ isValid.maxLength?.constraint }
/>
);
},
filterBy: false,
enableSorting: false,
} ),
Expand Down
10 changes: 10 additions & 0 deletions packages/user-taxonomies/src/lock-unlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* WordPress dependencies
*/
import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';

export const { lock, unlock } =
__dangerousOptInToUnstableAPIsOnlyForCoreModules(
'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
'@wordpress/user-taxonomies'
);
4 changes: 3 additions & 1 deletion packages/user-taxonomies/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
{ "path": "../i18n" },
{ "path": "../icons" },
{ "path": "../notices" },
{ "path": "../ui" }
{ "path": "../private-apis" },
{ "path": "../ui" },
{ "path": "../url" }
]
}
13 changes: 8 additions & 5 deletions test/e2e/specs/admin/user-taxonomies.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,25 @@ test.describe( 'User taxonomies', () => {
await page
.getByRole( 'textbox', { name: 'Singular label' } )
.fill( 'Genre' );
// The slug field runs an async uniqueness check; the form's
// `isValid` stays false while it's in flight, so wait for the
// Focusing the slug field auto-fills it from the singular label,
// which also kicks off the async uniqueness check. The form's
// `isValid` stays false while that's in flight, so wait for the
// REST call to settle before submitting.
// The button doesn't reflect form validity, so a UI-only wait
// isn't possible.
// TODO: expolore disabling the button based on the form validity.
const slugField = page.getByRole( 'textbox', {
name: 'Taxonomy key',
} );
await Promise.all( [
page.waitForResponse(
( resp ) =>
resp.url().includes( `/${ TAXONOMIES_REST_BASE }` ) &&
resp.url().includes( 'slug=genre' )
),
page
.getByRole( 'textbox', { name: 'Taxonomy key' } )
.fill( 'genre' ),
slugField.focus(),
] );
await expect( slugField ).toHaveValue( 'genre' );
await page.getByRole( 'combobox', { name: 'Post types' } ).click();
await page.getByRole( 'option', { name: 'Posts' } ).click();
await expect(
Expand Down
Loading