├── gmail ├── assets │ └── img │ │ ├── odoo.png │ │ ├── readme.png │ │ └── odoo_full.png ├── .clasp.json ├── .prettierrc ├── src │ ├── global.d.ts │ ├── services │ │ ├── app_properties.ts │ │ ├── log_email.ts │ │ ├── translation.ts │ │ └── odoo_auth.ts │ ├── utils │ │ ├── html.ts │ │ ├── format.ts │ │ └── http.ts │ ├── const.ts │ ├── views │ │ ├── index.ts │ │ ├── debug.ts │ │ ├── card_actions.ts │ │ ├── error.ts │ │ ├── partner_actions.ts │ │ ├── tasks.ts │ │ ├── helpers.ts │ │ ├── tickets.ts │ │ ├── login.ts │ │ ├── leads.ts │ │ ├── search_partner.ts │ │ ├── partner.ts │ │ └── create_task.ts │ ├── main.ts │ └── models │ │ ├── ticket.ts │ │ ├── task.ts │ │ ├── lead.ts │ │ ├── project.ts │ │ ├── error_message.ts │ │ ├── email.ts │ │ └── company.ts ├── .claspignore ├── package.json ├── tsconfig.json ├── iap_instruction.md ├── .gitignore ├── package-lock.json ├── rollup.config.js ├── appsscript.json └── README.md ├── outlook ├── assets │ ├── odoo.png │ ├── odoo-128.png │ ├── odoo-16.png │ ├── odoo-32.png │ ├── odoo-64.png │ ├── odoo-80.png │ ├── spinner.gif │ ├── avatar_grey.png │ ├── odoo-full.png │ ├── spinner-2.gif │ ├── company_image.png │ ├── spinner-black.gif │ └── social │ │ ├── facebook.ico │ │ ├── linkedin.ico │ │ ├── twitter.ico │ │ └── crunchbase.ico ├── src │ ├── taskpane │ │ ├── components │ │ │ ├── AppContext.js │ │ │ ├── Main │ │ │ │ └── Main.css │ │ │ ├── Login │ │ │ │ ├── dialog.html │ │ │ │ └── Login.css │ │ │ ├── Contact │ │ │ │ ├── ContactPage │ │ │ │ │ └── ContactPage.css │ │ │ │ ├── ContactList │ │ │ │ │ ├── ContactList.tsx │ │ │ │ │ └── ContactListItem │ │ │ │ │ │ ├── ContactListItem.css │ │ │ │ │ │ └── ContactListItem.tsx │ │ │ │ └── ContactSection │ │ │ │ │ └── ContactSection.tsx │ │ │ ├── InfoCell │ │ │ │ ├── InfoCell.css │ │ │ │ └── InfoCell.tsx │ │ │ ├── CollapseSection │ │ │ │ ├── CollapseSection.css │ │ │ │ └── CollapseSection.tsx │ │ │ ├── Search │ │ │ │ ├── Search.css │ │ │ │ └── Search.tsx │ │ │ ├── GrayOverlay.tsx │ │ │ ├── GrayOverlay.css │ │ │ ├── Log │ │ │ │ └── Logger.css │ │ │ ├── ListItem │ │ │ │ ├── ListItem.css │ │ │ │ └── ListItem.tsx │ │ │ ├── SectionTasks │ │ │ │ ├── SelectProjectDropdown.css │ │ │ │ ├── SectionTasks.tsx │ │ │ │ └── SelectProjectDropdown.tsx │ │ │ ├── Company │ │ │ │ ├── CompanyInfoItem.tsx │ │ │ │ ├── CompanySocialIcons.tsx │ │ │ │ └── CompanySection │ │ │ │ │ └── CompanySection.css │ │ │ ├── SectionTickets │ │ │ │ └── SectionTickets.tsx │ │ │ ├── SectionLeads │ │ │ │ └── SectionLeads.tsx │ │ │ ├── ProfileCard │ │ │ │ └── ProfileCard.tsx │ │ │ └── Section │ │ │ │ └── Section.tsx │ │ ├── api.js │ │ ├── taskpane.html │ │ ├── index.tsx │ │ └── taskpane.css │ ├── utils │ │ ├── Themes.ts │ │ ├── httpRequest.ts │ │ └── Translator.ts │ └── classes │ │ ├── Project.ts │ │ ├── HelpdeskTicket.ts │ │ ├── Task.ts │ │ ├── Address.ts │ │ ├── Lead.ts │ │ ├── EnrichmentInfo.ts │ │ ├── Partner.ts │ │ ├── CompanyCache.ts │ │ └── Company.ts ├── .eslintrc.json ├── tsconfig.json ├── replaceDomain.sh ├── .gitignore ├── README.md ├── webpack.config.js ├── package.json └── manifest.xml └── COPYRIGHT /gmail/assets/img/odoo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/gmail/assets/img/odoo.png -------------------------------------------------------------------------------- /outlook/assets/odoo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/odoo.png -------------------------------------------------------------------------------- /gmail/assets/img/readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/gmail/assets/img/readme.png -------------------------------------------------------------------------------- /outlook/assets/odoo-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/odoo-128.png -------------------------------------------------------------------------------- /outlook/assets/odoo-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/odoo-16.png -------------------------------------------------------------------------------- /outlook/assets/odoo-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/odoo-32.png -------------------------------------------------------------------------------- /outlook/assets/odoo-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/odoo-64.png -------------------------------------------------------------------------------- /outlook/assets/odoo-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/odoo-80.png -------------------------------------------------------------------------------- /outlook/assets/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/spinner.gif -------------------------------------------------------------------------------- /gmail/assets/img/odoo_full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/gmail/assets/img/odoo_full.png -------------------------------------------------------------------------------- /outlook/assets/avatar_grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/avatar_grey.png -------------------------------------------------------------------------------- /outlook/assets/odoo-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/odoo-full.png -------------------------------------------------------------------------------- /outlook/assets/spinner-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/spinner-2.gif -------------------------------------------------------------------------------- /outlook/assets/company_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/company_image.png -------------------------------------------------------------------------------- /outlook/assets/spinner-black.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/spinner-black.gif -------------------------------------------------------------------------------- /outlook/src/taskpane/components/AppContext.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default React.createContext(); 4 | -------------------------------------------------------------------------------- /outlook/assets/social/facebook.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/social/facebook.ico -------------------------------------------------------------------------------- /outlook/assets/social/linkedin.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/social/linkedin.ico -------------------------------------------------------------------------------- /outlook/assets/social/twitter.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/social/twitter.ico -------------------------------------------------------------------------------- /outlook/assets/social/crunchbase.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/mail-client-extensions/HEAD/outlook/assets/social/crunchbase.ico -------------------------------------------------------------------------------- /gmail/.clasp.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptId": "1wAzxJBkBYbIs_P2K76RpoBGovgjNNfSoRASf7660wgkxwYa89WZmh2gS", 3 | "projectId": "odoo-gmail-304313" 4 | } 5 | -------------------------------------------------------------------------------- /gmail/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 120, 6 | "tabWidth": 4 7 | } 8 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Main/Main.css: -------------------------------------------------------------------------------- 1 | .connect-button { 2 | margin: 10px auto; 3 | text-align: center; /* Edge */ 4 | width: fit-content; 5 | } 6 | 7 | 8 | -------------------------------------------------------------------------------- /gmail/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare let global: any; 2 | type Card = any; 3 | type ActionEvent = any; 4 | type CardSection = any; 5 | type Button = any; 6 | type GmailAttachment = any; 7 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Login/dialog.html: -------------------------------------------------------------------------------- 1 | 2 | You'll be redirected in a few seconds... 3 | 4 | 5 | -------------------------------------------------------------------------------- /outlook/src/utils/Themes.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from 'office-ui-fabric-react'; 2 | 3 | export const OdooTheme = createTheme({ 4 | palette: { 5 | themePrimary: '#96417e', 6 | themeLight: '#f86ace', 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /gmail/.claspignore: -------------------------------------------------------------------------------- 1 | .git 2 | .git/* 3 | node_modules 4 | node_modules/** 5 | node_modules/**/.*/** 6 | node_modules/**/.* 7 | 8 | # ignore all files… 9 | **/** 10 | 11 | # include appscript and build result 12 | !appsscript.json 13 | !build/*.js 14 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Contact/ContactPage/ContactPage.css: -------------------------------------------------------------------------------- 1 | .contact-page > div:not(:first-child) { 2 | margin-top: 16px; 3 | } 4 | 5 | .contact-spinner{ 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | height: 100%; 10 | } 11 | -------------------------------------------------------------------------------- /gmail/src/services/app_properties.ts: -------------------------------------------------------------------------------- 1 | export function getOdooServerUrl() { 2 | return PropertiesService.getUserProperties().getProperty("ODOO_SERVER_URL"); 3 | } 4 | export function setOdooServerUrl(url: string) { 5 | PropertiesService.getUserProperties().setProperty("ODOO_SERVER_URL", url); 6 | } 7 | -------------------------------------------------------------------------------- /gmail/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@rollup/plugin-node-resolve": "^15.0.2", 4 | "@rollup/plugin-typescript": "^11.1.1", 5 | "@types/google-apps-script": "^1.0.64", 6 | "prettier": "^2.2.1", 7 | "rollup": "^3.22.0", 8 | "tslib": "^2.5.3" 9 | }, 10 | "type": "module" 11 | } 12 | -------------------------------------------------------------------------------- /gmail/src/utils/html.ts: -------------------------------------------------------------------------------- 1 | export function escapeHtml(unsafe: string): string { 2 | unsafe = unsafe || ""; 3 | return unsafe 4 | .replace(/&/g, "&") 5 | .replace(//g, ">") 7 | .replace(/"/g, """) 8 | .replace(/'/g, "'"); 9 | } 10 | -------------------------------------------------------------------------------- /gmail/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build", 4 | "baseUrl": ".", 5 | "strictNullChecks": false, 6 | "noImplicitThis": true, 7 | "noEmitOnError": true, 8 | "target": "ES5", 9 | "lib": ["dom", "es6", "scripthost", "es2017"] 10 | }, 11 | "include": ["src/*", "src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/InfoCell/InfoCell.css: -------------------------------------------------------------------------------- 1 | .button-link{ 2 | text-decoration: none; 3 | } 4 | 5 | .info-cell { 6 | display: flex; 7 | align-items: center; 8 | } 9 | 10 | .info-cell-title { 11 | font-size: 12px; 12 | color:rgb(161, 159, 157); 13 | } 14 | 15 | .info-cell-data { 16 | font-size: 14px 17 | } -------------------------------------------------------------------------------- /outlook/src/taskpane/components/CollapseSection/CollapseSection.css: -------------------------------------------------------------------------------- 1 | .section-card { 2 | user-select: none; 3 | } 4 | 5 | .collapse-section-button { 6 | cursor: pointer; 7 | margin-right: 8px; 8 | padding: 8px; 9 | border-radius: 25%; 10 | } 11 | 12 | .collapse-section-button:hover { 13 | background: #eeeeee; 14 | } 15 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Search/Search.css: -------------------------------------------------------------------------------- 1 | .search-container { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: stretch; 6 | margin: 8px 8px 16px 8px; 7 | } 8 | 9 | .search-icon { 10 | height: 100%; 11 | border-left: none; 12 | } 13 | 14 | .search-spinner { 15 | padding: 32px; 16 | } 17 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/GrayOverlay.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import './GrayOverlay.css'; 3 | 4 | const Progress = () => { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | }; 12 | 13 | export default Progress; 14 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/GrayOverlay.css: -------------------------------------------------------------------------------- 1 | .gray-overlay { 2 | position: absolute; 3 | width: 100%; 4 | height: 100%; 5 | background-color: gray; 6 | top: 0px; 7 | left: 0px; 8 | opacity: .3; 9 | } 10 | 11 | .overlay-spinner { 12 | position: fixed; 13 | height: 120px; 14 | width: 120px; 15 | top: calc(50% - 60px); 16 | left: calc(50% - 60px); 17 | } -------------------------------------------------------------------------------- /gmail/iap_instruction.md: -------------------------------------------------------------------------------- 1 | # Shared secret between IAP and the add-on 2 | Go to your Google project, 3 | > clasp open 4 | 5 | Then File -> Project properties -> Script Properties 6 | 7 | And add a row, 8 | > `ODOO_SHARED_SECRET` `` 9 | 10 | On the IAP side, add a system parameter 11 | > `iap_mail_extension.shared_secret` `` 12 | 13 | This secret will allow the add-on to authenticate to IAP. 14 | -------------------------------------------------------------------------------- /outlook/src/classes/Project.ts: -------------------------------------------------------------------------------- 1 | export default class Project { 2 | id: number; 3 | name: string; 4 | company_id: number; 5 | 6 | static fromJson(projectJson: Object): Project { 7 | const project = new Project(); 8 | project.id = projectJson['project_id']; 9 | project.name = projectJson['name']; 10 | project.company_id = projectJson['company_id']; 11 | return project; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /outlook/src/classes/HelpdeskTicket.ts: -------------------------------------------------------------------------------- 1 | class HelpdeskTicket { 2 | id: number; 3 | name: string; 4 | isClosed: boolean; 5 | 6 | static fromJSON(o: Object): HelpdeskTicket { 7 | const ticket = new HelpdeskTicket(); 8 | ticket.id = o['ticket_id']; 9 | ticket.name = o['name']; 10 | ticket.isClosed = o['is_closed']; 11 | return ticket; 12 | } 13 | } 14 | 15 | export default HelpdeskTicket; 16 | -------------------------------------------------------------------------------- /outlook/src/classes/Task.ts: -------------------------------------------------------------------------------- 1 | export default class Task { 2 | id: number; 3 | name: string; 4 | projectName: string; 5 | companyId: number; 6 | 7 | static fromJSON(o: Object): Task { 8 | const task = new Task(); 9 | task.id = o['task_id']; 10 | task.name = o['name']; 11 | task.projectName = o['project_name'] || ''; 12 | task.companyId = o['company_id']; 13 | return task; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Log/Logger.css: -------------------------------------------------------------------------------- 1 | .log-container { 2 | display: inline-block; 3 | height: fit-content; 4 | margin-top: auto; 5 | margin-bottom: auto; 6 | padding-right: 8px; 7 | margin-left: auto; 8 | text-align: center; 9 | cursor: pointer; 10 | } 11 | 12 | .log-button { 13 | width: 20px; 14 | } 15 | 16 | .logged-text { 17 | height: fit-content; 18 | padding: 4px; 19 | color: #777; 20 | font-size: smaller; 21 | } 22 | -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | 2 | Most of the files are 3 | 4 | Copyright (c) 2020-2023 Odoo S.A. 5 | 6 | Many files also contain contributions from third 7 | parties. In this case the original copyright of 8 | the contributions can be traced through the 9 | history of the source version control system. 10 | 11 | When that is not the case, the files contain a prominent 12 | notice stating the original copyright and applicable 13 | license, or come with their own dedicated COPYRIGHT 14 | and/or LICENSE file. 15 | 16 | -------------------------------------------------------------------------------- /outlook/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": [ 4 | "react", 5 | "@typescript-eslint", 6 | "office-addins" 7 | ], 8 | "parserOptions": { 9 | "ecmaVersion": 6, 10 | "sourceType": "module", 11 | "ecmaFeatures": { 12 | "jsx": true 13 | }, 14 | "project": "./tsconfig.json" 15 | }, 16 | "extends": [ 17 | "plugin:office-addins/react" 18 | ], 19 | "settings": { 20 | "react": { 21 | "version": "detect" 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /gmail/.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # API keys and secrets 23 | .env 24 | 25 | # Dependency directory 26 | node_modules 27 | bower_components 28 | 29 | # Editors 30 | .idea 31 | *.iml 32 | 33 | # OS metadata 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Ignore final built js file 38 | build/ 39 | 40 | # Ignore built ts files 41 | dist/**/* 42 | 43 | # ignore yarn.lock 44 | yarn.lock 45 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Login/Login.css: -------------------------------------------------------------------------------- 1 | .login-info { 2 | display: flex; 3 | align-items: center; 4 | margin-top: 20px; 5 | margin-bottom: 20px; 6 | } 7 | 8 | .login-info-icon { 9 | margin-right: 20px; 10 | } 11 | 12 | .login-spinner { 13 | margin-right: 8px; 14 | } 15 | 16 | .error-text { 17 | margin-top: 16px; 18 | color: rgb(164, 38, 44); 19 | } 20 | 21 | .connect-your-database { 22 | font-size: 30px; 23 | width: 200px; 24 | margin: auto; 25 | text-align: center; 26 | } 27 | 28 | .connect-your-database > img { 29 | height: 30px; 30 | } 31 | -------------------------------------------------------------------------------- /gmail/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "requires": true, 3 | "lockfileVersion": 1, 4 | "dependencies": { 5 | "@types/google-apps-script": { 6 | "version": "1.0.31", 7 | "resolved": "https://registry.npmjs.org/@types/google-apps-script/-/google-apps-script-1.0.31.tgz", 8 | "integrity": "sha512-tgsJKk20fwFoh0Ml4Li3pqKQ5uu3Nr3XeRsee2+pkPGrJxDlA3qsHAA2q3/HRv5yi9U6QVvdGwJ16USnmA7wAA==" 9 | }, 10 | "prettier": { 11 | "version": "2.2.1", 12 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz", 13 | "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==", 14 | "dev": true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /outlook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "removeComments": false, 11 | "outDir": "dist", 12 | "allowUnusedLabels": false, 13 | "noImplicitReturns": true, 14 | "noUnusedParameters": true, 15 | "noUnusedLocals": true, 16 | "downlevelIteration": true, 17 | "lib": [ 18 | "es7", 19 | "dom", 20 | "es2020" 21 | ], 22 | "pretty": true, 23 | "typeRoots": [ 24 | "node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules" 29 | ], 30 | "compileOnSave": false, 31 | "buildOnSave": false 32 | } -------------------------------------------------------------------------------- /outlook/replaceDomain.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | helpFunction() 4 | { 5 | echo "" 6 | echo "Usage: $0 -d odoo.com/subdomain" 7 | echo -e "\t-d The domain that will replace localhost:3000 in the manifest.xml and api.js files." 8 | exit 1 # Exit script after printing help 9 | } 10 | 11 | while getopts "d:" opt 12 | do 13 | case "$opt" in 14 | d ) parameterD="$OPTARG" ;; 15 | ? ) helpFunction ;; # Print helpFunction in case parameter is non-existent 16 | esac 17 | done 18 | 19 | # Print helpFunction in case parameters are empty 20 | if [ -z "$parameterD" ] 21 | then 22 | echo "Some or all of the parameters are empty"; 23 | helpFunction 24 | fi 25 | 26 | escapedD=$(echo "$parameterD" | sed 's/\//\\\//g') 27 | sed -i "s/localhost:3000/$escapedD/" dist/manifest.xml 28 | sed -i "s/localhost:3000/$escapedD/" dist/taskpane.js -------------------------------------------------------------------------------- /outlook/src/taskpane/components/ListItem/ListItem.css: -------------------------------------------------------------------------------- 1 | .list-item-root-container { 2 | margin-bottom: 8px; 3 | display: flex; 4 | flex-direction: column; 5 | padding: 8px; 6 | border: solid 1px #ededed; 7 | border-radius: 4px; 8 | cursor: pointer; 9 | } 10 | 11 | .list-item-root-container:hover { 12 | background: #ededed; 13 | } 14 | 15 | .list-item-container { 16 | display: flex; 17 | flex-direction: row; 18 | } 19 | 20 | .list-item-info-container { 21 | display: flex; 22 | flex-direction: column; 23 | flex: 5; 24 | } 25 | 26 | .list-item-title-text { 27 | font-size: 14px; 28 | font-weight: 600; 29 | } 30 | 31 | .list-text { 32 | font-size: 14px; 33 | margin: 8px 8px 16px 8px; 34 | text-align: center; 35 | color: #8c8c8c; 36 | } 37 | 38 | .list-item-description { 39 | margin-top: 8px; 40 | font-size: 14px; 41 | color: #787878; 42 | } 43 | -------------------------------------------------------------------------------- /gmail/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 3 | 4 | const extensions = [".ts"]; 5 | 6 | /** 7 | * Prevent tree-shaking the entry-point 8 | * by not shaking any module that isn't imported by anyone. 9 | * @returns side-effects or nothing 10 | */ 11 | function preventEntrypointShakingPlugin() { 12 | return { 13 | name: "no-treeshaking", 14 | resolveId(id, importer) { 15 | if (!importer) { 16 | return { id, moduleSideEffects: "no-treeshake" }; 17 | } 18 | return null; 19 | }, 20 | }; 21 | } 22 | 23 | export default { 24 | input: "./src/main.ts", 25 | output: { 26 | dir: "./build", 27 | format: "esm", 28 | sourcemap: true, 29 | }, 30 | plugins: [ 31 | preventEntrypointShakingPlugin(), 32 | nodeResolve({ 33 | extensions, 34 | }), 35 | typescript(), 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/SectionTasks/SelectProjectDropdown.css: -------------------------------------------------------------------------------- 1 | .project-search-result-text { 2 | cursor: pointer; 3 | font-weight: bold; 4 | margin-top: 8px; 5 | } 6 | 7 | .project-search-no-result-text { 8 | margin-top: 8px; 9 | } 10 | 11 | .project-search-result-text:hover { 12 | color: #96417e; 13 | } 14 | 15 | .project-no-scroll { 16 | overflow-y: hidden; 17 | } 18 | 19 | .project-result-spinner { 20 | margin: 16px auto auto auto; 21 | height: 100%; 22 | } 23 | 24 | .project-search-bar { 25 | display: flex; 26 | flex-direction: row; 27 | align-items: center; 28 | justify-content: stretch; 29 | margin: 8px 0 8px 0; 30 | } 31 | 32 | .project-result-container { 33 | display: flex; 34 | flex-direction: column; 35 | margin: 16px; 36 | } 37 | 38 | .create-project-text { 39 | margin-top: 8px; 40 | margin-left: 16px; 41 | color: #0003e7; 42 | cursor: pointer; 43 | } 44 | 45 | .create-project-text:hover { 46 | color: #000063; 47 | } 48 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Company/CompanyInfoItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { IconDefinition } from '@fortawesome/fontawesome-common-types'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import InfoCell from '../InfoCell/InfoCell'; 5 | 6 | type CompanyInfoItemProps = { 7 | icon: IconDefinition; 8 | title: string; 9 | value: string; 10 | hrefContent?: string; 11 | }; 12 | 13 | const CompanyInfoItem = (props: CompanyInfoItemProps) => { 14 | const icon = ( 15 |
16 | 17 |
18 | ); 19 | 20 | let infoCell; 21 | 22 | if (props.hrefContent == undefined) { 23 | infoCell = ; 24 | } else { 25 | infoCell = ; 26 | } 27 | 28 | return
{infoCell}
; 29 | }; 30 | 31 | export default CompanyInfoItem; 32 | -------------------------------------------------------------------------------- /gmail/src/const.ts: -------------------------------------------------------------------------------- 1 | export const URLS: Record = { 2 | GET_TRANSLATIONS: "/mail_plugin/get_translations", 3 | LOG_EMAIL: "/mail_plugin/log_mail_content", 4 | // Partner 5 | GET_PARTNER: "/mail_plugin/partner/get", 6 | SEARCH_PARTNER: "/mail_plugin/partner/search", 7 | PARTNER_CREATE: "/mail_plugin/partner/create", 8 | CREATE_COMPANY: "/mail_plugin/partner/enrich_and_create_company", 9 | ENRICH_COMPANY: "/mail_plugin/partner/enrich_and_update_company", 10 | // CRM Lead 11 | CREATE_LEAD: "/mail_plugin/lead/create", 12 | // HELPDESK Ticket 13 | CREATE_TICKET: "/mail_plugin/ticket/create", 14 | // Project 15 | SEARCH_PROJECT: "/mail_plugin/project/search", 16 | CREATE_PROJECT: "/mail_plugin/project/create", 17 | CREATE_TASK: "/mail_plugin/task/create", 18 | // IAP 19 | IAP_COMPANY_ENRICHMENT: "https://iap-services.odoo.com/iap/mail_extension/enrich", 20 | }; 21 | 22 | export const ODOO_AUTH_URLS: Record = { 23 | LOGIN: "/web/login", 24 | AUTH_CODE: "/mail_plugin/auth", 25 | CODE_VALIDATION: "/mail_plugin/auth/access_token", 26 | SCOPE: "outlook", 27 | FRIENDLY_NAME: "Gmail", 28 | }; 29 | -------------------------------------------------------------------------------- /gmail/src/views/index.ts: -------------------------------------------------------------------------------- 1 | import { buildPartnerView } from "./partner"; 2 | import { buildErrorView } from "./error"; 3 | import { buildCompanyView } from "./company"; 4 | import { buildLoginMainView } from "./login"; 5 | import { buildCardActionsView } from "./card_actions"; 6 | import { State } from "../models/state"; 7 | import { actionCall } from "./helpers"; 8 | import { _t } from "../services/translation"; 9 | 10 | export function buildView(state: State) { 11 | const card = CardService.newCardBuilder(); 12 | 13 | if (state.error.code) { 14 | buildErrorView(state, card); 15 | } 16 | 17 | buildPartnerView(state, card); 18 | 19 | buildCompanyView(state, card); 20 | 21 | buildCardActionsView(state, card); 22 | 23 | if (!State.isLogged) { 24 | card.setFixedFooter( 25 | CardService.newFixedFooter().setPrimaryButton( 26 | CardService.newTextButton() 27 | .setText(_t("Login")) 28 | .setBackgroundColor("#00A09D") 29 | .setOnClickAction(actionCall(state, buildLoginMainView.name)), 30 | ), 31 | ); 32 | } 33 | 34 | return card.build(); 35 | } 36 | -------------------------------------------------------------------------------- /gmail/src/views/debug.ts: -------------------------------------------------------------------------------- 1 | import { createKeyValueWidget } from "./helpers"; 2 | import { _t, clearTranslationCache } from "../services/translation"; 3 | import { getAccessToken } from "src/services/odoo_auth"; 4 | import { getOdooServerUrl } from "src/services/app_properties"; 5 | 6 | export function buildDebugView() { 7 | const card = CardService.newCardBuilder(); 8 | const odooServerUrl = getOdooServerUrl(); 9 | const odooAccessToken = getAccessToken(); 10 | 11 | card.setHeader( 12 | CardService.newCardHeader().setTitle(_t("Debug Zone")).setSubtitle(_t("Debug zone for development purpose.")), 13 | ); 14 | 15 | card.addSection(CardService.newCardSection().addWidget(createKeyValueWidget(_t("Odoo Server URL"), odooServerUrl))); 16 | 17 | card.addSection( 18 | CardService.newCardSection().addWidget(createKeyValueWidget(_t("Odoo Access Token"), odooAccessToken)), 19 | ); 20 | 21 | card.addSection( 22 | CardService.newCardSection().addWidget( 23 | CardService.newTextButton() 24 | .setText(_t("Clear Translations Cache")) 25 | .setOnClickAction(CardService.newAction().setFunctionName(clearTranslationCache.name)), 26 | ), 27 | ); 28 | 29 | return card.build(); 30 | } 31 | -------------------------------------------------------------------------------- /outlook/.gitignore: -------------------------------------------------------------------------------- 1 | # NODE 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # next.js build output 62 | .next 63 | 64 | # VS CODE 65 | .vscode/ 66 | *.code-workspace 67 | 68 | dist/ 69 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Contact/ContactList/ContactList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ContactListItem from './ContactListItem/ContactListItem'; 3 | import Partner from '../../../../classes/Partner'; 4 | 5 | type ContactListProps = { 6 | partners: Partner[]; 7 | canCreatePartner: boolean; 8 | onItemClick?: (partner: Partner) => void; 9 | }; 10 | 11 | /** 12 | * Component used for displaying search results 13 | */ 14 | const ContactList = (props: ContactListProps) => { 15 | const onPartnerClick = (partner: Partner) => { 16 | props.onItemClick(partner); 17 | }; 18 | 19 | const contactsList = ( 20 |
21 | {props.partners.map((partner) => { 22 | return ( 23 | 29 | ); 30 | })} 31 |
32 | ); 33 | 34 | return ( 35 | 36 |
{contactsList}
37 |
38 | ); 39 | }; 40 | 41 | export default ContactList; 42 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Company/CompanySocialIcons.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function CompanySocialIcons({ twitter, facebook, linkedin, crunchbase }) { 4 | return ( 5 |
e.stopPropagation()}> 6 | {twitter && ( 7 | 8 | 9 | 10 | )} 11 | {facebook && ( 12 | 13 | 14 | 15 | )} 16 | {linkedin && ( 17 | 18 | 19 | 20 | )} 21 | {crunchbase && ( 22 | 23 | 24 | 25 | )} 26 |
27 | ); 28 | } 29 | 30 | export default CompanySocialIcons; 31 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/InfoCell/InfoCell.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import './InfoCell.css'; 3 | 4 | type InfoCellProps = { 5 | hrefContent?: string; 6 | icon: any; 7 | title: string; 8 | value: string; 9 | }; 10 | 11 | const InfoCell = (props: InfoCellProps) => { 12 | const { hrefContent, icon, title, value } = props; 13 | if (hrefContent != undefined) { 14 | return ( 15 | 16 |
17 |
{icon}
18 |
19 |
{title}
20 |
{value}
21 |
22 |
23 |
24 | ); 25 | } else { 26 | return ( 27 |
28 |
29 |
{icon}
30 |
31 |
{title}
32 |
{value}
33 |
34 |
35 |
36 | ); 37 | } 38 | }; 39 | 40 | export default InfoCell; 41 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Contact/ContactSection/ContactSection.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Partner from '../../../../classes/Partner'; 3 | 4 | import ContactListItem from '../ContactList/ContactListItem/ContactListItem'; 5 | 6 | import api from '../../../api'; 7 | import AppContext from '../../AppContext'; 8 | 9 | type ContactSectionProps = { 10 | partner: Partner; 11 | canCreatePartner: boolean; 12 | onPartnerInfoChanged?: (partner: Partner) => void; 13 | }; 14 | 15 | class ContactSection extends React.Component { 16 | viewContact = (partner) => { 17 | const cids = this.context.getUserCompaniesString(); 18 | const url = `${api.baseURL}/web#id=${partner.id}&model=res.partner&view_type=form${cids}`; 19 | window.open(url, '_blank'); 20 | }; 21 | 22 | render() { 23 | const onItemClick = this.props.partner.isAddedToDatabase() ? this.viewContact : null; 24 | 25 | return ( 26 |
27 | 32 |
33 | ); 34 | } 35 | } 36 | ContactSection.contextType = AppContext; 37 | 38 | export default ContactSection; 39 | -------------------------------------------------------------------------------- /outlook/src/classes/Address.ts: -------------------------------------------------------------------------------- 1 | class Address { 2 | number: string; 3 | street: string; 4 | city: string; 5 | zip: string; 6 | country: string; 7 | 8 | constructor() { 9 | this.number = ''; 10 | this.street = ''; 11 | this.city = ''; 12 | this.zip = ''; 13 | this.country = ''; 14 | } 15 | 16 | getLines(): string[] { 17 | const firstLine = [this.number || '', this.street || ''].join(' ').trim(); 18 | const secondLine = [this.city || '', this.zip || ''].join(' ').trim(); 19 | 20 | const lines = []; 21 | if (firstLine && firstLine.length) lines.push(firstLine); 22 | if (secondLine && secondLine.length) lines.push(secondLine); 23 | if (this.country && this.country.length) lines.push(this.country); 24 | 25 | return lines; 26 | } 27 | 28 | static fromJSON(o: Object): Address { 29 | if (!o) return new Address(); 30 | return Object.assign(new Address(), o); 31 | } 32 | 33 | static fromRevealJSON(o: Object): Address { 34 | const address = new Address(); 35 | address.number = o['street_number']; 36 | address.street = o['street_name']; 37 | address.city = o['city']; 38 | address.zip = o['postal_code']; 39 | address.country = o['country_name']; 40 | return address; 41 | } 42 | } 43 | 44 | export default Address; 45 | -------------------------------------------------------------------------------- /outlook/src/taskpane/api.js: -------------------------------------------------------------------------------- 1 | const api = { 2 | baseURL: localStorage.getItem('baseURL'), 3 | createLead: '/mail_plugin/lead/create', 4 | createPartner: '/mail_plugin/partner/create', 5 | createProject: '/mail_plugin/project/create', 6 | createTask: '/mail_plugin/task/create', 7 | createTicket: '/mail_plugin/ticket/create', 8 | enrichCompany: '/mail_plugin/partner/enrich_and_create_company', 9 | enrichAndCreate: '/mail_plugin/partner/enrich_and_create_company', 10 | enrichAndUpdate: '/mail_plugin/partner/enrich_and_update_company', 11 | getPartner: '/mail_plugin/partner/get', 12 | iapLeadEnrichment: 'https://iap-services.odoo.com/iap/mail_extension/enrich', 13 | logSingleMail: '/mail_plugin/log_mail_content', 14 | searchPartner: '/mail_plugin/partner/search', 15 | getTranslations: '/mail_plugin/get_translations', 16 | searchProject: '/mail_plugin/project/search', 17 | 18 | // Authentication 19 | loginPage: '/web/login', // Should be the usual Odoo login page. 20 | authCodePage: '/mail_plugin/auth', // The page where to allow or deny access. You get an auth code. 21 | getAccessToken: '/mail_plugin/auth/access_token', // The address where to post to exchange an auth code for an access token. 22 | addInBaseURL: 'https://' + __DOMAIN__, 23 | outlookScope: 'outlook', 24 | outlookFriendlyName: 'Outlook', 25 | }; 26 | 27 | export default api; 28 | -------------------------------------------------------------------------------- /gmail/src/utils/format.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Format the given URL by checking the protocol part and removing trailing "/". 3 | */ 4 | export function formatUrl(url: string): string { 5 | if (!url || !url.length) { 6 | return; 7 | } 8 | if (url.indexOf("http://") !== 0 && url.indexOf("https://") !== 0) { 9 | url = "https://" + url; 10 | } 11 | 12 | // Remove trailing "/" 13 | return url.replace(/\/+$/, ""); 14 | } 15 | 16 | /** 17 | * Return the given string only if it's not null and not empty. 18 | */ 19 | export function isTrue(value: any): string { 20 | if (value && value.length) { 21 | return value; 22 | } 23 | } 24 | 25 | /** 26 | * Return the first element of an array if the array is not null and not empty. 27 | */ 28 | export function first(value: any[]): any { 29 | if (value && value.length) { 30 | return value[0]; 31 | } 32 | } 33 | 34 | /** 35 | * Repeat the given string "n" times. 36 | */ 37 | export function repeat(str: string, n: number) { 38 | let result = ""; 39 | while (n > 0) { 40 | result += str; 41 | n--; 42 | } 43 | return result; 44 | } 45 | 46 | /** 47 | * Truncate the given text to not exceed the given length. 48 | */ 49 | export function truncate(str: string, maxLength: number) { 50 | if (str.length > maxLength) { 51 | return str.substring(0, maxLength - 3) + "..."; 52 | } 53 | return str; 54 | } 55 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Company/CompanySection/CompanySection.css: -------------------------------------------------------------------------------- 1 | .company-info-icon{ 2 | font-size: 15px; 3 | margin: 0 15px 0 0; 4 | } 5 | 6 | .company-header { 7 | display: flex; 8 | flex-direction: row; 9 | justify-content: space-between; 10 | flex-wrap: wrap; 11 | padding: 8px; 12 | padding-bottom: 0; 13 | cursor: pointer; 14 | } 15 | 16 | .company-header:hover { 17 | background: #ededed; 18 | } 19 | 20 | .company-header .company-name { 21 | display: flex; 22 | margin-bottom: 10px; 23 | } 24 | 25 | .company-header .company-name img { 26 | display: block; 27 | max-height: 50px; 28 | max-width: 50px; 29 | margin-right: 15px; 30 | border: 1px solid whitesmoke; 31 | } 32 | 33 | .company-insights { 34 | padding: 8px; 35 | } 36 | 37 | .company-insights .company-description { 38 | display: block; 39 | margin-bottom: 8px; 40 | } 41 | 42 | .social-links a { 43 | padding: 3px; 44 | } 45 | 46 | .social-links img { 47 | height: 15px; 48 | } 49 | 50 | .company-info-item { 51 | margin-bottom: 8px; 52 | display: flex; 53 | flex-direction: row; 54 | align-items: center; 55 | } 56 | 57 | .insights-info { 58 | text-align: center; 59 | padding: 8px 16px 16px 16px; 60 | font-size: 14px; 61 | } 62 | 63 | .insights-button { 64 | margin: 8px auto 16px auto 65 | } 66 | 67 | .insights-empty-info { 68 | text-align: center; 69 | padding: 8px; 70 | font-size: 14px; 71 | } 72 | -------------------------------------------------------------------------------- /outlook/src/taskpane/taskpane.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Contoso Task Pane Add-in 12 | 13 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /gmail/src/main.ts: -------------------------------------------------------------------------------- 1 | import { buildView } from "./views/index"; 2 | import { Email } from "./models/email"; 3 | import { State } from "./models/state"; 4 | import { Partner } from "./models/partner"; 5 | import { _t } from "./services/translation"; 6 | 7 | /** 8 | * Entry point of the application, executed when an email is open. 9 | * 10 | * If the user is not connected to a Odoo database, we will contact IAP and enrich the 11 | * domain of the op penned email. 12 | * 13 | * If the user is connected to a Odoo database, we will fetch the corresponding partner 14 | * and other information like his leads, tickets, company... 15 | */ 16 | function onGmailMessageOpen(event) { 17 | GmailApp.setCurrentMessageAccessToken(event.messageMetadata.accessToken); 18 | const currentEmail = new Email(event.gmail.messageId, event.gmail.accessToken); 19 | 20 | const [partner, odooUserCompanies, canCreatePartner, canCreateProject, error] = Partner.enrichPartner( 21 | currentEmail.contactEmail, 22 | currentEmail.contactName 23 | ); 24 | 25 | if (!partner) { 26 | // Should at least use the FROM headers to generate the partner 27 | throw new Error(_t("Error during enrichment")); 28 | } 29 | 30 | const state = new State( 31 | partner, 32 | canCreatePartner, 33 | currentEmail, 34 | odooUserCompanies, 35 | null, 36 | null, 37 | canCreateProject, 38 | error 39 | ); 40 | 41 | return [buildView(state)]; 42 | } 43 | -------------------------------------------------------------------------------- /gmail/src/views/card_actions.ts: -------------------------------------------------------------------------------- 1 | import { buildDebugView } from "./debug"; 2 | import { buildView } from "../views/index"; 3 | import { State } from "../models/state"; 4 | import { Partner } from "../models/partner"; 5 | import { resetAccessToken } from "../services/odoo_auth"; 6 | import { _t, clearTranslationCache } from "../services/translation"; 7 | import { actionCall } from "./helpers"; 8 | import { pushToRoot } from "./helpers"; 9 | 10 | function onLogout(state: State) { 11 | resetAccessToken(); 12 | clearTranslationCache(); 13 | 14 | const [partner, odooUserCompanies, canCreatePartner, canCreateProject, error] = Partner.enrichPartner( 15 | state.email.contactEmail, 16 | state.email.contactName, 17 | ); 18 | const newState = new State( 19 | partner, 20 | canCreatePartner, 21 | state.email, 22 | odooUserCompanies, 23 | null, 24 | null, 25 | canCreateProject, 26 | error, 27 | ); 28 | return pushToRoot(buildView(newState)); 29 | } 30 | 31 | export function buildCardActionsView(state: State, card: Card) { 32 | const canContactOdooDatabase = state.error.canContactOdooDatabase && State.isLogged; 33 | 34 | if (State.isLogged) { 35 | card.addCardAction( 36 | CardService.newCardAction().setText(_t("Logout")).setOnClickAction(actionCall(state, onLogout.name)), 37 | ); 38 | } 39 | 40 | card.addCardAction( 41 | CardService.newCardAction().setText(_t("Debug")).setOnClickAction(actionCall(state, buildDebugView.name)), 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /outlook/src/classes/Lead.ts: -------------------------------------------------------------------------------- 1 | class Lead { 2 | id: number; 3 | name: string; 4 | expectedRevenue: string; // string because formatted 5 | probability: number; 6 | recurringRevenue?: string; 7 | recurringPlan?: string; 8 | 9 | static fromJSON(o: Object): Lead { 10 | const lead = new Lead(); 11 | lead.id = o['lead_id']; 12 | lead.name = o['name']; 13 | lead.probability = o['probability']; 14 | 15 | lead.expectedRevenue = this.removeDecimals(o['expected_revenue']); 16 | 17 | if (o['recurring_revenue']) { 18 | lead.recurringRevenue = this.removeDecimals(o['recurring_revenue']); 19 | lead.recurringPlan = o['recurring_plan']; 20 | } 21 | 22 | return lead; 23 | } 24 | 25 | static copy(lead: Lead): Lead { 26 | const newLead = new Lead(); 27 | newLead.id = lead.id; 28 | newLead.name = lead.name; 29 | newLead.expectedRevenue = lead.expectedRevenue; 30 | newLead.probability = lead.probability; 31 | return newLead; 32 | } 33 | 34 | private static removeDecimals(revenue: string): string { 35 | if (revenue.search(/\.00\D*$/) != -1) { 36 | // if it ends with ".00" or ".00 $" or ".00 €", etc. 37 | return revenue.replace(/\.00/, ''); 38 | } else if (revenue.search(/,00\D*$/) != -1) { 39 | // if it ends with ",00" or ",00 $" or ",00 €", etc. 40 | return revenue.replace(/,00/, ''); 41 | } 42 | return revenue; 43 | } 44 | } 45 | 46 | export default Lead; 47 | -------------------------------------------------------------------------------- /gmail/src/models/ticket.ts: -------------------------------------------------------------------------------- 1 | import { postJsonRpc } from "../utils/http"; 2 | import { URLS } from "../const"; 3 | import { getAccessToken } from "src/services/odoo_auth"; 4 | 5 | /** 6 | * Represent a "helpdesk.ticket" record. 7 | */ 8 | export class Ticket { 9 | id: number; 10 | name: string; 11 | 12 | /** 13 | * Make a RPC call to the Odoo database to create a ticket 14 | * and return the ID of the newly created record. 15 | */ 16 | static createTicket(partnerId: number, emailBody: string, emailSubject: string): number { 17 | const url = PropertiesService.getUserProperties().getProperty("ODOO_SERVER_URL") + URLS.CREATE_TICKET; 18 | const odooAccessToken = getAccessToken(); 19 | 20 | const response = postJsonRpc( 21 | url, 22 | { email_body: emailBody, email_subject: emailSubject, partner_id: partnerId }, 23 | { Authorization: "Bearer " + odooAccessToken }, 24 | ); 25 | 26 | return response ? response.ticket_id || null : null; 27 | } 28 | 29 | /** 30 | * Unserialize the ticket object (reverse JSON.stringify). 31 | */ 32 | static fromJson(values: any): Ticket { 33 | const ticket = new Ticket(); 34 | ticket.id = values.id; 35 | ticket.name = values.name; 36 | return ticket; 37 | } 38 | 39 | /** 40 | * Parse the dictionary return by the Odoo endpoint. 41 | */ 42 | static fromOdooResponse(values: any): Ticket { 43 | const ticket = new Ticket(); 44 | ticket.id = values.ticket_id; 45 | ticket.name = values.name; 46 | return ticket; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/ListItem/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import './ListItem.css'; 2 | import * as React from 'react'; 3 | import Logger from '../Log/Logger'; 4 | import api from '../../api'; 5 | import AppContext from '../AppContext'; 6 | 7 | type ListItemProps = { 8 | title: string; 9 | description?: string; 10 | // Record information to redirect to Odoo 11 | // and log email on the record 12 | model: string; 13 | res_id: number; 14 | logTitle: string; 15 | }; 16 | 17 | class ListItem extends React.Component { 18 | openInOdoo = () => { 19 | const cids = this.context.getUserCompaniesString(); 20 | const url = `${api.baseURL}/web#id=${this.props.res_id}&model=${this.props.model}&view_type=form${cids}`; 21 | window.open(url, '_blank'); 22 | }; 23 | 24 | render() { 25 | return ( 26 |
27 |
28 |
29 |
{this.props.title}
30 | {this.props.description && ( 31 |
{this.props.description}
32 | )} 33 |
34 | 35 |
36 |
37 | ); 38 | } 39 | } 40 | 41 | ListItem.contextType = AppContext; 42 | 43 | export default ListItem; 44 | -------------------------------------------------------------------------------- /outlook/src/taskpane/index.tsx: -------------------------------------------------------------------------------- 1 | import 'office-ui-fabric-react/dist/css/fabric.min.css'; 2 | import App from './components/App'; 3 | import { AppContainer } from 'react-hot-loader'; 4 | import { initializeIcons } from 'office-ui-fabric-react/lib/Icons'; 5 | import * as React from 'react'; 6 | import * as ReactDOM from 'react-dom'; 7 | import { Authenticator } from '@microsoft/office-js-helpers'; 8 | 9 | initializeIcons(); 10 | 11 | let isOfficeInitialized = false; 12 | 13 | const title = 'Odoo for Outlook'; 14 | 15 | const render = (Component) => { 16 | ReactDOM.render( 17 | 18 | 23 | , 24 | document.getElementById('container'), 25 | ); 26 | }; 27 | 28 | let itemChangedHandler: (type: Office.EventType) => void; 29 | const itemChangedRegister = (f: (type: Office.EventType) => void) => { 30 | itemChangedHandler = f; 31 | }; 32 | 33 | /* Render application after Office initializes */ 34 | Office.initialize = () => { 35 | if (Authenticator.isAuthDialog()) return; 36 | isOfficeInitialized = true; 37 | Office.context.mailbox.addHandlerAsync(Office.EventType.ItemChanged, itemChangedHandler); 38 | render(App); 39 | }; 40 | 41 | /* Initial render showing a progress bar */ 42 | render(App); 43 | 44 | if ((module as any).hot) { 45 | (module as any).hot.accept('./components/App', () => { 46 | const NextApp = require('./components/App').default; 47 | render(NextApp); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Contact/ContactList/ContactListItem/ContactListItem.css: -------------------------------------------------------------------------------- 1 | .contact-container { 2 | width: 100%; 3 | box-sizing: border-box; 4 | display: flex; 5 | padding: 8px; 6 | } 7 | 8 | .contact-container.clickable { 9 | cursor: pointer; 10 | } 11 | 12 | .contact-container.clickable:hover { 13 | background-color: #eeeeee; 14 | } 15 | 16 | .contact-card { 17 | display: contents; 18 | width: 100%; 19 | box-sizing: border-box; 20 | } 21 | 22 | .contact-card .icon { 23 | display: block; 24 | max-height: 50px; 25 | max-width: 50px; 26 | margin-right: 15px; 27 | border: 1px solid whitesmoke 28 | } 29 | 30 | .contact-info { 31 | overflow: hidden; 32 | } 33 | 34 | .contact-card .contact-name { 35 | flex: 1; 36 | font-weight: 600; 37 | font-size: 14px; 38 | color: #4b4a49; 39 | text-overflow: ellipsis; 40 | white-space: nowrap; 41 | overflow: hidden; 42 | } 43 | 44 | .contact-card .contact-email, 45 | .contact-card .contact-phone { 46 | font-size: 13px; 47 | height: 19px; 48 | display: flex; 49 | flex-direction: row; 50 | align-items: center; 51 | padding-right: 10px; 52 | pointer-events: none; 53 | } 54 | 55 | .contact-info-icon { 56 | color: darkgrey; 57 | margin-right: 4px; 58 | } 59 | 60 | [data-initials]:before { 61 | background: #a2478a;; 62 | color: white; 63 | opacity: 1; 64 | content: attr(data-initials); 65 | display: inline-block; 66 | font-weight: 600; 67 | font-size: 20px; 68 | border-radius: 50%; 69 | vertical-align: middle; 70 | margin-right: 0.5em; 71 | width: 50px; 72 | height: 50px; 73 | line-height: 50px; 74 | text-align: center; 75 | } 76 | -------------------------------------------------------------------------------- /gmail/src/models/task.ts: -------------------------------------------------------------------------------- 1 | import { postJsonRpc } from "../utils/http"; 2 | import { URLS } from "../const"; 3 | import { getAccessToken } from "src/services/odoo_auth"; 4 | 5 | /** 6 | * Represent a "project.task" record. 7 | */ 8 | export class Task { 9 | id: number; 10 | name: string; 11 | projectName: string; 12 | 13 | /** 14 | * Unserialize the task object (reverse JSON.stringify). 15 | */ 16 | static fromJson(values: any): Task { 17 | const task = new Task(); 18 | task.id = values.id; 19 | task.name = values.name; 20 | task.projectName = values.projectName; 21 | return task; 22 | } 23 | 24 | /** 25 | * Parse the dictionary return by the Odoo endpoint. 26 | */ 27 | static fromOdooResponse(values: any): Task { 28 | const task = new Task(); 29 | task.id = values.task_id; 30 | task.name = values.name; 31 | task.projectName = values.project_name; 32 | return task; 33 | } 34 | 35 | /** 36 | * Make a RPC call to the Odoo database to create a task 37 | * and return the ID of the newly created record. 38 | */ 39 | static createTask(partnerId: number, projectId: number, emailBody: string, emailSubject: string): Task { 40 | const url = PropertiesService.getUserProperties().getProperty("ODOO_SERVER_URL") + URLS.CREATE_TASK; 41 | const odooAccessToken = getAccessToken(); 42 | 43 | const response = postJsonRpc( 44 | url, 45 | { email_subject: emailSubject, email_body: emailBody, project_id: projectId, partner_id: partnerId }, 46 | { Authorization: "Bearer " + odooAccessToken }, 47 | ); 48 | 49 | const taskId = response ? response.task_id || null : null; 50 | 51 | if (!taskId) { 52 | return null; 53 | } 54 | 55 | return Task.fromJson({ 56 | id: taskId, 57 | name: response.name, 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/SectionTickets/SectionTickets.tsx: -------------------------------------------------------------------------------- 1 | import Partner from '../../../classes/Partner'; 2 | import HelpdeskTicket from '../../../classes/HelpdeskTicket'; 3 | import * as React from 'react'; 4 | import api from '../../api'; 5 | import AppContext from '../AppContext'; 6 | import Section from '../Section/Section'; 7 | import { _t } from '../../../utils/Translator'; 8 | 9 | type SectionTicketsProps = { 10 | partner: Partner; 11 | canCreatePartner: boolean; 12 | }; 13 | 14 | type SectionTicketsState = { 15 | tickets: HelpdeskTicket[]; 16 | }; 17 | 18 | class SectionTickets extends React.Component { 19 | constructor(props, context) { 20 | super(props, context); 21 | const sortedTicket = this.props.partner.tickets.sort((t1, t2) => Number(t1.isClosed) - Number(t2.isClosed)); 22 | this.state = { tickets: sortedTicket }; 23 | } 24 | 25 | render() { 26 | return ( 27 |
ticket.isClosed && _t('Closed')} 42 | /> 43 | ); 44 | } 45 | } 46 | SectionTickets.contextType = AppContext; 47 | export default SectionTickets; 48 | -------------------------------------------------------------------------------- /gmail/src/services/log_email.ts: -------------------------------------------------------------------------------- 1 | import { postJsonRpc } from "../utils/http"; 2 | import { escapeHtml } from "../utils/html"; 3 | import { URLS } from "../const"; 4 | import { Email } from "../models/email"; 5 | import { ErrorMessage } from "../models/error_message"; 6 | import { _t } from "../services/translation"; 7 | import { getAccessToken } from "./odoo_auth"; 8 | 9 | /** 10 | * Format the email body before sending it to Odoo. 11 | * Add error message at the end of the email, fix some CSS issues,... 12 | */ 13 | function _formatEmailBody(email: Email, error: ErrorMessage): string { 14 | let body = email.body; 15 | 16 | body = `${_t("From:")} ${escapeHtml(email.contactEmail)}

${body}`; 17 | 18 | if (error.code === "attachments_size_exceeded") { 19 | body += `
${_t( 20 | "Attachments could not be logged in Odoo because their total size exceeded the allowed maximum.", 21 | )}`; 22 | } 23 | 24 | // Make the "attachment" links bigger, otherwise we need to scroll to fully see them 25 | // Can not add a 25 | 26 |
__ERROR_MESSAGE__
27 | `; 28 | 29 | /** 30 | * Callback function called during the OAuth authentication process. 31 | * 32 | * 1. The user click on the "Login button" 33 | * We generate a state token (for this function) 34 | * 2. The user is redirected to Odoo and enter his login / password 35 | * 3. Then the user is redirected to the Google App-Script 36 | * 4. Thanks the the state token, the function "odooAuthCallback" is called with the auth code 37 | * 5. The auth code is exchanged for an access token with a RPC call 38 | */ 39 | function odooAuthCallback(callbackRequest: any) { 40 | Logger.log("Run authcallback"); 41 | const success = callbackRequest.parameter.success; 42 | const authCode = callbackRequest.parameter.auth_code; 43 | 44 | if (success !== "1") { 45 | return HtmlService.createHtmlOutput( 46 | errorPage.replace("__ERROR_MESSAGE__", "Odoo did not return successfully."), 47 | ); 48 | } 49 | 50 | Logger.log("Get access token from auth code..."); 51 | 52 | const userProperties = PropertiesService.getUserProperties(); 53 | const odooUrl = userProperties.getProperty("ODOO_SERVER_URL"); 54 | 55 | const response = postJsonRpc(odooUrl + ODOO_AUTH_URLS.CODE_VALIDATION, { 56 | auth_code: authCode, 57 | }); 58 | 59 | if (!response || !response.access_token || !response.access_token.length) { 60 | return HtmlService.createHtmlOutput( 61 | errorPage.replace( 62 | "__ERROR_MESSAGE__", 63 | "The token exchange failed. Maybe your token has expired or your database can not be reached by the Google server." + 64 | "
Contact your administrator or our support.", 65 | ), 66 | ); 67 | } 68 | 69 | const accessToken = response.access_token; 70 | 71 | userProperties.setProperty("ODOO_ACCESS_TOKEN", accessToken); 72 | 73 | return HtmlService.createHtmlOutput("Success !"); 74 | } 75 | 76 | /** 77 | * Generate the URL to redirect the user for the authentication to the Odoo database. 78 | * 79 | * This URL contains a state and the Odoo database should resend it. 80 | * The Google server use the state code to know which function to execute when the user 81 | * is redirected on their server. 82 | */ 83 | export function getOdooAuthUrl() { 84 | const userProperties = PropertiesService.getUserProperties(); 85 | const odooUrl = userProperties.getProperty("ODOO_SERVER_URL"); 86 | const scriptId = ScriptApp.getScriptId(); 87 | 88 | if (!odooUrl || !odooUrl.length) { 89 | throw new Error("Can not retrieve the Odoo database URL."); 90 | } 91 | 92 | if (!scriptId || !scriptId.length) { 93 | throw new Error("Can not retrieve the script ID."); 94 | } 95 | 96 | const stateToken = ScriptApp.newStateToken().withMethod(odooAuthCallback.name).withTimeout(3600).createToken(); 97 | 98 | const redirectToAddon = `https://script.google.com/macros/d/${scriptId}/usercallback`; 99 | const scope = ODOO_AUTH_URLS.SCOPE; 100 | 101 | const url = 102 | odooUrl + 103 | ODOO_AUTH_URLS.AUTH_CODE + 104 | encodeQueryData({ 105 | redirect: redirectToAddon, 106 | friendlyname: "Gmail", 107 | state: stateToken, 108 | scope: scope, 109 | }); 110 | 111 | return url; 112 | } 113 | 114 | /** 115 | * Return the access token saved in the user properties. 116 | */ 117 | export const getAccessToken = () => { 118 | const userProperties = PropertiesService.getUserProperties(); 119 | const accessToken = userProperties.getProperty("ODOO_ACCESS_TOKEN"); 120 | if (!accessToken || !accessToken.length) { 121 | return; 122 | } 123 | return accessToken; 124 | }; 125 | 126 | /** 127 | * Reset the access token saved in the user properties. 128 | */ 129 | export const resetAccessToken = () => { 130 | const userProperties = PropertiesService.getUserProperties(); 131 | userProperties.deleteProperty("ODOO_ACCESS_TOKEN"); 132 | }; 133 | 134 | /** 135 | * Make an HTTP request to "/mail_plugin/auth/access_token" (cors="*") on the Odoo 136 | * database to verify that the server is reachable and that the mail plugin module is 137 | * installed. 138 | * 139 | * Returns True if the Odoo database is reachable and if the "mail_plugin" module 140 | * is installed, false otherwise. 141 | */ 142 | export const isOdooDatabaseReachable = (odooUrl: string): boolean => { 143 | if (!odooUrl || !odooUrl.length) { 144 | return false; 145 | } 146 | 147 | const response = postJsonRpc( 148 | odooUrl + ODOO_AUTH_URLS.CODE_VALIDATION, 149 | { auth_code: null }, 150 | {}, 151 | { returnRawResponse: true }, 152 | ); 153 | if (!response) { 154 | return false; 155 | } 156 | 157 | const responseCode = response.getResponseCode(); 158 | 159 | if (responseCode > 299 || responseCode < 200) { 160 | return false; 161 | } 162 | 163 | return true; 164 | }; 165 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Section/Section.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Partner from '../../../classes/Partner'; 3 | import { ContentType, HttpVerb, sendHttpRequest } from '../../../utils/httpRequest'; 4 | import { _t } from '../../../utils/Translator'; 5 | import CollapseSection from '../CollapseSection/CollapseSection'; 6 | import ListItem from '../ListItem/ListItem'; 7 | import api from '../../api'; 8 | import AppContext from '../AppContext'; 9 | 10 | type SectionAbstractProps = { 11 | className?: string; 12 | records: any[]; 13 | partner: Partner; 14 | canCreatePartner: boolean; 15 | 16 | // Odoo Record creation 17 | model: string; 18 | // endpoint used to create the record 19 | odooEndpointCreateRecord: string; 20 | // name of the key returned by Odoo containing the record ID 21 | odooRecordIdName: string; 22 | // Odoo action name used for the redirection 23 | odooRedirectAction: string; 24 | // Event when we click on the "+" button to create a new record 25 | // Can be intercepted to give additional values before creating the record 26 | // (e.g.: Search a project and add the project ID before creating a task) 27 | onClickCreate?: (callback: (any?) => void) => void; 28 | 29 | // Messages 30 | title: string; 31 | titleCount: string; 32 | msgNoPartner: string; 33 | msgNoPartnerNoAccess: string; 34 | msgNoRecord: string; 35 | msgLogEmail: string; 36 | getRecordDescription: (any) => string; 37 | }; 38 | 39 | type SectionAbstractState = { 40 | records: any[]; 41 | isCollapsed: boolean; 42 | }; 43 | 44 | /** 45 | * Section Component used to display the list of leads, tasks, tickets... Allow to create 46 | * the record, to log the email on the record or to hide them. 47 | */ 48 | class Section extends React.Component { 49 | constructor(props, context) { 50 | super(props, context); 51 | const isCollapsed = !props.records || !props.records.length; 52 | this.state = { records: this.props.records, isCollapsed: isCollapsed }; 53 | } 54 | 55 | private onClickCreate = () => { 56 | if (this.props.onClickCreate) { 57 | this.props.onClickCreate(this.createRecordRequest); 58 | } else { 59 | this.createRecordRequest(); 60 | } 61 | }; 62 | 63 | private createRecordRequest = (additionnalValues?) => { 64 | Office.context.mailbox.item.body.getAsync(Office.CoercionType.Html, async (result) => { 65 | // Remove the history and only log the most recent message. 66 | const message = result.value.split('
')[0]; 67 | const subject = Office.context.mailbox.item.subject; 68 | 69 | const requestJson = Object.assign( 70 | { 71 | partner_id: this.props.partner.id, 72 | email_body: message, 73 | email_subject: subject, 74 | }, 75 | additionnalValues || {}, 76 | ); 77 | 78 | let response = null; 79 | try { 80 | response = await sendHttpRequest( 81 | HttpVerb.POST, 82 | api.baseURL + this.props.odooEndpointCreateRecord, 83 | ContentType.Json, 84 | this.context.getConnectionToken(), 85 | requestJson, 86 | true, 87 | ).promise; 88 | } catch (error) { 89 | this.context.showHttpErrorMessage(error); 90 | return; 91 | } 92 | 93 | const parsed = JSON.parse(response); 94 | if (parsed['error']) { 95 | this.context.showTopBarMessage(); 96 | return; 97 | } 98 | const cids = this.context.getUserCompaniesString(); 99 | const recordId = parsed.result[this.props.odooRecordIdName]; 100 | const url = `${api.baseURL}/web#action=${this.props.odooRedirectAction}&id=${recordId}&model=${this.props.model}&view_type=form${cids}`; 101 | window.open(url); 102 | }); 103 | }; 104 | 105 | private getSection = () => { 106 | if (!this.props.partner.isAddedToDatabase()) { 107 | return ( 108 |
109 | {_t(this.props.canCreatePartner ? this.props.msgNoPartner : this.props.msgNoPartnerNoAccess)} 110 |
111 | ); 112 | } else if (this.state.records.length > 0) { 113 | return ( 114 |
115 | {this.state.records.map((record) => ( 116 | 124 | ))} 125 |
126 | ); 127 | } 128 | return
{_t(this.props.msgNoRecord)}
; 129 | }; 130 | 131 | render() { 132 | const recordCount = this.state.records && this.state.records.length; 133 | const title = this.state.records 134 | ? _t(this.props.titleCount, { count: recordCount.toString() }) 135 | : _t(this.props.title); 136 | 137 | return ( 138 | 144 | {this.getSection()} 145 | 146 | ); 147 | } 148 | } 149 | 150 | Section.contextType = AppContext; 151 | export default Section; 152 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/SectionTasks/SelectProjectDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner, SpinnerSize, TextField } from 'office-ui-fabric-react'; 2 | import * as React from 'react'; 3 | import Partner from '../../../classes/Partner'; 4 | import Project from '../../../classes/Project'; 5 | import { ContentType, HttpVerb, sendHttpRequest } from '../../../utils/httpRequest'; 6 | import { OdooTheme } from '../../../utils/Themes'; 7 | import { _t } from '../../../utils/Translator'; 8 | import api from '../../api'; 9 | import './SelectProjectDropdown.css'; 10 | import AppContext from '../AppContext'; 11 | 12 | type SelectProjectProps = { 13 | partner: Partner; 14 | canCreateProject: boolean; 15 | onProjectClick: (project: Project) => void; 16 | }; 17 | 18 | type SelectProjectState = { 19 | query: string; 20 | isLoading: boolean; 21 | projects: Project[]; 22 | }; 23 | 24 | class SelectProjectDropdown extends React.Component { 25 | constructor(props, context) { 26 | super(props, context); 27 | this.state = { query: '', isLoading: false, projects: [] }; 28 | } 29 | 30 | private projectsRequest; 31 | 32 | private onQueryChanged = (event) => { 33 | const query = event.target.value; 34 | this.setState({ query: query }); 35 | this.cancelProjectsRequest(); 36 | if (query.length > 0) { 37 | this.getProjectsRequest(query); 38 | } else { 39 | this.setState({ isLoading: false, projects: [] }); 40 | } 41 | }; 42 | 43 | private cancelProjectsRequest = () => { 44 | if (this.projectsRequest) this.projectsRequest.cancel(); 45 | }; 46 | 47 | private getProjectsRequest = async (searchTerm: string) => { 48 | if (!searchTerm || !searchTerm.length) { 49 | return; 50 | } 51 | 52 | this.setState({ isLoading: true }); 53 | 54 | this.projectsRequest = sendHttpRequest( 55 | HttpVerb.POST, 56 | api.baseURL + api.searchProject, 57 | ContentType.Json, 58 | this.context.getConnectionToken(), 59 | { search_term: searchTerm }, 60 | true, 61 | ); 62 | 63 | this.context.addRequestCanceller(this.projectsRequest.cancel); 64 | 65 | let response = null; 66 | try { 67 | response = JSON.parse(await this.projectsRequest.promise); 68 | } catch (error) { 69 | if (error) { 70 | this.setState({ isLoading: false, projects: [] }); 71 | this.context.showHttpErrorMessage(error); 72 | } 73 | return; 74 | } 75 | const projects = response.result.map((project_json) => Project.fromJson(project_json)); 76 | this.setState({ projects: projects, isLoading: false }); 77 | }; 78 | 79 | private createProject = async () => { 80 | const createProjectRequest = sendHttpRequest( 81 | HttpVerb.POST, 82 | api.baseURL + api.createProject, 83 | ContentType.Json, 84 | this.context.getConnectionToken(), 85 | { name: this.state.query }, 86 | true, 87 | ); 88 | 89 | this.setState({ isLoading: true }); 90 | 91 | let response = null; 92 | try { 93 | response = JSON.parse(await createProjectRequest.promise); 94 | } catch (error) { 95 | if (error) { 96 | this.setState({ isLoading: false, projects: [] }); 97 | this.context.showHttpErrorMessage(error); 98 | this.setState({ isLoading: false }); 99 | } 100 | return; 101 | } 102 | 103 | const createdProject = Project.fromJson(response.result); 104 | this.props.onProjectClick(createdProject); 105 | }; 106 | 107 | private getProjects = () => { 108 | const searchedTermExists = this.state.projects.filter( 109 | (p) => p.name.toUpperCase() === this.state.query.toUpperCase(), 110 | ).length; 111 | 112 | const allowCreateNewProject = this.props.canCreateProject && !!this.state.query.length && !searchedTermExists; 113 | 114 | return ( 115 |
116 | {this.state.projects.map((project) => ( 117 |
this.props.onProjectClick(project)}> 121 | {project.name} 122 |
123 | ))} 124 | {allowCreateNewProject && ( 125 |
126 | {_t('Create %(name)s', { name: this.state.query })} 127 |
128 | )} 129 | {this.state.query.length && !allowCreateNewProject && !this.state.projects.length ? ( 130 |
{_t('No Project Found')}
131 | ) : null} 132 | {this.state.isLoading && ( 133 | 134 | )} 135 |
136 | ); 137 | }; 138 | 139 | render() { 140 | return ( 141 |
142 |
{_t('Pick a Project to create a Task')}
143 |
144 | e.target.select()} 151 | /> 152 |
153 | {this.getProjects()} 154 |
155 | ); 156 | } 157 | } 158 | 159 | SelectProjectDropdown.contextType = AppContext; 160 | export default SelectProjectDropdown; 161 | -------------------------------------------------------------------------------- /outlook/src/classes/CompanyCache.ts: -------------------------------------------------------------------------------- 1 | import Company from './Company'; 2 | import Address from './Address'; 3 | 4 | /* TODO: Write a version with a doubly linked list tha avoid loading the whole 5 | cache to prune. And compare the perfs */ 6 | 7 | class CacheObject { 8 | version: number; 9 | timestamp: number; 10 | company: Company; 11 | constructor(version: number, timestamp: number, company: Company) { 12 | this.version = version; 13 | this.timestamp = timestamp; 14 | this.company = company; 15 | } 16 | } 17 | 18 | class CompanyCache { 19 | size: number; 20 | occupancy: number; 21 | pruningRatio: number; 22 | version: number; 23 | 24 | constructor(version: number, size: number, pruningRatio: number) { 25 | this.size = size; 26 | this.pruningRatio = pruningRatio; 27 | this.version = version; 28 | 29 | // Compute the actual occupancy of the current local storage. 30 | this.occupancy = 0; 31 | for (const key in localStorage) { 32 | if (this._isCompanyDomain(key)) { 33 | const cacheObject: CacheObject = JSON.parse(localStorage.getItem(key)); 34 | // Remove old cache objects. Objects whose version differ from this cache's version. 35 | if (!('version' in cacheObject) || cacheObject.version != this.version) { 36 | localStorage.removeItem(key); 37 | } else { 38 | ++this.occupancy; 39 | } 40 | } 41 | } 42 | 43 | // If this new LRU has different parameters than the previous one, it 44 | // may already be necessary to prune. 45 | if (this.occupancy > this.size) { 46 | this._prune(); 47 | } 48 | } 49 | 50 | _isCompanyDomain(s: string): boolean { 51 | return s.startsWith('@'); 52 | } 53 | 54 | _getCacheKeyForCompany(company: Company): string { 55 | const assignedCompany = Object.assign(new Company(), company); // to get the methods on the basic object. 56 | return '@' + assignedCompany.getBareDomain(); 57 | } 58 | 59 | _getCacheKeyForDomain(domain: string): string { 60 | return '@' + domain; 61 | } 62 | 63 | _getCacheKeyForEmail(email: string): string { 64 | return '@' + email.split('@')[1]; 65 | } 66 | 67 | /* Pruning 1000 elements with a ratio of 0.5 took 7ms. 68 | on Chrome 81.0.4044.122, with a Ryzen 3700X 69 | Removing only one, the oldest, took 5ms. */ 70 | _prune() { 71 | // Retrieve all cacheObjects and put them in an array. 72 | const cacheObjects: CacheObject[] = []; 73 | for (const key in localStorage) { 74 | if (this._isCompanyDomain(key)) { 75 | cacheObjects.push(JSON.parse(localStorage.getItem(key))); 76 | } 77 | } 78 | 79 | // Sort them from most recent to oldest. 80 | cacheObjects.sort((a: CacheObject, b: CacheObject) => { 81 | if (a.timestamp > b.timestamp) { 82 | return -1; 83 | } 84 | if (a.timestamp < b.timestamp) { 85 | return 1; 86 | } 87 | return 0; 88 | }); 89 | 90 | // How many to keep. A pruning ratio of 0 make the LRU behave like an 91 | // actual LRU, removing only the one oldest element. 92 | const toKeep: number = this.pruningRatio 93 | ? Math.ceil(cacheObjects.length * (1 - this.pruningRatio)) 94 | : cacheObjects.length - 1; 95 | 96 | // Prune. 97 | for (let i: number = toKeep; i < cacheObjects.length; ++i) { 98 | localStorage.removeItem(this._getCacheKeyForCompany(cacheObjects[i].company)); 99 | } 100 | 101 | this.occupancy = toKeep; 102 | } 103 | 104 | add(company: Company) { 105 | const cacheKey: string = this._getCacheKeyForCompany(company); 106 | const cacheObject = new CacheObject(this.version, new Date().getTime(), company); 107 | 108 | // Check whether it's just a replace. 109 | if (localStorage.getItem(cacheKey)) { 110 | localStorage.setItem(cacheKey, JSON.stringify(cacheObject)); 111 | return; 112 | } 113 | 114 | // It's an actual add. This will impact the occupancy. 115 | if (this.occupancy < this.size) { 116 | localStorage.setItem(cacheKey, JSON.stringify(cacheObject)); 117 | ++this.occupancy; 118 | return; 119 | } 120 | 121 | // Pruning time. 122 | this._prune(); 123 | 124 | // Add the new item. 125 | localStorage.setItem(cacheKey, JSON.stringify(cacheObject)); 126 | ++this.occupancy; 127 | } 128 | 129 | get(email: string): Company { 130 | const cacheKey: string = this._getCacheKeyForEmail(email); 131 | // Get the corresponding CacheObject. 132 | const cacheObjectstring: string = localStorage.getItem(cacheKey); 133 | if (cacheObjectstring === null) { 134 | return null; 135 | } 136 | const cacheObject: CacheObject = JSON.parse(cacheObjectstring); 137 | 138 | // Update the timestamp in the LRU. 139 | cacheObject.timestamp = new Date().getTime(); 140 | localStorage.setItem(cacheKey, JSON.stringify(cacheObject)); 141 | 142 | // Return the company. 143 | // Prototype is assigned to get back class methods. TODO: find a more generic solution. 144 | Object.setPrototypeOf(cacheObject.company, Company.prototype); 145 | Object.setPrototypeOf(cacheObject.company.address, Address.prototype); 146 | return cacheObject.company; 147 | } 148 | 149 | remove(email: string): boolean { 150 | const cacheKey: string = this._getCacheKeyForEmail(email); 151 | // Get the corresponding CacheObject. 152 | const cacheObjectstring: string = localStorage.getItem(cacheKey); 153 | if (cacheObjectstring === null) { 154 | return false; 155 | } 156 | 157 | // Actually remove the CacheObject. 158 | localStorage.removeItem(cacheKey); 159 | --this.occupancy; 160 | 161 | return true; 162 | } 163 | } 164 | 165 | export default CompanyCache; 166 | -------------------------------------------------------------------------------- /outlook/src/classes/Company.ts: -------------------------------------------------------------------------------- 1 | import Address from './Address'; 2 | import EnrichmentInfo, { EnrichmentInfoType } from './EnrichmentInfo'; 3 | 4 | /*** 5 | * id reserved for empty companies. 6 | */ 7 | const ID_COMPANY_EMPTY: number = -1; 8 | 9 | export enum EnrichmentStatus { 10 | enrichmentAvailable = 0, // means that the company has not been enriched before and could maybe be enriched 11 | enriched = 1, // means that the company has been previously enriched 12 | enrichmentEmpty = 2, // means that an attempt was made to enrich the company and that no data was found 13 | } 14 | 15 | class Company { 16 | id: number; 17 | domain: string; 18 | name: string; 19 | website: string; 20 | image: string; // base64 21 | address: Address; 22 | phone: string; 23 | email: string; 24 | additionalInfo: {}; //Map; 25 | 26 | enrichmentStatus: EnrichmentStatus; 27 | 28 | constructor() { 29 | this.id = ID_COMPANY_EMPTY; 30 | this.domain = ''; 31 | this.name = ''; 32 | this.website = ''; 33 | this.phone = ''; 34 | this.address = new Address(); 35 | this.image = ''; 36 | this.email = ''; 37 | this.additionalInfo = {}; 38 | } 39 | 40 | static fromJSON(o: Object, enrichmentInfo?: EnrichmentInfo): Company { 41 | if (!o) return new Company(); 42 | const company = Object.assign(new Company(), o); 43 | company.address = Address.fromJSON(o['address']); 44 | 45 | if (enrichmentInfo != null) { 46 | switch (enrichmentInfo.type) { 47 | case EnrichmentInfoType.NoData: 48 | company.enrichmentStatus = EnrichmentStatus.enrichmentEmpty; 49 | break; 50 | case EnrichmentInfoType.CompanyCreated: 51 | case EnrichmentInfoType.CompanyUpdated: 52 | company.enrichmentStatus = EnrichmentStatus.enriched; 53 | break; 54 | } 55 | } else if (JSON.stringify(company.additionalInfo) != '{}') { 56 | company.enrichmentStatus = EnrichmentStatus.enriched; 57 | } else { 58 | company.enrichmentStatus = EnrichmentStatus.enrichmentAvailable; 59 | } 60 | 61 | return company; 62 | } 63 | 64 | static fromRevealJSON(o: Object): Company { 65 | const company = new Company(); 66 | company.id = ID_COMPANY_EMPTY; 67 | company.additionalInfo = o; 68 | return company; 69 | } 70 | 71 | static getEmptyCompany = (): Company => { 72 | const company = new Company(); 73 | company.id = ID_COMPANY_EMPTY; 74 | return company; 75 | }; 76 | 77 | getDomain(): string { 78 | let domain = this.domain || this.additionalInfo['domain'] || this.website; 79 | if (domain && !domain.startsWith('http://') && !domain.startsWith('https://')) { 80 | domain = 'https://' + domain; 81 | } 82 | return domain; 83 | } 84 | 85 | getBareDomain() { 86 | let domain = this.domain || this.additionalInfo['domain']; 87 | if (domain) { 88 | domain = domain.replace(/(^\w+:|^)\/\//, ''); 89 | } 90 | return domain; 91 | } 92 | 93 | getPhone(): string { 94 | if (this.phone) return this.phone; 95 | 96 | if (this.additionalInfo['phone_numbers'] && this.additionalInfo['phone_numbers'].length > 0) 97 | return this.additionalInfo['phone_numbers'][0]; 98 | 99 | return ''; 100 | } 101 | 102 | getName(): string { 103 | return this.name || this.additionalInfo['name']; 104 | } 105 | 106 | getDescription(): string { 107 | return this.additionalInfo['description']; 108 | } 109 | 110 | getIndustry(): string { 111 | const industries = [this.additionalInfo['industry_group'], this.additionalInfo['sub_industry']]; 112 | return industries.filter(Boolean).join(', '); 113 | } 114 | 115 | getEmployees(): number { 116 | return this.additionalInfo['employees']; 117 | } 118 | 119 | getYearFounded(): number { 120 | return this.additionalInfo['founded_year']; 121 | } 122 | 123 | getKeywords(): string { 124 | if (this.additionalInfo['tag']) return this.additionalInfo['tag'].join(', '); 125 | return ''; 126 | } 127 | 128 | getCompanyType(): string { 129 | return this.additionalInfo['company_type']; 130 | } 131 | 132 | getLogoURL(): string { 133 | return this.additionalInfo['logo']; 134 | } 135 | 136 | /* "annual_revenue" seems to be a number, often at 0.0 without a currency. Let's ignore this field. 137 | Data sample: "annual_revenue": 0.0, 138 | "estimated_annual_revenue": "$50M-$100M", 139 | */ 140 | getRevenue(): string { 141 | return this.additionalInfo['estimated_annual_revenue']; 142 | } 143 | 144 | getLocation(): string { 145 | return this.address.getLines().join(', ') || this.additionalInfo['location']; 146 | } 147 | 148 | getTwitter(): string { 149 | return this.additionalInfo['twitter']; 150 | } 151 | 152 | getFacebook(): string { 153 | return this.additionalInfo['facebook']; 154 | } 155 | 156 | getLinkedin(): string { 157 | return this.additionalInfo['linkedin']; 158 | } 159 | 160 | getCrunchbase(): string { 161 | return this.additionalInfo['crunchbase']; 162 | } 163 | 164 | getInitials(): string { 165 | const name = this.getName(); 166 | if (!name) { 167 | return ''; 168 | } 169 | const names = this.name.split(' '); 170 | let initials = names[0].substring(0, 1).toUpperCase(); 171 | 172 | // If the company is more than two words, better only include the first letter of the first word. 173 | if (names.length == 2) { 174 | initials += names[1].substring(0, 1).toUpperCase(); 175 | } 176 | 177 | return initials; 178 | } 179 | 180 | /*** 181 | * Returns True if the company exists in the Odoo database, False otherwise 182 | */ 183 | isAddedToDatabase(): boolean { 184 | return this.id && this.id > 0; 185 | } 186 | } 187 | 188 | export default Company; 189 | -------------------------------------------------------------------------------- /gmail/src/views/create_task.ts: -------------------------------------------------------------------------------- 1 | import { buildView } from "../views/index"; 2 | import { updateCard, pushCard, pushToRoot } from "./helpers"; 3 | import { UI_ICONS } from "./icons"; 4 | import { createKeyValueWidget, actionCall, notify } from "./helpers"; 5 | import { URLS } from "../const"; 6 | import { getOdooServerUrl } from "src/services/app_properties"; 7 | import { ErrorMessage } from "../models/error_message"; 8 | import { Project } from "../models/project"; 9 | import { State } from "../models/state"; 10 | import { Task } from "../models/task"; 11 | import { logEmail } from "../services/log_email"; 12 | import { _t } from "../services/translation"; 13 | 14 | function onSearchProjectClick(state: State, parameters: any, inputs: any) { 15 | const inputQuery = inputs.search_project_query; 16 | const query = (inputQuery && inputQuery.length && inputQuery[0]) || ""; 17 | const [projects, error] = Project.searchProject(query); 18 | 19 | state.error = error; 20 | state.searchedProjects = projects; 21 | 22 | const createTaskView = buildCreateTaskView(state, query, true); 23 | 24 | // If go back, show again the "Create Project" section, but do not show all old searches 25 | return parameters.hideCreateProjectSection ? updateCard(createTaskView) : pushCard(createTaskView); 26 | } 27 | 28 | function onCreateProjectClick(state: State, parameters: any, inputs: any) { 29 | const inputQuery = inputs.new_project_name; 30 | const projectName = (inputQuery && inputQuery.length && inputQuery[0]) || ""; 31 | 32 | if (!projectName || !projectName.length) { 33 | return notify(_t("The project name is required")); 34 | } 35 | 36 | const project = Project.createProject(projectName); 37 | if (!project) { 38 | return notify(_t("Could not create the project")); 39 | } 40 | 41 | return onSelectProject(state, { project: project }); 42 | } 43 | 44 | function onSelectProject(state: State, parameters: any) { 45 | const project = Project.fromJson(parameters.project); 46 | const task = Task.createTask(state.partner.id, project.id, state.email.body, state.email.subject); 47 | 48 | if (!task) { 49 | return notify(_t("Could not create the task")); 50 | } 51 | 52 | task.projectName = project.name; 53 | state.partner.tasks.push(task); 54 | 55 | const taskUrl = 56 | PropertiesService.getUserProperties().getProperty("ODOO_SERVER_URL") + 57 | `/web#id=${task.id}&action=project_mail_plugin.project_task_action_form_edit&model=project.task&view_type=form`; 58 | 59 | // Open the URL to the Odoo task and update the card 60 | return CardService.newActionResponseBuilder() 61 | .setOpenLink(CardService.newOpenLink().setUrl(taskUrl)) 62 | .setNavigation(pushToRoot(buildView(state))) 63 | .build(); 64 | } 65 | 66 | export function buildCreateTaskView(state: State, query: string = "", hideCreateProjectSection: boolean = false) { 67 | let noProject = false; 68 | if (!state.searchedProjects) { 69 | // Initiate the search 70 | [state.searchedProjects, state.error] = Project.searchProject(""); 71 | noProject = !state.searchedProjects.length; 72 | } 73 | 74 | const odooServerUrl = getOdooServerUrl(); 75 | const partner = state.partner; 76 | const tasks = partner.tasks; 77 | const projects = state.searchedProjects; 78 | 79 | const card = CardService.newCardBuilder(); 80 | 81 | if (!noProject) { 82 | const projectSection = CardService.newCardSection().setHeader( 83 | "" + _t("Create a Task in an existing Project") + "", 84 | ); 85 | 86 | projectSection.addWidget( 87 | CardService.newTextInput() 88 | .setFieldName("search_project_query") 89 | .setTitle(_t("Search a Project")) 90 | .setValue(query || "") 91 | .setOnChangeAction( 92 | actionCall(state, onSearchProjectClick.name, { 93 | hideCreateProjectSection: hideCreateProjectSection, 94 | }), 95 | ), 96 | ); 97 | 98 | projectSection.addWidget( 99 | CardService.newTextButton() 100 | .setText(_t("Search")) 101 | .setOnClickAction( 102 | actionCall(state, onSearchProjectClick.name, { 103 | hideCreateProjectSection: hideCreateProjectSection, 104 | }), 105 | ), 106 | ); 107 | 108 | if (!projects.length) { 109 | projectSection.addWidget(CardService.newTextParagraph().setText(_t("No project found."))); 110 | } 111 | for (let project of projects) { 112 | const projectCard = createKeyValueWidget( 113 | null, 114 | project.name, 115 | null, 116 | project.partnerName, 117 | null, 118 | actionCall(state, onSelectProject.name, { project: project }), 119 | ); 120 | 121 | projectSection.addWidget(projectCard); 122 | } 123 | card.addSection(projectSection); 124 | } 125 | 126 | if (!hideCreateProjectSection && state.canCreateProject) { 127 | const createProjectSection = CardService.newCardSection().setHeader( 128 | "" + _t("Create a Task in a new Project") + "", 129 | ); 130 | 131 | createProjectSection.addWidget( 132 | CardService.newTextInput().setFieldName("new_project_name").setTitle(_t("Project Name")).setValue(""), 133 | ); 134 | 135 | createProjectSection.addWidget( 136 | CardService.newTextButton() 137 | .setText(_t("Create Project & Task")) 138 | .setOnClickAction(actionCall(state, onCreateProjectClick.name)), 139 | ); 140 | card.addSection(createProjectSection); 141 | } else if (noProject) { 142 | const noProjectSection = CardService.newCardSection(); 143 | 144 | noProjectSection.addWidget(CardService.newImage().setImageUrl(UI_ICONS.empty_folder)); 145 | 146 | noProjectSection.addWidget(CardService.newTextParagraph().setText("" + _t("No project") + "")); 147 | 148 | noProjectSection.addWidget( 149 | CardService.newTextParagraph().setText( 150 | _t("There are no project in your database. Please ask your project manager to create one."), 151 | ), 152 | ); 153 | 154 | card.addSection(noProjectSection); 155 | } 156 | 157 | return card.build(); 158 | } 159 | -------------------------------------------------------------------------------- /outlook/src/taskpane/components/Search/Search.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Partner from '../../../classes/Partner'; 3 | import { ContentType, HttpVerb, sendHttpRequest } from '../../../utils/httpRequest'; 4 | import PartnerData from '../../../classes/Partner'; 5 | import api from '../../api'; 6 | import ContactList from '../Contact/ContactList/ContactList'; 7 | import AppContext from '../AppContext'; 8 | import { Spinner, SpinnerSize } from 'office-ui-fabric-react'; 9 | import { OdooTheme } from '../../../utils/Themes'; 10 | import { faSearch } from '@fortawesome/free-solid-svg-icons'; 11 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 12 | import { TextField } from 'office-ui-fabric-react/lib-amd'; 13 | import { _t } from '../../../utils/Translator'; 14 | import './Search.css'; 15 | 16 | const MAX_PARTNERS = 30; 17 | 18 | type SearchProps = { 19 | query?: string; 20 | canCreatePartner: boolean; 21 | onPartnerClick: (partner: Partner, historyState?: SearchState) => void; 22 | historyState?: any; 23 | }; 24 | 25 | export type SearchState = { 26 | query: string; 27 | foundPartners?: Partner[]; 28 | isLoading: boolean; 29 | }; 30 | 31 | class Search extends React.Component { 32 | constructor(props, context) { 33 | super(props, context); 34 | if (props.historyState) { 35 | this.state = { ...props.historyState }; 36 | } else { 37 | this.state = { 38 | query: props.query || '', 39 | foundPartners: undefined, 40 | isLoading: false, 41 | }; 42 | } 43 | } 44 | 45 | private cancelOnGoingRequest = () => { 46 | if (this.onGoingRequest) this.onGoingRequest.cancel(); 47 | }; 48 | 49 | private getCurrentHistoryState = () => { 50 | if (this.state.isLoading) { 51 | return { 52 | query: this.state.query, 53 | foundPartners: undefined, 54 | isLoading: true, 55 | } as SearchState; 56 | } else { 57 | return { ...this.state } as SearchState; 58 | } 59 | }; 60 | 61 | private getPartnersRequest = (query: String) => { 62 | this.setState({ isLoading: true }); 63 | this.cancelOnGoingRequest(); 64 | if (query.length > 0) { 65 | this.onGoingRequest = sendHttpRequest( 66 | HttpVerb.POST, 67 | api.baseURL + api.searchPartner, 68 | ContentType.Json, 69 | this.context.getConnectionToken(), 70 | { search_term: query }, 71 | true, 72 | ); 73 | this.context.addRequestCanceller(this.onGoingRequest); 74 | this.onGoingRequest.promise 75 | .then((response) => { 76 | const parsed = JSON.parse(response); 77 | const partners = parsed.result.partners.map((partner_json) => { 78 | return PartnerData.fromJSON(partner_json); 79 | }); 80 | this.setState({ foundPartners: partners, isLoading: false }); 81 | }) 82 | .catch((error) => { 83 | this.setState({ foundPartners: [], isLoading: false }); 84 | this.context.showHttpErrorMessage(error); 85 | }); 86 | } else { 87 | this.setState({ foundPartners: [], isLoading: false }); 88 | } 89 | }; 90 | 91 | private onKeyDown = (event) => { 92 | if (event.key == 'Enter') this.getPartnersRequest(this.state.query); 93 | }; 94 | 95 | private onGoingRequest: { promise: Promise; cancel: () => void }; 96 | 97 | private onPartnerClick = (partner) => { 98 | this.props.onPartnerClick(partner, this.getCurrentHistoryState()); 99 | }; 100 | 101 | private onQueryChanged = (event) => { 102 | this.setState({ query: event.target.value }); 103 | }; 104 | 105 | render() { 106 | const searchBar = ( 107 |
108 | e.target.select()} 115 | autoFocus 116 | /> 117 |
this.getPartnersRequest(this.state.query)}> 120 | 121 |
122 |
123 | ); 124 | 125 | let resultsView = null; 126 | if (this.state.isLoading) { 127 | resultsView = ( 128 |
129 | 130 |
131 | ); 132 | } else if (this.state.foundPartners !== undefined) { 133 | let foundContactsNumberText = 134 | this.state.foundPartners.length >= MAX_PARTNERS ? '30+' : this.state.foundPartners.length.toString(); 135 | resultsView = ( 136 |
137 |
138 |
139 |
140 | {_t('Contacts Found (%(count)s)', { 141 | count: foundContactsNumberText, 142 | })} 143 |
144 |
145 |
146 |
147 | 152 |
153 |
154 | ); 155 | } 156 | 157 | return ( 158 |
159 | {searchBar} 160 | {resultsView} 161 |
162 | ); 163 | } 164 | } 165 | 166 | Search.contextType = AppContext; 167 | 168 | export default Search; 169 | -------------------------------------------------------------------------------- /outlook/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | c5549a21-aefb-4ba8-ae7c-b77bceab4023 4 | 2.0.1 5 | Odoo 6 | en-US 7 | 8 | 9 | 10 | 11 | 12 | 13 | https://localhost:3000 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 250 28 | 29 |
30 |
31 | ReadWriteItem 32 | 33 | 34 | 35 | false 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 |