Skip to content

Commit

Permalink
feat(i18n): i18n on React Form
Browse files Browse the repository at this point in the history
  • Loading branch information
ggrossetie committed Oct 24, 2024
1 parent 540e683 commit 3f5ef93
Show file tree
Hide file tree
Showing 13 changed files with 584 additions and 1,814 deletions.
2,046 changes: 338 additions & 1,708 deletions front/package-lock.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions front/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
"@rjsf/core": "~5.21",
"@rjsf/validator-ajv8": "^5.21.2",
"@rollup/plugin-graphql": "^2.0.2",
"@vitejs/plugin-legacy": "^3.0.1",
"@vitejs/plugin-react": "^4.0.0",
"@vitejs/plugin-legacy": "~5.4",
"@vitejs/plugin-react": "~4.3",
"biblatex-csl-converter": "^1.11.0",
"clsx": "^1.2.1",
"core-js": "^3.38",
Expand Down Expand Up @@ -68,8 +68,8 @@
"slugify": "1.6.6",
"swr": "^2.0.0",
"terser": "^5.16.1",
"vite": "^4.0.1",
"vite-plugin-handlebars": "^1.6.0",
"vite": "~5.4",
"vite-plugin-handlebars": "~2.0",
"y-monaco": "^0.1.4",
"y-websocket": "^1.5.0",
"yjs": "^13.6.6"
Expand Down Expand Up @@ -106,4 +106,4 @@
"node": "18.18.2",
"npm": "10.2.0"
}
}
}
219 changes: 156 additions & 63 deletions front/src/components/Form.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React, { Fragment, useMemo, useState } from 'react'
import Form from '@rjsf/core'
import validator from "@rjsf/validator-ajv8"
import PropTypes from 'prop-types'
import React, { Fragment, useCallback, useMemo, useState } from 'react'
import Form, { getDefaultRegistry } from '@rjsf/core'
import validator from '@rjsf/validator-ajv8'
import { set } from 'object-path-immutable'
import { Translation } from 'react-i18next'
import basicUiSchema from '../schemas/ui-schema-basic-override.json'
import uiSchema from '../schemas/ui-schema-editor.json'
import schema from '../schemas/data-schema.json'
import defaultUiSchema from '../schemas/ui-schema-editor.json'
import defaultSchema from '../schemas/data-schema.json'
import { toYaml } from './Write/metadata/yaml'

// REMIND: use a custom SelectWidget to support "ui:emptyValue"
Expand All @@ -18,22 +20,65 @@ import Button from './Button'
import { Plus, Trash } from 'react-feather'
import IsidoreAuthorAPIAutocompleteField from './Write/metadata/isidoreAuthor'

const CustomSelect = function(props) {
const {
templates: { BaseInputTemplate: DefaultBaseInputTemplate },
} = getDefaultRegistry()

/**
* @param {BaseInputTemplate} properties
*/
function BaseInputTemplate (properties) {
const { placeholder } = properties
return (<Translation>
{
(t) => <DefaultBaseInputTemplate {...properties} placeholder={t(placeholder)}/>
}
</Translation>)
}

/**
* @param {SelectWidget} properties
*/
function CustomSelect (properties) {
const { options, title, placeholder } = properties
return (<div className={styles.selectContainer}>
<SelectWidget {...props}/>
</div>)
<Translation>
{
(t) => <SelectWidget {...{
...properties, placeholder: t(placeholder), options: {
enumOptions: options?.enumOptions?.map((opt) => {
if (title && opt.label in title) {
return {
label: t(title[opt.label]),
value: opt.value
}
}
return {
label: t(opt.label),
value: opt.value
}
})
}
}}/>
}
</Translation>
</div>)
}
function ArrayFieldTemplate(props) {
const addItemTitle = props.uiSchema['ui:add-item-title'] ?? 'Ajouter'
const removeItemTitle = props.uiSchema['ui:remove-item-title'] ?? 'Supprimer'
const title = props.uiSchema['ui:title']

const inlineRemoveButton = props.schema?.items?.type === 'string' || !removeItemTitle
/**
* @param {ArrayFieldTemplateProps} properties
*/
function ArrayFieldTemplate (properties) {
const addItemTitle = properties.uiSchema['ui:add-item-title'] ?? 'Ajouter'
const removeItemTitle = properties.uiSchema['ui:remove-item-title'] ?? 'Supprimer'
const title = properties.uiSchema['ui:title']

const inlineRemoveButton = properties.schema?.items?.type === 'string' || !removeItemTitle
return (
<fieldset className={styles.fieldset} key={props.key}>
{title && <legend id={props.id}>{title}</legend>}
{props.items &&
props.items.map((element) => (
<fieldset className={styles.fieldset} key={properties.key}>
{title && <Translation>{(t) => <legend id={properties.id}>{t(title)}</legend>}</Translation>}
{properties.items &&
properties.items.map((element) => (
<div
id={element.key}
key={element.key}
Expand All @@ -55,12 +100,12 @@ function ArrayFieldTemplate(props) {
)}
</div>
))}
{props.canAdd && (
{properties.canAdd && (
<Button
type="button"
className={styles.addButton}
tabIndex={-1}
onClick={props.onAddClick}
onClick={properties.onAddClick}
>
<Plus/>
{addItemTitle}
Expand All @@ -70,21 +115,41 @@ function ArrayFieldTemplate(props) {
)
}

function FieldTemplate (properties) {
const { id, classNames, style, help, description, errors, children } = properties
const label = properties.schema.$id
? properties.label[properties.schema.$id]
: properties.label
return (
<div className={classNames} style={style}>
<label htmlFor={id}>
<Translation>

Check failure on line 126 in front/src/components/Form.jsx

View workflow job for this annotation

GitHub Actions / build (front)

src/components/Write/yamleditor/YamlEditor.test.jsx > YamlEditor > renders with an empty Yaml

Error: [vitest] No "Translation" export is defined on the "react-i18next" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("react-i18next"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ FieldTemplate src/components/Form.jsx:126:10 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5 ❯ renderRootSync node_modules/react-dom/cjs/react-dom.development.js:26473:7

Check failure on line 126 in front/src/components/Form.jsx

View workflow job for this annotation

GitHub Actions / build (front)

src/components/Write/yamleditor/YamlEditor.test.jsx > YamlEditor > renders with a valid Yaml

Error: [vitest] No "Translation" export is defined on the "react-i18next" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("react-i18next"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ FieldTemplate src/components/Form.jsx:126:10 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5 ❯ renderRootSync node_modules/react-dom/cjs/react-dom.development.js:26473:7

Check failure on line 126 in front/src/components/Form.jsx

View workflow job for this annotation

GitHub Actions / build (front)

src/components/Write/yamleditor/YamlEditor.test.jsx > YamlEditor > renders with an invalid Yaml

Error: [vitest] No "Translation" export is defined on the "react-i18next" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("react-i18next"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ FieldTemplate src/components/Form.jsx:126:10 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5 ❯ renderRootSync node_modules/react-dom/cjs/react-dom.development.js:26473:7
{
(t) => <>{t(label)}</>
}
</Translation>
</label>
{description}
{children}
{errors}
{help}
</div>
)
}

/**
*
* @param {ObjectFieldTemplateProps} props
* @returns
* @param {ObjectFieldTemplateProps} properties
*/
function ObjectFieldTemplate(props) {
if (props.uiSchema['ui:groups']) {
const groups = props.uiSchema['ui:groups']
function ObjectFieldTemplate (properties) {
if (properties.uiSchema['ui:groups']) {
const groups = properties.uiSchema['ui:groups']
const groupedElements = groups.map(({ fields, title }) => {
const elements = fields
.filter(
(field) => (props.uiSchema[field] || {})['ui:widget'] !== 'hidden'
(field) => (properties.uiSchema[field] || {})['ui:widget'] !== 'hidden'
)
.map((field) => {
const element = props.properties.find((element) => element.name === field)
const element = properties.properties.find((element) => element.name === field)

if (!element) {
console.error('Field configuration not found for "%s" in \'ui:groups\' "%s" — part of %o', field, title, fields)
Expand All @@ -93,47 +158,68 @@ function ObjectFieldTemplate(props) {
return [field, element]
})

if (elements && elements.length > 0) {
return (
<fieldset className={styles.fieldset} key={fields.join('-')}>
{title && <legend>{title}</legend>}
{elements.map(([field, element]) => (
element
? <Fragment key={field}>{element.content}</Fragment>
: <p key={field} className={styles.fieldHasNoElementError}>
Field <code>{field}</code> defined in <code>ui:groups</code> is not an
entry of <code>data-schema.json[properties]</code> object.
</p>
))}
</fieldset>
)
}
if (elements && elements.length > 0) {
return (
<fieldset className={styles.fieldset} key={fields.join('-')}>
{title && <legend>
<Translation>
{
(t) => <>{t(title)}</>
}
</Translation>
</legend>}
{elements.map(([field, element]) => (
element
? <Fragment key={field}>{element.content}</Fragment>
: <p key={field} className={styles.fieldHasNoElementError}>
Field <code>{field}</code> defined in <code>ui:groups</code> is not an
entry of <code>data-schema.json[properties]</code> object.
</p>
))}
</fieldset>
)
}
})

return <>{groupedElements}</>
}

if (props) {
const autocomplete = props.uiSchema['ui:autocomplete']
if (properties) {
const autocomplete = properties.uiSchema['ui:autocomplete']
return (
<Fragment key={props.key}>
{autocomplete === "IsidoreAuthorSearch" && <IsidoreAuthorAPIAutocompleteField {...props}/>}
{props.properties.map((element) => (
<Fragment key={properties.key}>
{properties.description}
{autocomplete === 'IsidoreAuthorSearch' && <IsidoreAuthorAPIAutocompleteField {...properties}/>}
{properties.properties.map((element) => (
<Fragment key={element.name}>{element.content}</Fragment>
))}
</Fragment>
)
}
}

const customFields = {
IsidoreKeywordSearch: isidoreKeywordSearch,
IsidoreAuthorSearch: isidoreAuthorSearch,
}

/**
*
* @param initialFormData
* @param basicMode
* @param {(any) => void} onChange
* @return {Element}
* @constructor
*/
export default function SchemaForm ({
formData: initialFormData,
basicMode,
onChange = () => {},
onChange = () => {
},
}) {
const [formData, setFormData] = useState(initialFormData)
const [errors, setErrors] = useState({})
const formContext = {
const [, setErrors] = useState({})
const formContext = useMemo(() => ({
partialUpdate: ({ id, value }) => {
const path = id.replace('root_', '').replace('_', '.')
setFormData((state) => {
Expand All @@ -142,46 +228,53 @@ export default function SchemaForm ({
return newFormData
})
},
}
}), [onChange, setFormData])

const effectiveUiSchema = useMemo(
() => (basicMode ? { ...uiSchema, ...basicUiSchema } : uiSchema),
() => (basicMode ? { ...defaultUiSchema, ...basicUiSchema } : defaultUiSchema),
[basicMode]
)

const customWidgets = {
SelectWidget: CustomSelect,
}

const customFields = {
IsidoreKeywordSearch: isidoreKeywordSearch,
IsidoreAuthorSearch: isidoreAuthorSearch,
}

const customTemplates = {
ObjectFieldTemplate,
FieldTemplate,
BaseInputTemplate,
ArrayFieldTemplate
}

const handleUpdate = useCallback((event) => {
const formData = event.formData
setFormData(formData)
onChange(toYaml(formData))
}, [setFormData, onChange])

// noinspection JSValidateTypes
return (
<Form
className={styles.form}
formContext={formContext}
schema={schema}
schema={defaultSchema}
name="Metadata"
templates={customTemplates}
widgets={customWidgets}
fields={customFields}
uiSchema={effectiveUiSchema}
formData={formData}
onChange={(e) => {
setFormData(e.formData)
onChange(toYaml(e.formData))
}}
onChange={handleUpdate}
onError={setErrors}
validator={validator}
>
<hr hidden={true} />
<hr hidden={true}/>
</Form>
)
}

SchemaForm.propTypes = {
formData: PropTypes.object,
basicMode: PropTypes.bool,
onChange: PropTypes.func
}
2 changes: 1 addition & 1 deletion front/src/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { LifeBuoy } from 'react-feather'
import { useSelector } from 'react-redux'
import { Link, Route, Switch } from 'react-router-dom'

import logoContent from '../../public/images/logo.svg?inline'
import logoContent from '/images/logo.svg?inline'
import { useActiveWorkspace } from '../hooks/workspace.js'

import styles from './header.module.scss'
Expand Down
14 changes: 11 additions & 3 deletions front/src/components/form.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@

.form {
:global {
.field-array > label.control-label {
@include legend;
.field-array > label {
display: none;
}

.field-object > label {
font-weight: bold;
}
}

Expand Down Expand Up @@ -84,7 +88,7 @@
}

.form-group {
border-bottom: 1px solid #f0f0f0;
//border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
padding: 0.5rem 0.5rem;
Expand All @@ -97,6 +101,10 @@
> * {
width: 100%;
}

.field-object > label {
display: none;
}

> fieldset {
> button {
Expand Down
Loading

0 comments on commit 3f5ef93

Please sign in to comment.