UNPKG

@plone/volto

Version:
359 lines (328 loc) 10.3 kB
/** * VocabularyTermsWidget * @module components/manage/Widgets/VocabularyTermsWidget * Widget for plone.schema.JSONField field meant for a SimpleVocabulary source * VOCABULARY_SCHEMA = json.dumps( { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object", "properties": { "token": {"type": "string"}, "titles": { "type": "object", "properties": { "lang": {"type": "string"}, "title": {"type": "string"}, } }, } } } }, } ) class IPloneconfSettings(Interface): types_of_foo = schema.JSONField( title="Types of Foo", description="Available types of a foo", required=False, schema=VOCABULARY_SCHEMA, widget="vocabularyterms", default={"items": [ { "token": "talk", "titles": { "en": "Talk", "de": "Vortrag", } }, { "token": "lightning-talk", "titles": { "en": "Lightning-Talk", "de": "kürzerer erleuchtender Vortrag", } }, ]}, missing_value={"items": []}, ) @provider(IVocabularyFactory) def TalkTypesVocabulary(context): name = "ploneconf.types_of_talk" registry_record_value = api.portal.get_registry_record(name) items = registry_record_value.get('items', []) lang = api.portal.get_current_language() return SimpleVocabulary.fromItems([[item['token'], item['token'], item['titles'][lang]] for item in items]) * titles are editable * tokens are generated * * Purpose: Use this widget for a controlpanel field * that acts as a source of a vocabulary for a zope.schema.Choice field. * Vocabulary terms should change over time only in title, not value, * as vocabulary term values are stored on content type instances. * * Apply widget with `widget='vocabularyterms'` * Future widget directive coming: Apply widget with directive widget * * See storybook for a demo: Run * `yarn storybook` * or see https://docs.voltocms.com/storybook/ */ import React from 'react'; import { useDispatch } from 'react-redux'; import find from 'lodash/find'; import findIndex from 'lodash/findIndex'; import remove from 'lodash/remove'; import { defineMessages, useIntl } from 'react-intl'; import { v4 as uuid } from 'uuid'; import { Button } from 'semantic-ui-react'; import DragDropList from '@plone/volto/components/manage/DragDropList/DragDropList'; import Icon from '@plone/volto/components/theme/Icon/Icon'; import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper'; import ObjectWidget from '@plone/volto/components/manage/Widgets/ObjectWidget'; import langmap from '@plone/volto/helpers/LanguageMap/LanguageMap'; import deleteSVG from '@plone/volto/icons/delete.svg'; import addSVG from '@plone/volto/icons/add.svg'; import dragSVG from '@plone/volto/icons/drag.svg'; const messages = defineMessages({ title: { id: 'Vocabulary terms', defaultMessage: 'Vocabulary terms', }, termtitle: { id: 'Vocabulary term', defaultMessage: 'Vocabulary term', }, addTerm: { id: 'Add vocabulary term', defaultMessage: 'Add term', }, removeTerm: { id: 'Remove term', defaultMessage: 'Remove term', }, clearTermTitle: { id: 'Reset term title', defaultMessage: 'Reset title', }, termtitlelabel: { id: 'Vocabulary term title', defaultMessage: 'Title', }, }); const VocabularyTermsWidget = (props) => { const { id, value = {}, onChange } = props; var widgetvalue = value; const dispatch = useDispatch(); const [toFocusId, setToFocusId] = React.useState(''); const [editableToken, setEditableToken] = React.useState(''); const intl = useIntl(); React.useEffect(() => { const element = document.getElementById(toFocusId); element && element.focus(); setToFocusId(''); }, [dispatch, toFocusId]); // LEGACY: value from unordered zope.schema.Dict instead of zope.schema.JSONField if (widgetvalue.items === undefined) { widgetvalue = { items: Object.keys(widgetvalue).map((key) => { return { token: key, titles: { en: widgetvalue[key], }, }; }), }; } let vocabularyterms = widgetvalue.items; let supportedLanguages = Object.keys( vocabularyterms?.map((el) => el.titles)?.pop() || {}, ); const TermSchema = { title: 'Translation of term', fieldsets: [ { id: 'default', title: 'Email', fields: supportedLanguages, }, ], properties: Object.fromEntries( supportedLanguages.map((languageIdentifier) => [ languageIdentifier, { title: langmap[languageIdentifier]?.nativeName ?? languageIdentifier, }, ]), ), required: [], }; function onChangeFieldHandler(token, fieldid, fieldvalue) { let index = findIndex(widgetvalue.items, { token: token }); let newitems = widgetvalue.items; newitems.splice(index, 1, { token: token, titles: fieldvalue, }); onChange(id, { items: newitems, }); } function addTermHandler(e) { e.preventDefault(); const newtoken = uuid(); let newitems = widgetvalue.items; newitems.push({ token: newtoken, titles: Object.fromEntries(supportedLanguages.map((el) => [el, ''])), }); onChange(id, { items: newitems, }); setToFocusId(`field-${supportedLanguages[0]}-0-${id}-${newtoken}`); setEditableToken(newtoken); } function swap(arr, from, to) { arr.splice(from, 1, arr.splice(to, 1, arr[from])[0]); } let enhancedvocabularyterms = vocabularyterms.map((el) => { return { ...el, '@id': el.token }; }); return ( <FormFieldWrapper {...props} className="vocabularytermswidget dictwidget"> <div className="add-item-button-wrapper"> <Button aria-label={intl.formatMessage(messages.termtitle)} onClick={(e) => { addTermHandler(e); }} > <Icon name={addSVG} size="18px" /> {intl.formatMessage(messages.addTerm)} </Button> </div> <DragDropList childList={enhancedvocabularyterms.map((o) => [o['@id'], o])} onMoveItem={(result) => { const { source, destination } = result; if (!destination) { return; } let newitems = widgetvalue.items; swap(newitems, source.index, destination.index); onChange(id, { items: newitems, }); return true; }} > {(dragProps) => { const { child, childId, index } = dragProps; let termProps = { index: index, id, vocabularyterms, vterm: child, onChange, }; return termsWrapper( dragProps, <ObjectWidget id={`${id}-${child.token}`} key={childId} onChange={(fieldid, fieldvalue) => { onChangeFieldHandler(child.token, fieldid, fieldvalue); }} value={child.titles} schema={TermSchema} title="Translation of term" />, { editableToken, setEditableToken, ...termProps }, ); }} </DragDropList> </FormFieldWrapper> ); }; const termsWrapper = ({ draginfo }, OW, termProps) => ( <TermsWrapper draginfo={draginfo} termProps={termProps}> {OW} </TermsWrapper> ); const TermsWrapper = (props) => { const intl = useIntl(); const { termProps, draginfo, children } = props; const { id, vocabularyterms, vterm, onChange } = termProps; const _updateTermsWithNewToken = (term, newtoken) => { let newitems = termProps.vocabularyterms; let index = findIndex(newitems, { token: term.token }); newitems.splice(index, 1, { token: newtoken, titles: newitems[index].titles, }); onChange(id, { items: newitems, }); }; function onChangeTokenHandler(event) { let value = event.target.value; // required token length: 3 if (value.length > 2) { // check if value is different from already used tokens if (find(termProps.vocabularyterms, (el) => el.token === value)) { // token already token. Stay with uuid. } else { // `token '${value}' is OK` _updateTermsWithNewToken(vterm, value); termProps.setEditableToken(''); } } } return ( <div ref={draginfo.innerRef} {...draginfo.draggableProps} className="vocabularyterm" > <div style={{ alignItems: 'center', display: 'flex' }}> <div {...draginfo.dragHandleProps} className="draghandlewrapper"> <Icon name={dragSVG} size="18px" /> </div> <div className="ui drag block inner">{children}</div> <div> {vterm.token === termProps.editableToken ? ( <input id={`token-${vterm.token}`} title="Token" placeholder="token" onBlur={onChangeTokenHandler} /> ) : null} <Button icon basic className="delete-button" title={intl.formatMessage(messages.removeTerm)} aria-label={`${intl.formatMessage(messages.removeTerm)} #${ vterm.token }`} onClick={(e) => { e.preventDefault(); remove(vocabularyterms, (el) => el.token === vterm.token); onChange(id, { items: vocabularyterms }); }} > <Icon name={deleteSVG} size="20px" color="#e40166" /> </Button> </div> </div> </div> ); }; export default VocabularyTermsWidget;