mirror of
https://github.com/strapi/strapi.git
synced 2025-08-07 08:16:35 +00:00
[I18N] add tabs for navigating between forms (#9364)
This commit is contained in:
parent
9fd676e62c
commit
6c23b08920
@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { Flex, Text } from '@buffetjs/core';
|
||||||
|
|
||||||
|
export const TabNavRaw = styled(props => <Flex flexDirection="column" {...props} />)`
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TabsRaw = styled(props => (
|
||||||
|
<Flex alignItems="center" justifyContent="flex-end" {...props} />
|
||||||
|
))`
|
||||||
|
width: 100%;
|
||||||
|
margin-left: ${({ position }) => (position === 'right' ? 'auto' : 0)};
|
||||||
|
border-bottom: 1px solid ${({ theme }) => theme.main.colors.brightGrey};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TabButton = styled(props => (
|
||||||
|
<Text
|
||||||
|
as="button"
|
||||||
|
textTransform="uppercase"
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight={props['aria-selected'] ? 'bold' : 'semiBold'}
|
||||||
|
color={props['aria-selected'] ? 'mediumBlue' : 'grey'}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))`
|
||||||
|
height: 3.8rem;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
margin-left: 3rem;
|
||||||
|
border-bottom: 2px solid
|
||||||
|
${props => (props['aria-selected'] ? props.theme.main.colors.mediumBlue : 'transparent')};
|
||||||
|
padding: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TabPanelRaw = styled.div`
|
||||||
|
padding: 2.2rem 0;
|
||||||
|
`;
|
129
packages/strapi-helper-plugin/lib/src/components/Tabs/index.js
Normal file
129
packages/strapi-helper-plugin/lib/src/components/Tabs/index.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import React, { createContext, useContext, useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { TabNavRaw, TabButton, TabsRaw, TabPanelRaw } from './TabComponents';
|
||||||
|
|
||||||
|
const TabsIndexContext = createContext({ selectedIndex: 0, setSelectedIndex: () => undefined });
|
||||||
|
const TabsIdContext = createContext(null);
|
||||||
|
|
||||||
|
export const TabsNav = ({ children, defaultSelection, label, id }) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(defaultSelection);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsIdContext.Provider value={id}>
|
||||||
|
<TabsIndexContext.Provider value={{ selectedIndex, setSelectedIndex }}>
|
||||||
|
<TabNavRaw role="tablist" aria-label={label}>
|
||||||
|
{children}
|
||||||
|
</TabNavRaw>
|
||||||
|
</TabsIndexContext.Provider>
|
||||||
|
</TabsIdContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TabsNav.propTypes = {
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
defaultSelection: PropTypes.number.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tabs = ({ children, position }) => {
|
||||||
|
const id = useContext(TabsIdContext);
|
||||||
|
const { setSelectedIndex, selectedIndex } = useContext(TabsIndexContext);
|
||||||
|
const childrenArray = React.Children.toArray(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsRaw position={position}>
|
||||||
|
{childrenArray.map((child, index) =>
|
||||||
|
React.cloneElement(child, {
|
||||||
|
onSelect: () => setSelectedIndex(index),
|
||||||
|
selected: index === selectedIndex,
|
||||||
|
id: `${id}-${index}`,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TabsRaw>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Tabs.defaultProps = {
|
||||||
|
position: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
Tabs.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
position: PropTypes.oneOf(['left', 'right']),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TabsPanel = ({ children }) => {
|
||||||
|
const { selectedIndex } = useContext(TabsIndexContext);
|
||||||
|
const id = useContext(TabsIdContext);
|
||||||
|
const childrenArray = React.Children.toArray(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{childrenArray.map((child, index) =>
|
||||||
|
React.cloneElement(child, { selected: index === selectedIndex, id: `${id}-${index}` })
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TabsPanel.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tab = ({ children, selected, onSelect, id }) => {
|
||||||
|
const ariaControls = `${id}-tabpanel`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabButton
|
||||||
|
role="tab"
|
||||||
|
id={`${id}-tab`}
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-controls={ariaControls}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={onSelect}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TabButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Tab.defaultProps = {
|
||||||
|
selected: false,
|
||||||
|
id: '',
|
||||||
|
onSelect: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
Tab.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
selected: PropTypes.bool,
|
||||||
|
onSelect: PropTypes.func,
|
||||||
|
id: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TabPanel = ({ children, selected, id }) => {
|
||||||
|
const labelledBy = `${id}-tab`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanelRaw
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
hidden={!selected}
|
||||||
|
id={`${id}-tabpanel`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TabPanelRaw>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TabPanel.defaultProps = {
|
||||||
|
id: '',
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
TabPanel.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
selected: PropTypes.bool,
|
||||||
|
id: PropTypes.string,
|
||||||
|
};
|
@ -25,6 +25,7 @@ export { default as HeaderSearch } from './components/HeaderSearch';
|
|||||||
export { default as IcoContainer } from './components/IcoContainer';
|
export { default as IcoContainer } from './components/IcoContainer';
|
||||||
export { default as InputAddon } from './components/InputAddon';
|
export { default as InputAddon } from './components/InputAddon';
|
||||||
export { default as EmptyState } from './components/EmptyState';
|
export { default as EmptyState } from './components/EmptyState';
|
||||||
|
export * from './components/Tabs';
|
||||||
|
|
||||||
export { default as InputAddonWithErrors } from './components/InputAddonWithErrors';
|
export { default as InputAddonWithErrors } from './components/InputAddonWithErrors';
|
||||||
export { default as InputCheckbox } from './components/InputCheckbox';
|
export { default as InputCheckbox } from './components/InputCheckbox';
|
||||||
|
@ -30,12 +30,15 @@ const LocaleSettingsPage = ({ locale, onDelete, onEdit }) => {
|
|||||||
<FontAwesomeIcon icon="trash-alt" />
|
<FontAwesomeIcon icon="trash-alt" />
|
||||||
</span>
|
</span>
|
||||||
) : null,
|
) : null,
|
||||||
onClick: () => onDelete(locale),
|
onClick: e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(locale);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomRow>
|
<CustomRow onClick={() => onEdit(locale)}>
|
||||||
<td>
|
<td>
|
||||||
<Text>{locale.code}</Text>
|
<Text>{locale.code}</Text>
|
||||||
</td>
|
</td>
|
||||||
|
@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Label } from '@buffetjs/core';
|
||||||
|
import { Inputs } from '@buffetjs/custom';
|
||||||
|
import Select from 'react-select';
|
||||||
|
import { Col, Row } from 'reactstrap';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
import { getTrad } from '../../utils';
|
||||||
|
|
||||||
|
const BaseForm = ({ options, defaultOption }) => {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { values, handleChange } = useFormikContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<span id="locale-code">
|
||||||
|
<Label htmlFor="">
|
||||||
|
{formatMessage({
|
||||||
|
id: getTrad('Settings.locales.modal.edit.locales.label'),
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
aria-labelledby="locale-code"
|
||||||
|
options={options}
|
||||||
|
defaultValue={defaultOption}
|
||||||
|
isDisabled
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Inputs
|
||||||
|
label={formatMessage({
|
||||||
|
id: getTrad('Settings.locales.modal.edit.locales.displayName'),
|
||||||
|
})}
|
||||||
|
name="displayName"
|
||||||
|
description={formatMessage({
|
||||||
|
id: getTrad('Settings.locales.modal.edit.locales.displayName.description'),
|
||||||
|
})}
|
||||||
|
type="text"
|
||||||
|
value={values.displayName}
|
||||||
|
onChange={handleChange}
|
||||||
|
validations={{
|
||||||
|
max: 50,
|
||||||
|
}}
|
||||||
|
translatedErrors={{
|
||||||
|
max: formatMessage({
|
||||||
|
id: getTrad('Settings.locales.modal.edit.locales.displayName.error'),
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
BaseForm.defaultProps = {
|
||||||
|
defaultOption: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
BaseForm.propTypes = {
|
||||||
|
options: PropTypes.arrayOf(
|
||||||
|
PropTypes.exact({ value: PropTypes.number.isRequired, label: PropTypes.string.isRequired })
|
||||||
|
).isRequired,
|
||||||
|
defaultOption: PropTypes.exact({
|
||||||
|
value: PropTypes.number.isRequired,
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BaseForm;
|
@ -1,13 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Modal, ModalHeader, ModalSection, ModalFooter } from 'strapi-helper-plugin';
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalHeader,
|
||||||
|
HeaderModal,
|
||||||
|
HeaderModalTitle,
|
||||||
|
ModalFooter,
|
||||||
|
ModalForm,
|
||||||
|
Tabs,
|
||||||
|
TabsNav,
|
||||||
|
Tab,
|
||||||
|
TabsPanel,
|
||||||
|
TabPanel,
|
||||||
|
} from 'strapi-helper-plugin';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { Button, Label, InputText } from '@buffetjs/core';
|
import { Button } from '@buffetjs/core';
|
||||||
import Select from 'react-select';
|
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { object, string } from 'yup';
|
import { object, string } from 'yup';
|
||||||
import useEditLocale from '../../hooks/useEditLocale';
|
import useEditLocale from '../../hooks/useEditLocale';
|
||||||
import { getTrad } from '../../utils';
|
import { getTrad } from '../../utils';
|
||||||
|
import BaseForm from './BaseForm';
|
||||||
|
|
||||||
const ModalEdit = ({ localeToEdit, onClose, locales }) => {
|
const ModalEdit = ({ localeToEdit, onClose, locales }) => {
|
||||||
const { isEditing, editLocale } = useEditLocale();
|
const { isEditing, editLocale } = useEditLocale();
|
||||||
@ -31,6 +43,12 @@ const ModalEdit = ({ localeToEdit, onClose, locales }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpened} onToggle={onClose}>
|
<Modal isOpen={isOpened} onToggle={onClose}>
|
||||||
|
<HeaderModal>
|
||||||
|
<ModalHeader
|
||||||
|
headerBreadcrumbs={[formatMessage({ id: getTrad('Settings.list.actions.edit') })]}
|
||||||
|
/>
|
||||||
|
</HeaderModal>
|
||||||
|
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{ displayName: localeToEdit ? localeToEdit.name : '' }}
|
initialValues={{ displayName: localeToEdit ? localeToEdit.name : '' }}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@ -38,44 +56,49 @@ const ModalEdit = ({ localeToEdit, onClose, locales }) => {
|
|||||||
displayName: string().max(50, 'Settings.locales.modal.edit.locales.displayName.error'),
|
displayName: string().max(50, 'Settings.locales.modal.edit.locales.displayName.error'),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{({ values, handleSubmit, handleChange, errors }) => (
|
{({ handleSubmit, errors }) => (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<ModalHeader
|
<div className="container-fluid">
|
||||||
headerBreadcrumbs={[formatMessage({ id: getTrad('Settings.list.actions.edit') })]}
|
<div className="container-fluid">
|
||||||
/>
|
<HeaderModalTitle
|
||||||
<ModalSection>
|
style={{
|
||||||
<div>
|
fontSize: '1.8rem',
|
||||||
<span id="locale-code">
|
height: '65px',
|
||||||
<Label htmlFor="">
|
fontWeight: 'bold',
|
||||||
{formatMessage({ id: getTrad('Settings.locales.modal.edit.locales.label') })}
|
alignItems: 'center',
|
||||||
</Label>
|
marginBottom: '-39px',
|
||||||
</span>
|
paddingTop: '16px',
|
||||||
|
}}
|
||||||
<Select
|
>
|
||||||
aria-labelledby="locale-code"
|
|
||||||
options={options}
|
|
||||||
defaultValue={defaultOption}
|
|
||||||
isDisabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="displayName">
|
|
||||||
{formatMessage({
|
{formatMessage({
|
||||||
id: getTrad('Settings.locales.modal.edit.locales.displayName'),
|
id: getTrad('Settings.locales.modal.title'),
|
||||||
})}
|
})}
|
||||||
</Label>
|
</HeaderModalTitle>
|
||||||
<InputText name="displayName" value={values.displayName} onChange={handleChange} />
|
|
||||||
|
|
||||||
{errors.displayName && (
|
<ModalForm>
|
||||||
<small>
|
<TabsNav
|
||||||
{formatMessage({
|
defaultSelection={0}
|
||||||
id: getTrad(' Settings.locales.modal.edit.locales.displayName.error'),
|
label={formatMessage({
|
||||||
|
id: getTrad('Settings.locales.modal.edit.tab.label'),
|
||||||
})}
|
})}
|
||||||
</small>
|
id="i18n-settings-tabs"
|
||||||
)}
|
>
|
||||||
|
<Tabs position="right">
|
||||||
|
<Tab>{formatMessage({ id: getTrad('Settings.locales.modal.base') })}</Tab>
|
||||||
|
<Tab>{formatMessage({ id: getTrad('Settings.locales.modal.advanced') })}</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<TabsPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<BaseForm options={options} defaultOption={defaultOption} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>advanced</TabPanel>
|
||||||
|
</TabsPanel>
|
||||||
|
</TabsNav>
|
||||||
|
</ModalForm>
|
||||||
</div>
|
</div>
|
||||||
</ModalSection>
|
</div>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<section>
|
<section>
|
||||||
<Button type="button" color="cancel" onClick={onClose}>
|
<Button type="button" color="cancel" onClick={onClose}>
|
||||||
|
@ -30,7 +30,15 @@ jest.mock('strapi-helper-plugin', () => ({
|
|||||||
ModalHeader: ({ children }) => <div>{children}</div>,
|
ModalHeader: ({ children }) => <div>{children}</div>,
|
||||||
ModalSection: ({ children }) => <div>{children}</div>,
|
ModalSection: ({ children }) => <div>{children}</div>,
|
||||||
ModalFooter: ({ children }) => <div>{children}</div>,
|
ModalFooter: ({ children }) => <div>{children}</div>,
|
||||||
|
HeaderModal: ({ children }) => <div>{children}</div>,
|
||||||
|
HeaderModalTitle: ({ children }) => <div>{children}</div>,
|
||||||
|
ModalForm: ({ children }) => <div>{children}</div>,
|
||||||
ListButton: () => <div />,
|
ListButton: () => <div />,
|
||||||
|
Tabs: ({ children }) => <div>{children}</div>,
|
||||||
|
TabsNav: ({ children }) => <div>{children}</div>,
|
||||||
|
Tab: ({ children }) => <div>{children}</div>,
|
||||||
|
TabsPanel: ({ children }) => <div>{children}</div>,
|
||||||
|
TabPanel: ({ children }) => <div>{children}</div>,
|
||||||
useUserPermissions: jest.fn(),
|
useUserPermissions: jest.fn(),
|
||||||
request: jest.fn(),
|
request: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@ -166,6 +174,7 @@ describe('i18n settings page', () => {
|
|||||||
'a very very very very long string that has more than fifty characters in order to show a warning',
|
'a very very very very long string that has more than fifty characters in order to show a warning',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
fireEvent.blur(screen.getByLabelText('Settings.locales.modal.edit.locales.displayName'));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('Settings.locales.modal.edit.confirmation')).toBeDisabled()
|
expect(screen.getByText('Settings.locales.modal.edit.confirmation')).toBeDisabled()
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
"Settings.locales.list.title.singular": "{number} Locale",
|
"Settings.locales.list.title.singular": "{number} Locale",
|
||||||
"Settings.locales.list.title.plural": "{number} Locales",
|
"Settings.locales.list.title.plural": "{number} Locales",
|
||||||
"Settings.locales.row.default-locale": "Default locale",
|
"Settings.locales.row.default-locale": "Default locale",
|
||||||
|
"Settings.locales.modal.title": "Configurations",
|
||||||
"Settings.locales.modal.delete.confirm": "Yes, delete",
|
"Settings.locales.modal.delete.confirm": "Yes, delete",
|
||||||
"Settings.locales.modal.delete.message": "Deleting this locale will delete all associated content. If you want to keep some content, make sure to reallocate it to another locale first.",
|
"Settings.locales.modal.delete.message": "Deleting this locale will delete all associated content. If you want to keep some content, make sure to reallocate it to another locale first.",
|
||||||
"Settings.locales.modal.delete.secondMessage": "Do you want to delete this locale?",
|
"Settings.locales.modal.delete.secondMessage": "Do you want to delete this locale?",
|
||||||
@ -31,5 +32,9 @@
|
|||||||
"Settings.locales.modal.edit.success": "Locale successfully edited",
|
"Settings.locales.modal.edit.success": "Locale successfully edited",
|
||||||
"Settings.locales.modal.edit.locales.label": "Locales",
|
"Settings.locales.modal.edit.locales.label": "Locales",
|
||||||
"Settings.locales.modal.edit.locales.displayName": "Locale display name",
|
"Settings.locales.modal.edit.locales.displayName": "Locale display name",
|
||||||
"Settings.locales.modal.edit.locales.displayName.error": "The locale display name can only be less than 50 characters."
|
"Settings.locales.modal.edit.locales.displayName.error": "The locale display name can only be less than 50 characters.",
|
||||||
|
"Settings.locales.modal.edit.locales.displayName.description":"Locale will be displayed under that name in the administration panel",
|
||||||
|
"Settings.locales.modal.edit.tab.label":"Navigating between the I18N base settings and advanced settings",
|
||||||
|
"Settings.locales.modal.base": "Base settings",
|
||||||
|
"Settings.locales.modal.advanced": "Advanced settings"
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user