diff --git a/codemods/remove-import-if-unused.js b/codemods/remove-import-if-unused.js
new file mode 100644
index 0000000000..b4dff77fa3
--- /dev/null
+++ b/codemods/remove-import-if-unused.js
@@ -0,0 +1,94 @@
+const mkRemoveIfUnused = j => root => {
+ const removeIfUnused = (importSpecifier, importDeclaration) => {
+ if (!importSpecifier.value.local) {
+ return
+ }
+ const varName = importSpecifier.value.local.name
+ if (varName === 'React') {
+ return false
+ }
+
+ const isUsedInScopes = () => {
+ return (
+ j(importDeclaration)
+ .closestScope()
+ .find(j.Identifier, { name: varName })
+ .filter(p => {
+ if (p.value.start === importSpecifier.value.local.start)
+ return false
+ if (p.parentPath.value.type === 'Property' && p.name === 'key')
+ return false
+ if (p.name === 'property') return false
+ return true
+ })
+ .size() > 0
+ )
+ }
+
+ // Caveat, this doesn't work with annonymously exported class declarations.
+ const isUsedInDecorators = () => {
+ // one could probably cache these, but I'm lazy.
+ let used = false
+ root.find(j.ClassDeclaration).forEach(klass => {
+ used =
+ used ||
+ (klass.node.decorators &&
+ j(klass.node.decorators)
+ .find(j.Identifier, { name: varName })
+ .filter(p => {
+ if (p.parentPath.value.type === 'Property' && p.name === 'key')
+ return false
+ if (p.name === 'property') return false
+ return true
+ })
+ .size() > 0)
+ })
+ return used
+ }
+
+ if (!(isUsedInScopes() || isUsedInDecorators())) {
+ j(importSpecifier).remove()
+ return true
+ }
+ return false
+ }
+
+ const removeUnusedDefaultImport = importDeclaration => {
+ return (
+ j(importDeclaration)
+ .find(j.ImportDefaultSpecifier)
+ .filter(s => removeIfUnused(s, importDeclaration))
+ .size() > 0
+ )
+ }
+
+ const removeUnusedNonDefaultImports = importDeclaration => {
+ return (
+ j(importDeclaration)
+ .find(j.ImportSpecifier)
+ .filter(s => removeIfUnused(s, importDeclaration))
+ .size() > 0
+ )
+ }
+
+ // Return True if somethin was transformed.
+ const processImportDeclaration = importDeclaration => {
+ // e.g. import 'styles.css'; // please Don't Touch these imports!
+ if (importDeclaration.value.specifiers.length === 0) return false
+
+ const hadUnusedDefaultImport = removeUnusedDefaultImport(importDeclaration)
+ const hadUnusedNonDefaultImports = removeUnusedNonDefaultImports(
+ importDeclaration
+ )
+
+ if (importDeclaration.value.specifiers.length === 0) {
+ j(importDeclaration).remove()
+ return true
+ }
+ return hadUnusedDefaultImport || hadUnusedNonDefaultImports
+ }
+
+ root.find(j.ImportDeclaration).forEach(processImportDeclaration)
+}
+
+module.exports = mkRemoveIfUnused
diff --git a/codemods/use-i18n-hook/example-after.js b/codemods/use-i18n-hook/example-after.js
new file mode 100644
index 0000000000..ed9caa0ceb
--- /dev/null
+++ b/codemods/use-i18n-hook/example-after.js
@@ -0,0 +1,33 @@
+import React from 'react'
+import { Text, useI18n } from 'cozy-ui/transpiled/react'
+
+const ComponentUsingT = () => {
+ const { t } = useI18n()
+
+ return (
+
+ {t('Title1')}
+ {t('Text1')}
+
+ )
+}
+
+const DumbComponentUsingTAndF = () => {
+ const { t,f } = useI18n()
+
+ return (
+
+ {t('Title2')}
+ {f(new Date())}
+
+ )
+}
+
+const EnhancedComponentUsingTAndF = DumbComponentUsingTAndF
+
+const DumbDefaultSimpleComponent = () => {
+ const { t } = useI18n()
+ return
t('Hello')
+}
+
+export default compose(hoc1, hoc2)(DumbDefaultSimpleComponent)
diff --git a/codemods/use-i18n-hook/example-before.js b/codemods/use-i18n-hook/example-before.js
new file mode 100644
index 0000000000..1abf4ab74b
--- /dev/null
+++ b/codemods/use-i18n-hook/example-before.js
@@ -0,0 +1,28 @@
+import React from 'react'
+import { translate, Text, Modal } from 'cozy-ui/transpiled/react'
+
+import OtherImports from 'other/imports'
+
+const ComponentUsingT = translate()(({ t }) => (
+
+ {t('Title1')}
+ {t('Text1')}
+
+))
+
+const DumbComponentUsingTAndF = ({ t, f }) => (
+
+ {t('Title2')}
+ {f(new Date())}
+
+)
+
+const EnhancedComponentUsingTAndF = translate()(DumbComponentUsingTAndF)
+
+const DumbDefaultSimpleComponent = ({ t }) => t('Hello')
+
+export default compose(
+ hoc1,
+ hoc2,
+ translate()
+)(DumbDefaultSimpleComponent)
diff --git a/codemods/use-i18n-hook/index.js b/codemods/use-i18n-hook/index.js
new file mode 100644
index 0000000000..1355cea48c
--- /dev/null
+++ b/codemods/use-i18n-hook/index.js
@@ -0,0 +1,157 @@
+import makeUtils from '../utils'
+
+const prepend = (arr, item) => {
+ arr.splice(0, 0, item)
+}
+
+const isI18nProp = prop => {
+ return prop.key && (prop.key.name === 't' || prop.key.name === 'f')
+}
+
+const findI18nProps = objPattern => {
+ if (!objPattern) {
+ return
+ }
+ if (objPattern.type !== 'ObjectPattern') {
+ return
+ }
+ return objPattern.properties
+ ? objPattern.properties.filter(isI18nProp).map(prop => prop.key.name)
+ : []
+}
+
+const findNearest = (path, condition) => {
+ while (path && !condition(path) && path.parentPath) {
+ path = path.parentPath
+ }
+ return path
+}
+
+const findPropObjectPattern = (j, functionBodyPath) => {
+ const functionBody = functionBodyPath.node
+ const propsArg = functionBody.params[0]
+ const propObjPattern =
+ propsArg && propsArg.type === 'ObjectPattern' ? propsArg : null
+ if (propObjPattern) {
+ return { objPattern: propObjPattern, from: 'params' }
+ }
+
+ const bodyPropsDeclarators = j(functionBodyPath).find(j.VariableDeclarator, {
+ init: {
+ name: 'props'
+ }
+ })
+ const bodyPropsDeclarator =
+ bodyPropsDeclarators.length > 0 ? bodyPropsDeclarators.get(0) : 0
+
+ if (
+ bodyPropsDeclarator &&
+ bodyPropsDeclarator.node.id.type === 'ObjectPattern'
+ ) {
+ return {
+ objPattern: bodyPropsDeclarator.node.id,
+ from: 'body',
+ declarator: bodyPropsDeclarator
+ }
+ }
+}
+
+export default function transformer(file, api) {
+ const j = api.jscodeshift
+ const utils = makeUtils(j)
+ const root = j(file.source)
+
+ const replaceI18nPropsByHook = arrowFunctionBodyPath => {
+ const arrowFunctionBody = arrowFunctionBodyPath.node
+ const objPattern = findPropObjectPattern(j, arrowFunctionBodyPath)
+ if (!objPattern) {
+ return
+ }
+
+ const {
+ objPattern: propObjPattern,
+ from: objPatternOrigin,
+ declarator: objPatternDeclarator
+ } = objPattern
+
+ const i18nProps = findI18nProps(propObjPattern)
+
+ if (!i18nProps || !i18nProps.length) {
+ return
+ }
+
+ if (!arrowFunctionBody.body.body || !arrowFunctionBody.body.body.splice) {
+ arrowFunctionBody.body = j.blockStatement([
+ j.returnStatement(arrowFunctionBody.body)
+ ])
+ }
+
+ const updatedProperties = propObjPattern.properties.filter(
+ prop => !isI18nProp(prop)
+ )
+
+ if (
+ updatedProperties.length === 0 &&
+ arrowFunctionBody.params.length === 1 &&
+ objPatternOrigin === 'params'
+ ) {
+ arrowFunctionBody.params = []
+ } else if (objPatternOrigin === 'params') {
+ arrowFunctionBody.params[0].properties = updatedProperties
+ } else if (updatedProperties.length > 0 && objPatternOrigin === 'body') {
+ objPatternDeclarator.node.id.properties = updatedProperties
+ } else if (updatedProperties === 0 && objPatternOrigin === 'body') {
+ objPatternDeclarator.prune()
+ }
+
+ prepend(arrowFunctionBody.body.body, `const { ${i18nProps} } = useI18n()`)
+ utils.hoc.removeHOC(arrowFunctionBodyPath, 'translate')
+
+ const declarator = findNearest(
+ arrowFunctionBodyPath,
+ x => x.node.type === 'VariableDeclarator'
+ )
+ const ComponentName = declarator.node.id.name
+ j(declarator)
+ .closestScope()
+ .find(j.Identifier, {
+ name: ComponentName
+ })
+ .forEach(path => {
+ utils.hoc.removeHOC(path, 'translate')
+ })
+
+ utils.hoc.removeDefaultExportHOC(root, ComponentName, 'translate')
+ return true
+ }
+
+ const components = root.find(j.ArrowFunctionExpression)
+
+ let needToAddUseI18nImport = false
+ components.forEach(path => {
+ if (replaceI18nPropsByHook(path)) {
+ needToAddUseI18nImport = true
+ }
+ })
+
+ if (needToAddUseI18nImport) {
+ utils.imports.add(
+ root,
+ {
+ useI18n: true
+ },
+ x => {
+ return (
+ x.source.value == 'cozy-ui/transpiled/react' ||
+ x.source.value == 'cozy-ui/react'
+ )
+ },
+ 'cozy-ui/transpiled/react'
+ )
+ utils.simplifyCompose(root)
+ utils.imports.removeUnused(root)
+ return root.toSource()
+ }
+
+ return null
+}
diff --git a/codemods/utils.js b/codemods/utils.js
index d9d4e6078f..93b2b80eac 100644
--- a/codemods/utils.js
+++ b/codemods/utils.js
@@ -1,35 +1,144 @@
+import makeRemoveImportIfUnused from './remove-import-if-unused'
+
/* eslint new-cap: 0 */
-module.exports = function (j) {
- const strip = s => s.replace(/^[\s\n]+/g, "").replace(/[\s\n]+$/g, "");
+module.exports = function(j) {
+ const strip = s => s.replace(/^[\s\n]+/g, '').replace(/[\s\n]+$/g, '')
const isJsxElementOfClass = (element, klass) => {
- return element.openingElement && element.openingElement.name.name === klass;
- };
+ return element.openingElement && element.openingElement.name.name === klass
+ }
const addJsxAttribute = (element, name, value) => {
element.openingElement.attributes.push(
- new j.jsxAttribute(
- new j.jsxIdentifier(name),
- maybeWrap(value)));
- };
+ new j.jsxAttribute(new j.jsxIdentifier(name), maybeWrap(value))
+ )
+ }
- const getAttributeName = x => x && x.name && x.name.name;
+ const getAttributeName = x => x && x.name && x.name.name
const getAttributeValue = x => x && x.value && x.value.value
const maybeWrap = x => {
- if (x.type === "Literal") {
- return new j.literal(strip(x.value));
+ if (x.type === 'Literal') {
+ return new j.literal(strip(x.value))
}
if (x.type === 'JSXExpressionContainer') {
return x
}
- return new j.jsxExpressionContainer(x);
- };
+ return new j.jsxExpressionContainer(x)
+ }
const emptyTextNode = x => {
- return x.type === "Literal" && strip(x.value).length === 0;
- };
+ return x.type === 'Literal' && strip(x.value).length === 0
+ }
+
+ const removeDefaultExportHOC = (root, ComponentName, hocName) => {
+ const defaultExports = root.find(j.ExportDefaultDeclaration)
+ if (!defaultExports.length) {
+ return
+ }
+ const defaultExport = defaultExports.get(0)
+ const decl = defaultExport.node.declaration
+ if (decl.type !== 'CallExpression') {
+ return
+ } else if (
+ decl.callee &&
+ decl.callee.callee &&
+ decl.callee.callee.name == hocName &&
+ decl.arguments[0].name == ComponentName
+ ) {
+ defaultExport.node.declaration = decl.arguments[0]
+ } else if (
+ decl.callee &&
+ decl.callee.callee &&
+ decl.callee.callee.name == 'compose' &&
+ decl.arguments[0].name == ComponentName
+ ) {
+ decl.callee.arguments = decl.callee.arguments.filter(
+ node => !node.callee || node.callee.name !== hocName
+ )
+ }
+ }
+
+ const removeHOC = (arrowFunctionBodyPath, hocName) => {
+ let curPath = arrowFunctionBodyPath
+ while (curPath) {
+ const curNode = curPath.node
+ if (
+ curNode.type === 'CallExpression' &&
+ curNode.callee.callee &&
+ curNode.callee.callee.name === hocName
+ ) {
+ const component = curPath.parentPath.node.arguments[0]
+ curPath.parentPath.replace(curPath.parentPath.node.arguments[0])
+ break
+ }
+ curPath = curPath.parentPath
+ }
+ }
+
+ const isSameSpec = (eSpec, spec) => {
+ return eSpec.imported.name == spec.imported.name
+ }
+
+ const mergeSpecifiersToImport = (importDeclaration, specifiers) => {
+ for (const spec of specifiers) {
+ if (importDeclaration.specifiers.find(eSpec => isSameSpec(eSpec, spec))) {
+ console.log('mergeSpecifiersToImport continue')
+ continue
+ } else {
+ importDeclaration.specifiers.push(spec)
+ }
+ }
+ }
+
+ const addImport = (root, specifierObj, sourceOrFilter, maybeSource) => {
+ const source = maybeSource || sourceOrFilter
+ const filter = maybeSource ? sourceOrFilter : null
+
+ const specifiers = Object.entries(specifierObj).map(([k, v]) => {
+ return k === 'default'
+ ? j.importDefaultSpecifier(j.identifier(v))
+ : j.importSpecifier(j.identifier(k))
+ })
+
+ const program = root.find(j.Program).get(0)
+
+ const matchingImports = root.find(
+ j.ImportDeclaration,
+ filter
+ ? filter
+ : {
+ source: {
+ value: source
+ }
+ }
+ )
+
+ if (matchingImports.length > 0) {
+ mergeSpecifiersToImport(matchingImports.get(0).node, specifiers)
+ } else {
+ const imports = root.find(j.ImportDeclaration)
+ const decl = j.importDeclaration(specifiers, j.literal(source))
+ imports.at(-1).insertAfter(decl)
+ }
+ }
+
+ const simplifyCompose = root => {
+ root
+ .find(j.CallExpression, {
+ callee: {
+ callee: {
+ name: 'compose'
+ }
+ }
+ })
+ .forEach(path => {
+ if (path.node.callee.arguments.length === 1) {
+ path.node.callee = path.node.callee.arguments[0]
+ }
+ })
+ }
return {
nodes: {
@@ -41,6 +150,15 @@ module.exports = function (j) {
maybeWrap: maybeWrap,
getAttributeValue,
getAttributeName
- }
- };
+ },
+ hoc: {
+ removeDefaultExportHOC,
+ removeHOC
+ },
+ imports: {
+ add: addImport,
+ removeUnused: makeRemoveImportIfUnused(j)
+ },
+ simplifyCompose
+ }
}
diff --git a/package.json b/package.json
index 90d34bda93..c88ed0f645 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,9 @@
"screenshots": "node scripts/screenshots.js --screenshot-dir ./screenshots --styleguide-dir ./build/react",
"test": "yarn test:jest",
"test:jest": "jest --verbose",
- "transpile": "env BABEL_ENV=transpilation babel react/ --out-dir transpiled/react --verbose && babel helpers/ --out-dir transpiled/helpers --verbose",
+ "transpile:react": "env BABEL_ENV=transpilation babel react/ --out-dir transpiled/react --verbose",
+ "transpile:helpers": "babel helpers/ --out-dir transpiled/helpers --verbose",
+ "transpile": "yarn transpile:react && yarn transpile:helpers",
"posttranspile": "postcss transpiled/react/stylesheet.css --replace",
"travis-deploy-once": "travis-deploy-once",
"watch:css": "yarn build:css --watch",
diff --git a/react/AppSections/__snapshots__/index.spec.jsx.snap b/react/AppSections/__snapshots__/index.spec.jsx.snap
index 8f3b2f707c..2d479c8fb7 100644
--- a/react/AppSections/__snapshots__/index.spec.jsx.snap
+++ b/react/AppSections/__snapshots__/index.spec.jsx.snap
@@ -9,7 +9,7 @@ exports[`AppsSection component should be rendered correctly with apps list, subt
-
}
/>
-
}
/>
-
Services
-
}
/>
-
}
/>
-
-
}
/>
-
}
/>
-
Services
-
}
/>
-
}
/>
-
-
}
/>
-
}
/>
-
Services
-
}
/>
-
}
/>
-
-
}
/>
-
}
/>
-
Services
-
}
/>
-
}
/>
-
-
}
/>
-
}
/>
-
Services
-
}
/>
-
}
/>
- {
onAppClick={onAppClick}
/>
- ).dive()
+ )
+ .dive()
+ .dive()
}
it('should be rendered correctly with apps list, subtitle and onAppClick', () => {
const mockOnAppClick = jest.fn()
diff --git a/react/AppSections/components/__snapshots__/AppsSection.spec.jsx.snap b/react/AppSections/components/__snapshots__/AppsSection.spec.jsx.snap
index 0eb8c6ea27..be1b519469 100644
--- a/react/AppSections/components/__snapshots__/AppsSection.spec.jsx.snap
+++ b/react/AppSections/components/__snapshots__/AppsSection.spec.jsx.snap
@@ -10,7 +10,7 @@ exports[`AppsSection component should be rendered correctly with apps list, subt
-
-
-
-
-
-
-
+ {this.props.children}
+
+ )
}
}
@@ -64,35 +76,36 @@ I18n.defaultProps = {
defaultLang: DEFAULT_LANG
}
-const i18nContextTypes = {
+I18n.childContextTypes = {
t: PropTypes.func,
f: PropTypes.func,
lang: PropTypes.string
}
-I18n.childContextTypes = i18nContextTypes
-
// higher order decorator for components that need `t` and/or `f`
export const translate = () => WrappedComponent => {
- const Wrapper = (props, context) => {
+ const Wrapper = props => {
+ const i18nContext = useContext(I18nContext)
return (
)
}
+ Wrapper.displayName = `withI18n(${WrappedComponent.displayName ||
+ WrappedComponent.name})`
Wrapper.propTypes = {
//!TODO Remove this check after fixing https://github.com/cozy/cozy-drive/issues/1848
- ...(WrappedComponent ? WrappedComponent.propTypes : undefined),
- ...i18nContextTypes
+ ...(WrappedComponent ? WrappedComponent.propTypes : undefined)
}
- Wrapper.contextTypes = i18nContextTypes
return Wrapper
}
+export const useI18n = () => useContext(I18nContext)
+
export { initTranslation, extend } from './translation'
export default I18n
diff --git a/react/I18n/index.spec.jsx b/react/I18n/index.spec.jsx
new file mode 100644
index 0000000000..8472af8af8
--- /dev/null
+++ b/react/I18n/index.spec.jsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import I18n, { useI18n } from '.'
+
+const locales = { helloworld: 'Hello World !' }
+
+const DumbI18nHelloWorld = ({ t, f, lang }) => (
+
+ {t('helloworld')}
+
+ {f('2020-01-06', 'DDD MMM')}
+
+ {lang}
+
+)
+
+const I18nHelloWorldHook = () => {
+ const { t, f, lang } = useI18n()
+ return
+}
+
+const I18nHelloWorldOldAPI = (props, context) => {
+ const { t, f, lang } = context
+ return
+}
+
+I18nHelloWorldOldAPI.contextTypes = {
+ t: PropTypes.func,
+ f: PropTypes.func,
+ lang: PropTypes.string
+}
+
+describe('new context api', () => {
+ it('should provide t and f and lang through useI18n hook', () => {
+ const root = mount(
+ locales}>
+
+
+ )
+ expect(root.html()).toBe('Hello World !
6 Jan
en
')
+ })
+})
+
+describe('old context api', () => {
+ it('should provide t and f and lang through old context API', () => {
+ const root = mount(
+ locales}>
+
+
+ )
+ expect(root.html()).toBe('Hello World !
6 Jan
en
')
+ })
+})
diff --git a/react/Viewer/index.spec.jsx b/react/Viewer/index.spec.jsx
index 929fce56c7..72c590bc24 100644
--- a/react/Viewer/index.spec.jsx
+++ b/react/Viewer/index.spec.jsx
@@ -1,5 +1,6 @@
import React from 'react'
import { Viewer, isPlainText } from './index.jsx'
+import ViewerControls from './ViewerControls'
function flushPromises() {
return new Promise(resolve => setImmediate(resolve))
@@ -26,7 +27,7 @@ describe('Viewer', () => {
await flushPromises()
return viewer
} else {
- const viewer = wrapper.find('Wrapper').childAt(0)
+ const viewer = wrapper.find(ViewerControls.displayName).childAt(0)
return viewer
}
}
diff --git a/react/index.js b/react/index.js
index 37fcaff33c..b05fc2ad8a 100644
--- a/react/index.js
+++ b/react/index.js
@@ -5,7 +5,7 @@ export { default as ButtonClient } from './PushClientButton'
export { default as BannerClient } from './PushClientBanner'
export { default as ButtonAction } from './ButtonAction'
export { default as BarButton } from './BarButton'
-export { default as I18n, translate } from './I18n'
+export { default as I18n, translate, useI18n } from './I18n'
export { default as Icon, Sprite as IconSprite } from './Icon'
export { default as Sidebar } from './Sidebar'
export {
diff --git a/react/jestLib/I18n.js b/react/jestLib/I18n.js
index 4160faf634..826a9f460b 100644
--- a/react/jestLib/I18n.js
+++ b/react/jestLib/I18n.js
@@ -9,5 +9,5 @@ export const I18nContext = options => {
dictRequire: () => options.locale
})
- return I18nComponent.getChildContext()
+ return I18nComponent.getContextValue()
}