Skip to content
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

slug input #731

Merged
merged 2 commits into from
Jun 26, 2024
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
1 change: 1 addition & 0 deletions packages/playground/admin/app/components/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const Navigation = () => {
<MenuItem icon={line} label={'Client validation'} to={'input/clientValidation'} />
<MenuItem icon={line} label={'Checkbox'} to={'input/checkbox'} />
<MenuItem icon={line} label={'Radio'} to={'input/enumRadio'} />
<MenuItem icon={line} label={'Slug'} to={'input/slug'} />
</MenuItem>
<MenuItem icon={<ArchiveIcon size={16} />} label={'Select'}>
<MenuItem icon={line} label={'Has one select'} to={'select/hasOne'} />
Expand Down
30 changes: 27 additions & 3 deletions packages/playground/admin/app/pages/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { Button } from '@app/lib/ui/button'
import { Binding, PersistButton } from '@app/lib/binding'
import { SelectOrTypeField } from '@app/lib-extra/select-or-type-field'
import { FieldExists } from '@app/lib-extra/has-field'

import { SlugField } from '@app/lib-extra/slug-field/field'
import slugify from '@sindresorhus/slugify'

export const basic = () => <>
<Binding>
Expand Down Expand Up @@ -40,7 +41,7 @@ export const selectOrType = () => <>
<SelectOrTypeField field={'textValue'} label={'Text'} options={{
a: 'Option A',
b: 'Option B',
}}/>
}} />
</div>
</EntitySubTree>
</Binding>
Expand Down Expand Up @@ -114,7 +115,30 @@ export const clientValidation = () => <>
<InputField field={'textValue'} label={'Name'} required inputProps={{ pattern: '[a-z]+' }} />
<InputField field={'intValue'} label={'Number'} inputProps={{ required: true, max: 100 }} />
<CheckboxField field={'boolValue'} label={'Some boolean'} description={'Hello world'} inputProps={{ required: true }} />
<InputField field={'uuidValue'} label={'UUID'} />
<InputField field={'uuidValue'} label={'UUID'} />
</div>
</EntitySubTree>
</Binding>
</>


export const slug = () => <>
<Binding>
<Slots.Actions>
<PersistButton />
</Slots.Actions>
<EntitySubTree entity={'Slug(unique=unique)'} setOnCreate={'(unique=unique)'}>
<div className={'space-y-4'}>
<InputField field={'title'} label={'Title'} />
<SlugField
slugify={slugify}
field={'slug'}
label={'Slug'}
derivedFrom="title"
unpersistedHardPrefix="http://google.com"
persistedHardPrefix="/article/"
persistedSoftPrefix="foo/"
/>
</div>
</EntitySubTree>
</Binding>
Expand Down
77 changes: 77 additions & 0 deletions packages/playground/admin/lib-extra/slug-field/FormSlugInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { FormInput, FormInputProps } from '@contember/react-form'
import { Component, Environment, Field, FieldAccessor, SugaredRelativeSingleField } from '@contember/interface'
import * as React from 'react'
import { ComponentType } from 'react'
import { Slot } from '@radix-ui/react-slot'
import { useSlugInput } from './useSlugInput'

export type FormSlugInputProps =
& FormInputProps
& SlugInputOwnProps

export type SlugPrefix = string | ((environment: Environment) => string)

export type SlugInputDerivedFrom =
| SugaredRelativeSingleField['field']
| FieldAccessor.GetFieldAccessor

export interface SlugInputOwnProps {
slugify: (value: string) => string
derivedFrom: SlugInputDerivedFrom[] | SlugInputDerivedFrom
format?: (accessors: FieldAccessor[]) => string | null
unpersistedHardPrefix?: SlugPrefix
persistedHardPrefix?: SlugPrefix
persistedSoftPrefix?: SlugPrefix
}

type InputProps = React.JSX.IntrinsicElements['input'] & {
prefix?: string
href?: string
}
const SlotInput = Slot as ComponentType<InputProps>

export const FormSlugInput = Component<FormSlugInputProps>(({
derivedFrom,
unpersistedHardPrefix,
persistedHardPrefix,
persistedSoftPrefix,
format,
children,
field,
slugify,
...props
}, env) => {
const { parseValue, formatValue, onBlur, hardPrefix, fullValue } = useSlugInput({
field,
slugify,
derivedFrom,
format,
unpersistedHardPrefix,
persistedHardPrefix,
persistedSoftPrefix,
})

return (
<FormInput
field={field}
parseValue={parseValue}
formatValue={formatValue}
{...props}
>
<SlotInput onBlur={onBlur} prefix={hardPrefix} href={fullValue ?? undefined}>
{children}
</SlotInput>
</FormInput>
)
}, ({ field, isNonbearing, defaultValue, derivedFrom }) => {
const derivedFromArray = Array.isArray(derivedFrom) ? derivedFrom : [derivedFrom]
return <>
<Field field={field} isNonbearing={isNonbearing} defaultValue={defaultValue} />
{derivedFromArray.map((it, index) => {
if (typeof it === 'function') {
return
}
return <Field field={it} key={index} />
})}
</>
})
63 changes: 63 additions & 0 deletions packages/playground/admin/lib-extra/slug-field/field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { FormContainer, FormContainerProps } from '@app/lib/form'
import * as React from 'react'
import { ComponentProps, forwardRef, ReactNode } from 'react'
import { Input, InputBare, InputLike } from '@app/lib/ui/input'
import { cn } from '@app/lib/utils'
import { FormFieldScope, FormInputProps } from '@contember/react-form'
import { Component } from '@contember/interface'
import { ExternalLinkIcon } from 'lucide-react'
import { FormSlugInput, SlugInputOwnProps } from '@app/lib-extra/slug-field/FormSlugInput'

export type SlugFieldProps =
& Omit<FormInputProps, 'children'>
& Omit<FormContainerProps, 'children'>
& SlugInputOwnProps
& {
required?: boolean
inputProps?: ComponentProps<typeof Input>
}

export const SlugField = Component(({
field,
label,
description,
inputProps,
required,
...props
}: SlugFieldProps) => {
return (
<FormFieldScope field={field}>
<FormContainer description={description} label={label}>
<FormSlugInput field={field} {...props}>
<SlugInput
required={required} {...(inputProps ?? {})}
className={cn('max-w-md', inputProps?.className)}
/>
</FormSlugInput>
</FormContainer>
</FormFieldScope>
)
})


export type SlugInputProps =
& Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'>
& {
prefix?: ReactNode
href?: string
}

export const SlugInput = forwardRef<HTMLInputElement, SlugInputProps>(({ prefix, href, className, ...props }, ref) => {
return (
<InputLike className="relative">
{prefix &&
<span className="-my-2 -ml-2 text-gray-400 self-stretch py-1 pl-2 flex items-center">{prefix}</span>
}
<InputBare className={cn('pr-1', className)} {...props} ref={ref} />

{href && <a className="ml-auto self-stretch flex items-center px-2 text-gray-600 bg-gray-50 rounded-r-md border-l hover:bg-gray-100 -my-2 -mr-2" href={href} target="_blank" rel="noreferrer">
<ExternalLinkIcon className="h-4 w-4" />
</a>}
</InputLike>
)
})
Empty file.
128 changes: 128 additions & 0 deletions packages/playground/admin/lib-extra/slug-field/useSlugInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Environment, FieldAccessor, SugaredRelativeSingleField, useDesugaredRelativeSingleField, useEntity, useEnvironment, useField } from '@contember/react-binding'
import { FormInputHandler } from '@contember/react-form'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { SlugInputOwnProps } from './FormSlugInput'

export type SlugPrefix = string | ((environment: Environment) => string)

export type UseSlugValueProps =
& SlugInputOwnProps
& {
field: SugaredRelativeSingleField['field']
}

export const useSlugInput = ({
field: fieldName,
derivedFrom,
format,
unpersistedHardPrefix,
persistedHardPrefix,
persistedSoftPrefix,
slugify,
}: UseSlugValueProps) => {

const field = useField<string>(fieldName)
const normalizedUnpersistedHardPrefix = useNormalizedPrefix(unpersistedHardPrefix)
const normalizedPersistedHardPrefix = useNormalizedPrefix(persistedHardPrefix)
const normalizedPersistedSoftPrefix = useNormalizedPrefix(persistedSoftPrefix)

const derivedFromNormalized = useMemo(() => Array.isArray(derivedFrom) ? derivedFrom : [derivedFrom], [derivedFrom])

const fieldRef = useRef(field)
fieldRef.current = field
const normalizeValue = useCallback((value: string | null) => {
if (value === null) {
return null
}
return value
.replace(/\/+/g, '/')
.replace(/(?<=.)\/$/, '')
.replaceAll(/[^/]+/g, it => slugify(it))
}, [slugify])


const entity = useEntity()
const getEntityAccessor = entity.getAccessor
const desugaredField = useDesugaredRelativeSingleField(fieldName)

const fieldAccessorGetters = useMemo(() => {
return derivedFromNormalized.map((it): FieldAccessor.GetFieldAccessor => {
if (typeof it === 'function') {
return it
}
return getEntityAccessor().getField(it).getAccessor
})

}, [getEntityAccessor, derivedFromNormalized])


const createSlug = useCallback(() => {
const accessors = fieldAccessorGetters.map(it => it())
let slugValue: string | null = null
if (format) {
slugValue = format(accessors)
} else {
const parts = accessors.map(it => it.value !== null ? slugify(it.value as string) : null).filter(it => it !== null)
if (parts.length > 0) {
slugValue = parts.join('/') // configurable?
}
}
if (slugValue === null) {
return null
}
return normalizeValue(`${normalizedPersistedHardPrefix}${normalizedPersistedSoftPrefix}${slugValue}`)
}, [fieldAccessorGetters, format, normalizeValue, normalizedPersistedHardPrefix, normalizedPersistedSoftPrefix, slugify])

const handleUpdateSlug = useCallback(() => {
getEntityAccessor().batchUpdates(getAccessor => {
const targetEntity = getAccessor().getRelativeSingleEntity(desugaredField)
const targetField = getAccessor().getRelativeSingleField(desugaredField)
if (targetField.isTouched) {
return
}
if (targetEntity.existsOnServer && targetField.value !== null) {
return
}
const slug = createSlug()
if (slug !== null) {
targetField.updateValue(slug, { agent: 'derivedField' })
}
})
}, [createSlug, desugaredField, getEntityAccessor])

useEffect(() => {
const targetField = getEntityAccessor().getRelativeSingleField(desugaredField)
if (targetField.value !== null) {
return
}
handleUpdateSlug()
fieldAccessorGetters.forEach(it => {
it().addEventListener({ type: 'beforeUpdate' }, handleUpdateSlug)
})
}, [desugaredField, fieldAccessorGetters, format, getEntityAccessor, handleUpdateSlug])

const hardPrefix = normalizedUnpersistedHardPrefix + normalizedPersistedHardPrefix

return {
unpersistedHardPrefix: normalizedUnpersistedHardPrefix,
persistedHardPrefix: normalizedPersistedHardPrefix,
persistedSoftPrefix: normalizedPersistedSoftPrefix,
hardPrefix,
fullValue: field.value !== null ? `${normalizedUnpersistedHardPrefix}${field.value}` : null,
onBlur: useCallback(() => {
fieldRef.current.updateValue(normalizeValue(fieldRef.current.value))
}, [normalizeValue]),
parseValue: useCallback<FormInputHandler['parseValue']>(val => {
const parsedValue = val ?? null
return parsedValue !== null ? `${normalizedPersistedHardPrefix}${parsedValue}` : null
}, [normalizedPersistedHardPrefix]),
formatValue: useCallback<FormInputHandler['formatValue']>(value => {
return typeof value === 'string' ? value.substring(normalizedPersistedHardPrefix.length) : ''
}, [normalizedPersistedHardPrefix]),
}
}

const useNormalizedPrefix = (value?: SlugPrefix) => {
const environment = useEnvironment()
return useMemo(() => typeof value === 'function' ? value(environment) : value ?? '', [value, environment])
}
Loading
Loading