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() }