From f2099a43e5fcc1a5e5b6a2b281a1355445fa4743 Mon Sep 17 00:00:00 2001 From: Guillaume Grossetie Date: Thu, 31 Oct 2024 15:37:41 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20permet=20d'enregistrer=20des=20m=C3=A9t?= =?UTF-8?q?adonn=C3=A9es=20de=20type=20revue=20sur=20un=20corpus=20(au=20f?= =?UTF-8?q?ormat=20JSON)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/src/components/Form.jsx | 21 +++-- front/src/components/Form.test.jsx | 9 +-- front/src/components/Select.jsx | 8 +- .../Write/yamleditor/YamlEditor.jsx | 15 +++- front/src/components/corpus/Corpus.graphql | 2 +- front/src/components/corpus/CorpusItem.jsx | 4 + .../components/corpus/CorpusMetadataModal.jsx | 72 +++++++++++++++++ front/src/components/field.module.scss | 2 - front/src/components/form.module.scss | 75 +++++++++--------- .../src/components/metadata/MetadataForm.jsx | 12 +-- front/src/locales/en/translation.json | 10 ++- front/src/locales/fr/translation.json | 10 ++- .../src/schemas/corpus-journal-ui-schema.json | 28 +++++++ graphql/models/corpus.js | 4 +- graphql/resolvers/index.js | 3 + graphql/resolvers/jsonScalar.js | 79 +++++++++++++++++++ graphql/schema.js | 8 +- 17 files changed, 287 insertions(+), 75 deletions(-) create mode 100644 front/src/components/corpus/CorpusMetadataModal.jsx create mode 100644 front/src/schemas/corpus-journal-ui-schema.json create mode 100644 graphql/resolvers/jsonScalar.js diff --git a/front/src/components/Form.jsx b/front/src/components/Form.jsx index 0cf2831f9..a43963246 100644 --- a/front/src/components/Form.jsx +++ b/front/src/components/Form.jsx @@ -4,9 +4,6 @@ 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 defaultUiSchema from '../schemas/ui-schema-editor.json' -import defaultSchema from '../schemas/data-schema.json' // REMIND: use a custom SelectWidget to support "ui:emptyValue" // remove once fixed in https://github.com/rjsf-team/react-jsonschema-form/issues/1041 @@ -183,7 +180,7 @@ function ObjectFieldTemplate (properties) { 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) + console.error('Field configuration not found for "%s" in \'ui:groups\' "%s" — part of %o', field, title || '', fields) } return [field, element] @@ -237,14 +234,16 @@ const customFields = { /** * * @param initialFormData - * @param basicMode + * @param schema + * @param uiSchema * @param {(any) => void} onChange * @return {Element} * @constructor */ export default function SchemaForm ({ formData: initialFormData, - basicMode, + schema, + uiSchema, onChange = () => { }, }) { @@ -261,10 +260,6 @@ export default function SchemaForm ({ }, }), [onChange, setFormData]) - const effectiveUiSchema = useMemo( - () => (basicMode ? { ...defaultUiSchema, ...basicUiSchema } : defaultUiSchema), - [basicMode] - ) const customWidgets = { SelectWidget: CustomSelectWidget, @@ -289,12 +284,12 @@ export default function SchemaForm ({
{ - test('renders in basic mode with an empty form data', () => { - const { getByRole } = render() - - expect(getByRole('form')).toBeInTheDocument() - }) - test('renders in advanced mode with an empty form data', () => { - const { getByRole } = render() + test('renders a form with an empty form data and empty schema/uiSchema', () => { + const { getByRole } = render() expect(getByRole('form')).toBeInTheDocument() }) diff --git a/front/src/components/Select.jsx b/front/src/components/Select.jsx index 9130dfa7b..9764e0f7c 100644 --- a/front/src/components/Select.jsx +++ b/front/src/components/Select.jsx @@ -4,8 +4,14 @@ import fieldStyles from './field.module.scss' import { clsx } from 'clsx' const Select = forwardRef((props, forwardedRef) => { + const alignLabel = props.alignLabel !== false return (
- {props.label && } + {props.label && }
diff --git a/front/src/components/Write/yamleditor/YamlEditor.jsx b/front/src/components/Write/yamleditor/YamlEditor.jsx index ac64b8978..2b1e09895 100644 --- a/front/src/components/Write/yamleditor/YamlEditor.jsx +++ b/front/src/components/Write/yamleditor/YamlEditor.jsx @@ -1,15 +1,22 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo } from 'react' import PropTypes from 'prop-types' import YAML from 'js-yaml' import Form from '../../Form' -import { toYaml } from "../metadata/yaml.js"; -import { convertLegacyValues } from "../../metadata/MetadataValues.js"; +import { toYaml } from "../metadata/yaml.js" +import { convertLegacyValues } from "../../metadata/MetadataValues.js" +import defaultUiSchema from "../../../schemas/ui-schema-editor.json" +import basicUiSchema from "../../../schemas/ui-schema-basic-override.json" +import defaultSchema from '../../../schemas/data-schema.json' export default function YamlEditor({ yaml = '', basicMode = false, onChange = () => {} }) { + const effectiveUiSchema = useMemo( + () => (basicMode ? { ...defaultUiSchema, ...basicUiSchema } : defaultUiSchema), + [basicMode] + ) const [parsed = {}] = YAML.loadAll(yaml) const formData = convertLegacyValues(parsed) const handleChange = useCallback((newFormData) => onChange(toYaml(newFormData)), [onChange]) - return + return } YamlEditor.propTypes = { diff --git a/front/src/components/corpus/Corpus.graphql b/front/src/components/corpus/Corpus.graphql index b725408b7..0e6cc229b 100644 --- a/front/src/components/corpus/Corpus.graphql +++ b/front/src/components/corpus/Corpus.graphql @@ -46,7 +46,7 @@ mutation deleteCorpus($corpusId: ID!) { } } -mutation updateMetadata($corpusId: ID!, $metadata: String!) { +mutation updateMetadata($corpusId: ID!, $metadata: JSON!) { corpus(corpusId: $corpusId) { updateMetadata(metadata: $metadata) { _id diff --git a/front/src/components/corpus/CorpusItem.jsx b/front/src/components/corpus/CorpusItem.jsx index 7dd27fd81..4bee25bae 100644 --- a/front/src/components/corpus/CorpusItem.jsx +++ b/front/src/components/corpus/CorpusItem.jsx @@ -16,6 +16,7 @@ import CorpusUpdate from './CorpusUpdate.jsx' import { deleteCorpus } from './Corpus.graphql' import styles from './corpusItem.module.scss' +import CorpusMetadataModal from "./CorpusMetadataModal.jsx"; export default function CorpusItem({ corpus }) { @@ -90,6 +91,8 @@ export default function CorpusItem({ corpus }) { + + + +

{t('corpus.metadataModal.title')}

+ + + + + setEditMetadataVisible(false)}>{t('modal.close.text')} + {t('modal.saveButton.text')} +
+ ) +} + +CorpusMetadataModal.propTypes = { + corpusId: PropTypes.string.isRequired, + initialValue: PropTypes.object.isRequired +} diff --git a/front/src/components/field.module.scss b/front/src/components/field.module.scss index aac97f198..df7690830 100644 --- a/front/src/components/field.module.scss +++ b/front/src/components/field.module.scss @@ -38,8 +38,6 @@ flex-shrink: 0; margin-right: 1rem; padding: calc(0.5em - 1px) 0; - flex-basis: 10rem; - text-align: end; &[for] { cursor: pointer; diff --git a/front/src/components/form.module.scss b/front/src/components/form.module.scss index c6a6d006b..b1caa55cf 100644 --- a/front/src/components/form.module.scss +++ b/front/src/components/form.module.scss @@ -18,6 +18,7 @@ .selectContainer { @include selectFieldContainer; + select { @include actionField; @include selectField; @@ -29,41 +30,7 @@ .field-object > label { font-weight: bold; } - } - - label { - padding-right: 0.5rem; - } - - textarea { - width: 100%; - height: 150px; - } -} - -.verticalForm { - :global { - .control-field { - flex-direction: column; - margin-bottom: 1rem; - - label { - flex-basis: 1em; - } - } - } -} - -.fieldset { - border: none; - margin: 0 0 0.75rem; - padding: 0; - legend { - @include legend; - } - - :global { [role='combobox'] { position: relative; @@ -98,10 +65,11 @@ padding-bottom: 0.25rem; font-weight: bold; } + > * { width: 100%; } - + .field-object > label { display: none; } @@ -123,6 +91,7 @@ .field-string { flex: 1; border-bottom: none; + > input { width: 100%; } @@ -200,20 +169,54 @@ /** Array of objects field */ .array-item.can-add-remove { - /*padding: 0 0.5rem;*/ + /*padding: 0 0.5rem;*/ > div { padding-right: 0; } + > button { border: 1px solid black; } + > .field-string { display: unset; } } } } + + label { + padding-right: 0.5rem; + } + + textarea { + width: 100%; + height: 150px; + } +} + +.verticalForm { + :global { + .control-field { + flex-direction: column; + margin-bottom: 1rem; + + label { + flex-basis: 1em; + } + } + } +} + +.fieldset { + border: none; + margin: 0 0 0.75rem; + padding: 0; + + legend { + @include legend; + } } .comboboxReadonlyField { diff --git a/front/src/components/metadata/MetadataForm.jsx b/front/src/components/metadata/MetadataForm.jsx index b9d3facdb..bc4c12052 100644 --- a/front/src/components/metadata/MetadataForm.jsx +++ b/front/src/components/metadata/MetadataForm.jsx @@ -6,19 +6,21 @@ import { convertLegacyValues } from "./MetadataValues.js"; /** * @param data Values in JSON format - * @param templates List of template names - * @param onChange Function that return the values in YAML format + * @param schema Data schema + * @param uiSchema UI schema + * @param onChange Function that return the values in JSON format * @returns {Element} * @constructor */ -export default function MetadataForm({ data, templates, onChange }) { +export default function MetadataForm({ data, schema, uiSchema, onChange }) { const formData = convertLegacyValues(data) - const basicMode = templates.includes('basic') - return + return } MetadataForm.propTypes = { data: PropTypes.object, + schema: PropTypes.object, + uiSchema: PropTypes.object, templates: PropTypes.array, onChange: PropTypes.func, } diff --git a/front/src/locales/en/translation.json b/front/src/locales/en/translation.json index 52c8d7262..98eb1633d 100644 --- a/front/src/locales/en/translation.json +++ b/front/src/locales/en/translation.json @@ -76,6 +76,8 @@ "modal.cancelButton.text": "Cancel", "modal.confirmButton.text": "Confirm", "modal.close.text": "Close", + "modal.saveButton.text": "Save", + "corpus.type.journal": "Journal", "corpus.createForm.titleField": "Title", "corpus.createForm.descriptionField": "Description", "corpus.createModal.title": "Create a corpus", @@ -90,6 +92,7 @@ "corpus.articlesOrder.toastSuccess": "Articles order successfully updated.", "corpus.articlesOrder.toastFailure": "Unable to update articles order: {{errorMessage}}", "corpus.editModal.title": "Edit a corpus", + "corpus.metadataModal.title": "Edit corpus metadata", "corpus.editForm.buttonText": "Update", "corpus.editForm.buttonTitle": "Update this corpus", "corpus.update.toastSuccess": "Corpus successfully updated.", @@ -261,5 +264,10 @@ "article.metadata.type.interview": "Interview", "article.metadata.type.editorialColumn": "Editorial column", "form.addItem.title": "Add", - "form.removeItem.title": "Remove" + "form.removeItem.title": "Remove", + "corpus.metadata.form.name": "Name", + "corpus.metadata.form.issue": "Issue", + "corpus.metadata.form.issue.title": "Title", + "corpus.metadata.form.issue.number": "N°", + "corpus.metadata.form.issue.identifier": "Identifier" } diff --git a/front/src/locales/fr/translation.json b/front/src/locales/fr/translation.json index bbf6ba6cb..c8e877829 100644 --- a/front/src/locales/fr/translation.json +++ b/front/src/locales/fr/translation.json @@ -76,6 +76,8 @@ "modal.cancelButton.text": "Annuler", "modal.confirmButton.text": "Confirmer", "modal.close.text": "Fermer", + "modal.saveButton.text": "Enregistrer", + "corpus.type.journal": "Revue", "corpus.createForm.titleField": "Titre", "corpus.createForm.descriptionField": "Description", "corpus.createModal.title": "Créer un corpus", @@ -92,6 +94,7 @@ "corpus.articlesOrder.toastFailure": "Impossible de mettre à jour lʼordre des articles : {{errorMessage}}", "corpus.load.toastFailure": "Impossible de charger les corpus : {{errorMessage}}", "corpus.editModal.title": "Modifier un corpus", + "corpus.metadataModal.title": "Modifier les métadonnées du corpus", "corpus.editForm.buttonText": "Mettre à jour", "corpus.editForm.buttonTitle": "Mettre à jour ce corpus", "article.corpus.title": "Corpus", @@ -259,5 +262,10 @@ "article.metadata.type.interview": "Entretien", "article.metadata.type.editorialColumn": "Chronique", "form.addItem.title": "Ajouter", - "form.removeItem.title": "Supprimer" + "form.removeItem.title": "Supprimer", + "corpus.metadata.form.name": "Nom", + "corpus.metadata.form.issue": "Numéro de revue", + "corpus.metadata.form.issue.title": "Titre", + "corpus.metadata.form.issue.number": "N°", + "corpus.metadata.form.issue.identifier": "Identifiant" } diff --git a/front/src/schemas/corpus-journal-ui-schema.json b/front/src/schemas/corpus-journal-ui-schema.json new file mode 100644 index 000000000..baaa52747 --- /dev/null +++ b/front/src/schemas/corpus-journal-ui-schema.json @@ -0,0 +1,28 @@ +{ + "ui:groups": [ + { + "title": "corpus.metadata.form.issue", + "fields": [ + "issue" + ] + } + ], + "type": { + "ui:widget": "hidden" + }, + "name": { + "ui:title": "corpus.metadata.form.name" + }, + "issue": { + "ui:order": ["number", "title", "identifier"], + "number": { + "ui:title": "corpus.metadata.form.issue.number" + }, + "title": { + "ui:title": "corpus.metadata.form.issue.title" + }, + "identifier": { + "ui:title": "corpus.metadata.form.issue.identifier" + } + } +} diff --git a/graphql/models/corpus.js b/graphql/models/corpus.js index 1e55b8e40..31798e299 100644 --- a/graphql/models/corpus.js +++ b/graphql/models/corpus.js @@ -20,8 +20,8 @@ const corpusSchema = new Schema({ default: '' }, metadata: { - type: String, - default: '' + type: Schema.Types.Mixed, + default: {} }, workspace: { type: Schema.Types.ObjectId, diff --git a/graphql/resolvers/index.js b/graphql/resolvers/index.js index 6c38aeff7..9bc04d08b 100644 --- a/graphql/resolvers/index.js +++ b/graphql/resolvers/index.js @@ -7,9 +7,12 @@ const { Corpus, Query: CorpusQuery, Mutation: CorpusMutation } = require('./corp const { Mutation: AuthMutation } = require('./authResolver') const { InstanceUsageStats, Query: StatsQuery } = require('./statsResolver') const { EmailAddressResolver, JWTResolver, HexColorCodeResolver, DateTimeResolver } = require('graphql-scalars') +const { GraphQLJSON, GraphQLJSONObject } = require('./jsonScalar.js') module.exports = { // Custom Scalars + JSON: GraphQLJSON, + JSONObject: GraphQLJSONObject, EmailAddress: EmailAddressResolver, JWT: JWTResolver, HexColorCode: HexColorCodeResolver, diff --git a/graphql/resolvers/jsonScalar.js b/graphql/resolvers/jsonScalar.js new file mode 100644 index 000000000..ce6f4dc95 --- /dev/null +++ b/graphql/resolvers/jsonScalar.js @@ -0,0 +1,79 @@ +// copied/pasted from https://github.com/taion/graphql-type-json/blob/10418fa03875947140d1c0bd8b8de51926252e35/src/index.js +const { GraphQLScalarType } = require('graphql') +const { Kind, print } = require('graphql/language') + +function identity(value) { + return value; +} + +function ensureObject(value) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError( + `JSONObject cannot represent non-object value: ${value}`, + ); + } + + return value; +} + +function parseObject(typeName, ast, variables) { + const value = Object.create(null); + ast.fields.forEach((field) => { + // eslint-disable-next-line no-use-before-define + value[field.name.value] = parseLiteral(typeName, field.value, variables); + }); + + return value; +} + +function parseLiteral(typeName, ast, variables) { + switch (ast.kind) { + case Kind.STRING: + case Kind.BOOLEAN: + return ast.value; + case Kind.INT: + case Kind.FLOAT: + return parseFloat(ast.value); + case Kind.OBJECT: + return parseObject(typeName, ast, variables); + case Kind.LIST: + return ast.values.map((n) => parseLiteral(typeName, n, variables)); + case Kind.NULL: + return null; + case Kind.VARIABLE: + return variables ? variables[ast.name.value] : undefined; + default: + throw new TypeError(`${typeName} cannot represent value: ${print(ast)}`); + } +} + +module.exports = { + GraphQLJSON: new GraphQLScalarType({ + name: 'JSON', + description: + 'The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).', + specifiedByUrl: + 'https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf', + serialize: identity, + parseValue: identity, + parseLiteral: (ast, variables) => parseLiteral('JSON', ast, variables), + }), + GraphQLJSONObject: new GraphQLScalarType({ + name: 'JSONObject', + description: + 'The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).', + specifiedByUrl: + 'https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf', + serialize: ensureObject, + parseValue: ensureObject, + parseLiteral: (ast, variables) => { + if (ast.kind !== Kind.OBJECT) { + throw new TypeError( + `JSONObject cannot represent non-object value: ${print(ast)}`, + ); + } + + return parseObject('JSONObject', ast, variables); + }, + }) +} diff --git a/graphql/schema.js b/graphql/schema.js index 6f3d3d963..4a63c0461 100644 --- a/graphql/schema.js +++ b/graphql/schema.js @@ -2,6 +2,8 @@ const { makeExecutableSchema } = require('@graphql-tools/schema') const resolvers = require('./resolvers/index.js') const typeDefs = `#graphql +scalar JSON +scalar JSONObject scalar EmailAddress scalar JWT scalar DateTime @@ -306,14 +308,14 @@ input ArticleOrder { input UpdateCorpusInput { name: String! description: String - metadata: String + metadata: JSON } type Corpus { _id: String! name: String! description: String - metadata: String + metadata: JSON workspace: String articles: [CorpusArticle!]! creator: User! @@ -325,7 +327,7 @@ type Corpus { # mutations addArticle(articleId: ID!): Corpus rename(name: String!): Corpus - updateMetadata(metadata: String!): Corpus + updateMetadata(metadata: JSON!): Corpus updateArticlesOrder(articlesOrderInput: [ArticleOrder!]!): Corpus delete: Corpus! update(updateCorpusInput: UpdateCorpusInput!): Corpus!