-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #731 from contember/feat/slug-input
slug input
- Loading branch information
Showing
12 changed files
with
527 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
77 changes: 77 additions & 0 deletions
77
packages/playground/admin/lib-extra/slug-field/FormSlugInput.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} /> | ||
})} | ||
</> | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
128
packages/playground/admin/lib-extra/slug-field/useSlugInput.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) | ||
} |
Oops, something went wrong.