From a53022e16c9a0a8b8c40599ccf7847ef0b6746ba Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 25 Jun 2024 15:58:57 +0200 Subject: [PATCH 1/2] feat(playground): slug input --- .../admin/app/components/navigation.tsx | 1 + packages/playground/admin/app/pages/input.tsx | 30 ++- .../lib-extra/slug-field/FormSlugInput.tsx | 77 ++++++++ .../admin/lib-extra/slug-field/field.tsx | 63 ++++++ .../admin/lib-extra/slug-field/index.ts | 0 .../lib-extra/slug-field/useSlugInput.tsx | 128 ++++++++++++ .../migrations/2024-06-14-155859-slug.json | 187 ++++++++++++++++++ packages/playground/api/model/Slug.ts | 12 ++ packages/playground/api/model/index.ts | 1 + packages/playground/package.json | 3 + yarn.lock | 27 +++ 11 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 packages/playground/admin/lib-extra/slug-field/FormSlugInput.tsx create mode 100644 packages/playground/admin/lib-extra/slug-field/field.tsx create mode 100644 packages/playground/admin/lib-extra/slug-field/index.ts create mode 100644 packages/playground/admin/lib-extra/slug-field/useSlugInput.tsx create mode 100644 packages/playground/api/migrations/2024-06-14-155859-slug.json create mode 100644 packages/playground/api/model/Slug.ts diff --git a/packages/playground/admin/app/components/navigation.tsx b/packages/playground/admin/app/components/navigation.tsx index f084f108bb..bafd4f1263 100644 --- a/packages/playground/admin/app/components/navigation.tsx +++ b/packages/playground/admin/app/components/navigation.tsx @@ -38,6 +38,7 @@ export const Navigation = () => { + } label={'Select'}> diff --git a/packages/playground/admin/app/pages/input.tsx b/packages/playground/admin/app/pages/input.tsx index 587635cdf1..f5223c2f3e 100644 --- a/packages/playground/admin/app/pages/input.tsx +++ b/packages/playground/admin/app/pages/input.tsx @@ -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 = () => <> @@ -40,7 +41,7 @@ export const selectOrType = () => <> + }} /> @@ -114,7 +115,30 @@ export const clientValidation = () => <> - + + + + + + + +export const slug = () => <> + + + + + +
+ +
diff --git a/packages/playground/admin/lib-extra/slug-field/FormSlugInput.tsx b/packages/playground/admin/lib-extra/slug-field/FormSlugInput.tsx new file mode 100644 index 0000000000..e2dc5cf167 --- /dev/null +++ b/packages/playground/admin/lib-extra/slug-field/FormSlugInput.tsx @@ -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 + +export const FormSlugInput = Component(({ + 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 ( + + + {children} + + + ) +}, ({ field, isNonbearing, defaultValue, derivedFrom }) => { + const derivedFromArray = Array.isArray(derivedFrom) ? derivedFrom : [derivedFrom] + return <> + + {derivedFromArray.map((it, index) => { + if (typeof it === 'function') { + return + } + return + })} + +}) diff --git a/packages/playground/admin/lib-extra/slug-field/field.tsx b/packages/playground/admin/lib-extra/slug-field/field.tsx new file mode 100644 index 0000000000..55c429127a --- /dev/null +++ b/packages/playground/admin/lib-extra/slug-field/field.tsx @@ -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 + & Omit + & SlugInputOwnProps + & { + required?: boolean + inputProps?: ComponentProps + } + +export const SlugField = Component(({ + field, + label, + description, + inputProps, + required, + ...props +}: SlugFieldProps) => { + return ( + + + + + + + + ) +}) + + +export type SlugInputProps = + & Omit, 'type'> + & { + prefix?: ReactNode + href?: string + } + +export const SlugInput = forwardRef(({ prefix, href, className, ...props }, ref) => { + return ( + + {prefix && + {prefix} + } + + + {href && + + } + + ) +}) diff --git a/packages/playground/admin/lib-extra/slug-field/index.ts b/packages/playground/admin/lib-extra/slug-field/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/playground/admin/lib-extra/slug-field/useSlugInput.tsx b/packages/playground/admin/lib-extra/slug-field/useSlugInput.tsx new file mode 100644 index 0000000000..9d782cef89 --- /dev/null +++ b/packages/playground/admin/lib-extra/slug-field/useSlugInput.tsx @@ -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(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(val => { + const parsedValue = val ?? null + return parsedValue !== null ? `${normalizedPersistedHardPrefix}${parsedValue}` : null + }, [normalizedPersistedHardPrefix]), + formatValue: useCallback(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]) +} diff --git a/packages/playground/api/migrations/2024-06-14-155859-slug.json b/packages/playground/api/migrations/2024-06-14-155859-slug.json new file mode 100644 index 0000000000..3b3b89db20 --- /dev/null +++ b/packages/playground/api/migrations/2024-06-14-155859-slug.json @@ -0,0 +1,187 @@ +{ + "formatVersion": 5, + "modifications": [ + { + "modification": "createEnum", + "enumName": "SlugUnique", + "values": [ + "unique" + ] + }, + { + "modification": "createEntity", + "entity": { + "name": "Slug", + "primary": "id", + "primaryColumn": "id", + "tableName": "slug", + "fields": { + "id": { + "name": "id", + "columnName": "id", + "columnType": "uuid", + "nullable": false, + "type": "Uuid" + } + }, + "unique": [], + "indexes": [], + "eventLog": { + "enabled": true + } + } + }, + { + "modification": "createEntity", + "entity": { + "name": "SlugCategory", + "primary": "id", + "primaryColumn": "id", + "tableName": "slug_category", + "fields": { + "id": { + "name": "id", + "columnName": "id", + "columnType": "uuid", + "nullable": false, + "type": "Uuid" + } + }, + "unique": [], + "indexes": [], + "eventLog": { + "enabled": true + } + } + }, + { + "modification": "createColumn", + "entityName": "Slug", + "field": { + "name": "unique", + "columnName": "unique", + "columnType": "SlugUnique", + "nullable": false, + "type": "Enum", + "default": "unique" + }, + "fillValue": "unique" + }, + { + "modification": "createColumn", + "entityName": "Slug", + "field": { + "name": "slug", + "columnName": "slug", + "columnType": "text", + "nullable": false, + "type": "String" + } + }, + { + "modification": "createColumn", + "entityName": "Slug", + "field": { + "name": "title", + "columnName": "title", + "columnType": "text", + "nullable": false, + "type": "String" + } + }, + { + "modification": "createColumn", + "entityName": "SlugCategory", + "field": { + "name": "name", + "columnName": "name", + "columnType": "text", + "nullable": false, + "type": "String" + } + }, + { + "modification": "createRelation", + "entityName": "Slug", + "owningSide": { + "type": "ManyHasOne", + "name": "category", + "target": "SlugCategory", + "joiningColumn": { + "columnName": "category_id", + "onDelete": "restrict" + }, + "nullable": true + } + }, + { + "modification": "createUniqueConstraint", + "entityName": "Slug", + "unique": { + "fields": [ + "unique" + ] + } + }, + { + "modification": "patchAclSchema", + "patch": [ + { + "op": "add", + "path": "/roles/admin/entities/Slug", + "value": { + "predicates": {}, + "operations": { + "read": { + "id": true, + "unique": true, + "slug": true, + "title": true, + "category": true + }, + "create": { + "id": true, + "unique": true, + "slug": true, + "title": true, + "category": true + }, + "update": { + "id": true, + "unique": true, + "slug": true, + "title": true, + "category": true + }, + "delete": true, + "customPrimary": true + } + } + }, + { + "op": "add", + "path": "/roles/admin/entities/SlugCategory", + "value": { + "predicates": {}, + "operations": { + "read": { + "id": true, + "name": true + }, + "create": { + "id": true, + "name": true + }, + "update": { + "id": true, + "name": true + }, + "delete": true, + "customPrimary": true + } + } + } + ] + } + ] +} diff --git a/packages/playground/api/model/Slug.ts b/packages/playground/api/model/Slug.ts new file mode 100644 index 0000000000..49c2130fbb --- /dev/null +++ b/packages/playground/api/model/Slug.ts @@ -0,0 +1,12 @@ +import { c } from '@contember/schema-definition' + +export class Slug { + unique = c.enumColumn(c.createEnum('unique')).unique().notNull().default('unique') + slug = c.stringColumn().notNull() + title = c.stringColumn().notNull() + category = c.manyHasOne(SlugCategory) +} + +export class SlugCategory { + name = c.stringColumn().notNull() +} diff --git a/packages/playground/api/model/index.ts b/packages/playground/api/model/index.ts index 218ff99ce5..c4efa87025 100644 --- a/packages/playground/api/model/index.ts +++ b/packages/playground/api/model/index.ts @@ -7,6 +7,7 @@ export * from './LegacyEditor' export * from './PlateEditor' export * from './Grid' export * from './Repeater' +export * from './Slug' export * from './Input' export * from './Select' export * from './Upload' diff --git a/packages/playground/package.json b/packages/playground/package.json index 7da7737c12..f813fbf82b 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -53,5 +53,8 @@ "tailwindcss": "^3.4.3", "tailwindcss-animate": "^1.0.7", "vite-tsconfig-paths": "^4.3.2" + }, + "dependencies": { + "@sindresorhus/slugify": "^2.2.1" } } diff --git a/yarn.lock b/yarn.lock index 4ddbc4cd54..f3d92030df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -413,6 +413,7 @@ __metadata: "@faker-js/faker": ^8.4.1 "@radix-ui/react-separator": ^1.0.3 "@radix-ui/react-toolbar": ^1.0.4 + "@sindresorhus/slugify": ^2.2.1 "@udecode/cn": ^31.0.0 "@udecode/plate-autoformat": ^31.0.0 "@udecode/plate-basic-marks": ^31.0.0 @@ -2639,6 +2640,25 @@ __metadata: languageName: node linkType: hard +"@sindresorhus/slugify@npm:^2.2.1": + version: 2.2.1 + resolution: "@sindresorhus/slugify@npm:2.2.1" + dependencies: + "@sindresorhus/transliterate": ^1.0.0 + escape-string-regexp: ^5.0.0 + checksum: 6d651e99a4dfc63f1eccc5373f722af031f013bfce0b040b2c1151f5795f272f7c47146d8fc5f03afbb410c53c9f91f7cb1a50f402a8bf7dd1b691d8a450c712 + languageName: node + linkType: hard + +"@sindresorhus/transliterate@npm:^1.0.0": + version: 1.6.0 + resolution: "@sindresorhus/transliterate@npm:1.6.0" + dependencies: + escape-string-regexp: ^5.0.0 + checksum: 947c7c84dcba36c35d12ac7fd95ae9f77e988bd499471ebd0819812c451c8bfd20f8a236084a13fde196ba1eb064871f8915d09995531611569e2fe687411582 + languageName: node + linkType: hard + "@testing-library/dom@npm:^9.0.0": version: 9.3.3 resolution: "@testing-library/dom@npm:9.3.3" @@ -5058,6 +5078,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:^5.0.0": + version: 5.0.0 + resolution: "escape-string-regexp@npm:5.0.0" + checksum: 20daabe197f3cb198ec28546deebcf24b3dbb1a5a269184381b3116d12f0532e06007f4bc8da25669d6a7f8efb68db0758df4cd981f57bc5b57f521a3e12c59e + languageName: node + linkType: hard + "eslint-plugin-react-hooks@npm:^4.6.2": version: 4.6.2 resolution: "eslint-plugin-react-hooks@npm:4.6.2" From fd734326f298454eec8217389e90d0fef8bcf8d8 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 25 Jun 2024 15:59:10 +0200 Subject: [PATCH 2/2] rix(react-ui-lib): input focus --- packages/react-ui-lib/src/ui/input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-ui-lib/src/ui/input.tsx b/packages/react-ui-lib/src/ui/input.tsx index d7d8132204..38f90b65c4 100644 --- a/packages/react-ui-lib/src/ui/input.tsx +++ b/packages/react-ui-lib/src/ui/input.tsx @@ -34,7 +34,7 @@ export const InputLike = uic('div', { flex items-center min-h-10 w-full rounded-md border border-input bg-background p-2 text-sm ring-offset-background max-w-md file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground - focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 + focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 `,