-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Experiment: Auto fill slug from singular label for taxonomies and post types
#77938
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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' ), | ||
|
|
@@ -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 ) { | ||
| 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 | ||
|
|
@@ -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 ) { | ||
|
|
@@ -266,6 +294,60 @@ export function useSlugField( | |
| : null; | ||
| }, | ||
| }, | ||
| Edit: function SlugEdit( { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added it in the tracking issue for a follow up.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| } ), | ||
|
|
||
| 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a shame we need to do this solely for using @mirka are we planning to stabilize the validated form components soon?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since the validated components are currently mainly used through |
||
| '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' | ||
| ); | ||
| 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' ), | ||
|
|
@@ -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 | ||
|
|
@@ -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 ) { | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| } ), | ||
|
|
||
| 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' | ||
| ); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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-typespackage or something that might contain both user taxonomies and post types. I updated the tracking issue with this.