Skip to content

Commit

Permalink
Merge pull request #731 from contember/feat/slug-input
Browse files Browse the repository at this point in the history
slug input
  • Loading branch information
matej21 authored Jun 26, 2024
2 parents 53e47ed + fd73432 commit 70d2a7e
Show file tree
Hide file tree
Showing 12 changed files with 527 additions and 4 deletions.
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

0 comments on commit 70d2a7e

Please sign in to comment.