├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── nodejs.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── README.ru.md ├── docs ├── _config.yml ├── index.md └── index.ru.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── assets │ ├── jira_helper_128x128.png │ ├── jira_helper_16x16.png │ ├── jira_helper_32x32.png │ ├── jira_helper_48x48.png │ └── jira_helper_64x64.png ├── background │ ├── actions.js │ ├── background.html │ └── background.js ├── blur-for-sensitive │ ├── blurSensitive.css │ ├── blurSensitive.js │ └── todo.md ├── bug-template │ ├── BugTemplate.js │ ├── styles.css │ └── template.html ├── charts │ ├── AddChartGrid.js │ ├── AddSlaLine.js │ └── utils.js ├── column-limits │ ├── BoardPage │ │ ├── index.js │ │ └── styles.css │ ├── SettingsPage │ │ ├── htmlTemplates.js │ │ ├── index.js │ │ └── styles.css │ └── shared │ │ └── utils.js ├── content.js ├── contextMenu.js ├── field-limits │ ├── BoardPage │ │ ├── htmlTemplates.js │ │ ├── index.js │ │ └── styles.css │ ├── SettingsPage │ │ ├── htmlTemplates.js │ │ ├── index.js │ │ └── styles.css │ └── shared.js ├── issue │ ├── MarkFlaggedIssues.js │ ├── domSelectors.js │ └── img │ │ ├── flag.png │ │ └── flagNew.svg ├── manifest.json ├── options │ ├── options.html │ ├── options.js │ └── static │ │ └── jira_stickers_template.pdf ├── person-limits │ ├── PersonLimits.js │ ├── PersonLimitsSettings.js │ ├── nativeModalScript.js │ ├── personLimitsModal.html │ └── personLimitsModal.js ├── popup │ ├── chromePlugin.html │ └── chromePlugin.js ├── printcards │ ├── PrintCards.js │ ├── cardsRender │ │ ├── cardsRender.md │ │ ├── fonts │ │ │ ├── gostA.woff │ │ │ ├── gostB.ttf │ │ │ └── gostB.woff │ │ ├── printCard.css │ │ ├── printcards.html │ │ ├── printcards.js │ │ ├── renderSingleCardToString.js │ │ └── styleColorEpic.js │ ├── img │ │ └── printIcon.png │ ├── jiraHelperCards.md │ ├── services │ │ ├── popupService │ │ │ ├── helpers │ │ │ │ └── popupTemplates.js │ │ │ └── index.js │ │ ├── printButton.js │ │ └── specialFields.js │ ├── styles │ │ └── printCards.css │ └── utils │ │ └── common.js ├── related-tasks │ └── todo.md ├── routing.js ├── shared │ ├── ExtensionApiService.js │ ├── PageModification.js │ ├── colorPickerTooltip.js │ ├── constants.js │ ├── defaultHeaders.js │ ├── getPopup.js │ ├── htmlTemplates.js │ ├── jiraApi.js │ ├── runModifications.js │ ├── styles.css │ ├── trackChanges.js │ └── utils.js ├── swimlane │ ├── SwimlaneLimits.js │ ├── SwimlaneSettingsPopup.js │ ├── SwimlaneStats.js │ ├── constants.js │ ├── styles.css │ └── utils.js ├── tetris-planning │ ├── TetrisPlanning.js │ ├── TetrisPlanningButton.js │ ├── openModal.js │ ├── styles.css │ ├── template.html │ └── todo.md └── wiplimit-on-cells │ ├── WipLimitOnCells.js │ ├── WiplimitOnCellsSettingsPopup.js │ ├── constants.js │ └── table.js ├── test ├── routing │ ├── test-getCurrentRoute.js │ └── test-getSettingsTab.js └── shared │ ├── jiraApi │ └── test-getUser.js │ ├── test-PageModification.js │ ├── test-runModifications.js │ └── utils │ └── test-utils.js ├── tools └── prepare-commit-message.sh └── webpack ├── AutoRefreshPlugin.js ├── webpack.common.config.js ├── webpack.config.dev.js └── webpack.config.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "chrome": "55" 6 | }, 7 | "useBuiltIns": "usage", 8 | "corejs": 3 9 | }] 10 | ], 11 | "plugins": [ 12 | "@babel/plugin-transform-runtime", 13 | "@babel/plugin-proposal-class-properties" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | tab_width = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | end_of_line = lf 11 | # editorconfig-tools is unable to ignore longs strings or urls 12 | max_line_length = 120 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | *.test.js 3 | **/__tests__/ 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "prettier"], 3 | "plugins": ["prettier"], 4 | "env": { 5 | "browser": true, 6 | "jest": true 7 | }, 8 | "parser": "babel-eslint", 9 | "parserOptions": { 10 | "ecmaVersion": 6, 11 | "sourceType": "module", 12 | "ecmaFeatures": { 13 | "modules": true, 14 | "experimentalObjectRestSpread": true 15 | } 16 | }, 17 | "rules": { 18 | "no-console": "error", 19 | "comma-dangle": "off", 20 | "import/no-extraneous-dependencies": "error", 21 | "consistent-return": "warn", 22 | "import/prefer-default-export": "off", 23 | "quotes": ["error", "single"], 24 | "import/no-unresolved": [2, { "caseSensitive": false }], 25 | "prettier/prettier": ["error", { "endOfLine": "auto" }], 26 | "no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"], 27 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], 28 | "class-methods-use-this": "off", 29 | "no-param-reassign": "off" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # ================================================================================== 2 | # ================================================================================== 3 | # ng-polymorpheus codeowners 4 | # ================================================================================== 5 | # ================================================================================== 6 | # 7 | # Configuration of code ownership and review approvals for the angular/angular repo. 8 | # 9 | # More info: https://help.github.com/articles/about-codeowners/ 10 | # 11 | 12 | * @pavelpower @davakh 13 | # will be requested for review when someone opens a pull request 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - JIRA 7 28 | - JIRA 8 29 | - JIRA Cloud 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'Feature: ' 5 | labels: feature 6 | assignees: pavelpower 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [ 14.x ] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | 21 | - name: Lint, Test & Build 22 | run: | 23 | npm i 24 | npm run lint 25 | npm test 26 | npm run build 27 | env: 28 | CI: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | auto-update.xml 3 | dev-utils.crx 4 | dist.crx 5 | main.pem 6 | dist.pem 7 | .DS_Store 8 | node_modules 9 | .vscode 10 | dist 11 | .gitconfig 12 | coverage 13 | .history -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "proseWrap": "never", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 9 | ## [2.10.1] - 2021-02-25 10 | ### Fixed 11 | 12 | - Fix trouble with expedite swimlane from [@davakh](https://github.com/davakh) 13 | 14 | 15 | ## [2.10.0] - 2021-02-19 16 | ### Added 17 | 18 | — Choosing color for column WIP-limit from [@davakh](https://github.com/davakh) 19 | — Pop Up for set Swimline WIP-limits from [@davakh](https://github.com/davakh) 20 | — text-template for sub-tasks from [@pavelpower](https://github.com/pavelpower) 21 | — WIP-limit for Epics [@davakh](https://github.com/davakh) 22 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@tinkoff.ru. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/TinkoffCreditSystems/jira-helper/workflows/Node%20CI/badge.svg) 2 | 3 | ## “jira-helper” features 4 | 5 | _version 2.6.0_ 6 | 7 | - [Chart Bar - showed count issues in columns for every swimlane on a board](./docs/index.md#swimline-chart-bar) 8 | - [showed a red-flag on the issue panel](./docs/index.md#flag-on-issue-panel) 9 | - [Tetris-planning for the Scrum backlog](./docs/index.md#tetris-planning-for-scrum) 10 | - [Printing stickers 76x76мм on a usual laser printer by the set many issues of JIRA](./docs/index.md#printing-many-stickers) 11 | - [WIP-limit for several columns](./docs/index.md#wip-limits-for-several-columns) 12 | - [WIP-limit for Swimlane](./docs/index.md#wip-limits-for-swimlanes) 13 | - [Personal WIP-limit](./docs/index.md#wip-limit-for-person) 14 | - [SLA-line for Control Chart with percentile](./docs/index.md#sla-line-for-control-chart) 15 | - [Overlay Measurement Ruler on the Control Chart](./docs/index.md#control-chart-ruler) 16 | - [Secret data blurring](./docs/index.md#the-blurring-of-secret-data) 17 | 18 | ## Issuing project tasks 19 | 20 | All tasks are created at [github issues](https://github.com/TinkoffCreditSystems/jira-helper/issues) 21 | 22 | Before creating a task, please make sure that a similar task was not created earlier. Please be sure to check closed tasks - there is a chance that your request has already been fulfilled and will be released soon. 23 | 24 | 25 | ### Requesting a feature 26 | 27 | [Create a new task](https://github.com/TinkoffCreditSystems/jira-helper/issues/new) 28 | 29 | After adding description, please specify the following attributes only: 30 | 31 | - Labels: `feature` 32 | - Project: `jira-helper` 33 | 34 | 35 | ### Requesting a fix 36 | 37 | _In case some feature doesn’t operate as expected._ 38 | 39 | [Create a new task](https://github.com/TinkoffCreditSystems/jira-helper/issues/new) 40 | 41 | After adding description, please specify the following attributes only: 42 | 43 | - Labels: `invalid`, [`cloud jira`, `jira 7`, `jira 8`] – specify in which JIRA version the problem is reproduced. 44 | - Project: `jira-helper` 45 | 46 | 47 | ### Adding a description for a bug/problem 48 | 49 | [Create a new task](https://github.com/TinkoffCreditSystems/jira-helper/issues/new) 50 | 51 | After adding description, please specify the following attributes only: 52 | 53 | - Labels: `bug`, [`cloud jira`, `jira 7`, `jira 8`] – specify in which JIRA version the problem is reproduced. 54 | - Project: `jira-helper` 55 | 56 | 57 | ### List of most often used labels 58 | 59 | | labels | Meaning | 60 | |--------------|:--------------------------------------------------------------------------| 61 | | `feature` | new feature | 62 | | `invalid` | a feature doesn’t work as expected | 63 | | `bug` | a problem/error, please be sure to specify a JIRA version label, where one could reproduce it | 64 | | `jira 7` | reproducible in JIRA 7.x.x | 65 | | `jira 8` | reproducible in JIRA 8.x.x | 66 | | `cloud jira` | reproducible in JIRA Cloud | 67 | 68 | 69 | ## Installing the extension for development purposes 70 | 71 | Execute: 72 | 73 | ``` 74 | npm run bootstrap 75 | npm run dev 76 | ``` 77 | 78 | In Chrome: 79 | 80 | Open the menu, choose “More tools”, then ["Extensions"](chrome://extensions/) 81 | 82 | On the ["Extensions"](chrome://extensions/) page toggle “Developer mode”, press “Load unpacked” in the appeared menu. 83 | 84 | Choose the folder where the extension was built, `~/jira-helper/dist`. 85 | 86 | 87 | ### During development 88 | 89 | When code changes Webpack will automatically update the codebase in the `dist` folder. 90 | 91 | Press “Update” in the ["Extensions"](chrome://extensions/) developer menu and then reload the page, where the extension is being tested. 92 | 93 | 94 | ### Maintaining a git branch and git commits 95 | 96 | The branch name should start with an associated task number. 97 | 98 | Example: `2-title-issue`, where `2` is the mandatory task number. 99 | 100 | Every `commit` should have a task number associated with it. 101 | 102 | Example: `[#15] rename *.feature to *.ru.feature` 103 | 104 | Please use `english` language only to name branches and commits. 105 | 106 | ## Publishing information 107 | 108 | The official version of the extension is published in ["Chrome WebStore"](https://chrome.google.com/webstore/detail/jira-helper/egmbomekcmpieccamghfgjgnlllgbgdl) 109 | 110 | The extension is published after the release [is assembled at github](https://github.com/TinkoffCreditSystems/jira-helper/releases) 111 | 112 | Release version is the same as the application version in package.json [package.json](./package.json) and the version published in ["Chrome WebStore"](https://chrome.google.com/webstore/detail/jira-helper/egmbomekcmpieccamghfgjgnlllgbgdl) 113 | 114 | _Minimum required Chrome [version is >= 55](./src/manifest.json)_ 115 | -------------------------------------------------------------------------------- /README.ru.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/TinkoffCreditSystems/jira-helper.svg?branch=master)](https://travis-ci.com/TinkoffCreditSystems/jira-helper) 2 | 3 | # Расширение для Google Chrome 4 | 5 | ## Функционал расширения "jira-helper" 6 | 7 | _version 2.6.0_ 8 | 9 | - [Chart Bar - показывает количество задач в колоноках для кадой swimlane на доске](./docs/index.ru.md#swimline-chart-bar) 10 | - [Показывает красный флажок на панели задач](./docs/index.ru.md#flag-on-issue-panel) 11 | - [Тетрис-планирование для Scrum backlog](./docs/index.ru.md#tetris-planning-for-scrum) 12 | - [Печать множества стикеров 76x76mm на обычном лазерном принтере](./docs/index.ru.md#printing-many-stickers) 13 | - [WIP-limit для нескольких колонок](./docs/index.ru.md#wip-limits-for-several-columns) 14 | - [WIP-limit для Swimlane](./docs/index.ru.md#wip-limits-for-swimlanes) 15 | - [Personal WIP-limit](./docs/index.ru.md#wip-limit-for-person) 16 | - [SLA-линия для Control Chart с процентилем](./docs/index.ru.md#sla-line-for-control-chart) 17 | - [Наложение линейки измерений на Control Chart](./docs/index.ru.md#ruler-of-measuring-for-control-chart) 18 | - [Размытие секретных данных](./docs/index.ru.md#blurring-of-secret-data) 19 | 20 | ## Ведение задач проекта 21 | 22 | Все задачи заводятся на [github issues](https://github.com/TinkoffCreditSystems/jira-helper/issues) 23 | 24 | Перед добавлением задачи убедитесь, что подобной задачи еще не добавляли. 25 | Обязательно проверьте закрытые задачи, возможно к готовящейся версии такая задача уже добавлена. 26 | 27 | 28 | ### Для добавления нового функционала 29 | 30 | [Создайте новую задачу](https://github.com/TinkoffCreditSystems/jira-helper/issues/new) 31 | 32 | После описание задачи, добавьте только такие атрибуты: 33 | 34 | - Labels: `feature` 35 | - Project: `jira-helper` 36 | 37 | 38 | ### Если необходимо добавить исправление 39 | 40 | _Когда функционал работает не так, как ожидаете._ 41 | 42 | [Создайте новую задачу](https://github.com/TinkoffCreditSystems/jira-helper/issues/new) 43 | 44 | После описание задачи, добавьте только такие атрибуты: 45 | 46 | - Labels: `invalid`, [`cloud jira`, `jira 7`, `jira 8`] – укажите в каких версиях JIRA воспроизводится проблема. 47 | - Project: `jira-helper` 48 | 49 | 50 | ### Добавить описание проблемы (бага) 51 | 52 | [Создайте новую задачу](https://github.com/TinkoffCreditSystems/jira-helper/issues/new) 53 | 54 | После описание задачи, добавьте только такие аттрибуты: 55 | 56 | - Labels: `bug`, [`cloud jira`, `jira 7`, `jira 8`] – укажите в каких версиях JIRA воспроизводится проблема. 57 | - Project: `jira-helper` 58 | 59 | 60 | ### Labels общий список используемых labels 61 | 62 | | labels | Значение | 63 | |--------------|:--------------------------------------------------------------------------| 64 | | `feature` | новый функционал | 65 | | `invalid` | функционал работает не так как ожидается | 66 | | `bug` | проблема, ошибка - обязательно указывать label версии где воспроивзодится | 67 | | `jira 7` | воспроизводится в версии JIRA 7.x.x | 68 | | `jira 8` | воспроизводится в версии JIRA 8.x.x | 69 | | `cliud jira` | воспроизводится в версии Cloud JIRA | 70 | 71 | 72 | ## Установка расширения для разработки 73 | 74 | Выполнить: 75 | 76 | ``` 77 | npm run bootstrap 78 | npm run dev 79 | ``` 80 | 81 | В Chrome: 82 | 83 | Открыть меню, выбрать "Дополнительные инструменты", 84 | и в подменю выбрать ["Расширения"](chrome://extensions/) 85 | 86 | На панели ["Расширения"](chrome://extensions/) включить "Режим разработчика" 87 | 88 | После появления дополнительного меню, выбрать в нём 89 | "Загрузить распакованное расширение" 90 | 91 | Выбрать папку куда была произведена сборка `~/jira-helper/dist`. 92 | 93 | После этого добавиться плагин в Chrome. 94 | 95 | 96 | ### Во время разработки 97 | 98 | После изменения кода, webpack автоматически производит замену кода в папке `dist`. 99 | 100 | Поэтому на панели ["Расширения"](chrome://extensions/) нужно нажать 101 | на кнопку "Обновление" (в виде круглой стрелки). 102 | 103 | И перезагрузить web-страницу, на которой идет проверка, нажав `F5`. 104 | 105 | ### Ведение ветки и commit-ов 106 | 107 | Название ветки должно начинаться с номера задачи с которой она связана 108 | 109 | Пример: `2-title-issue`, где префикс `2` – это номер задачи, обязателен. 110 | 111 | В каждом `commit` обязательно добавляйте номер задачи с которым он связан 112 | 113 | Пример: `[#15] rename *.feature to *.ru.feaute` 114 | 115 | Названия веток и commit-ы пишем на `english` языке. 116 | 117 | ## Публикация расширения 118 | 119 | Официальное расширение публикуется в ["Chrome WebStore"](https://chrome.google.com/webstore/detail/jira-helper/egmbomekcmpieccamghfgjgnlllgbgdl) 120 | 121 | Публикация происходит после [сборки релиза на github](https://github.com/TinkoffCreditSystems/jira-helper/releases) 122 | 123 | Версия релиза совпадает с версией приложения в [package.json](./package.json) 124 | 125 | Этот же номер версии будет соответсвовать номеру пубикуемого в ["Chrome WebStore"](https://chrome.google.com/webstore/detail/jira-helper/egmbomekcmpieccamghfgjgnlllgbgdl) 126 | 127 | _Может использоватся в Chrome [version >= 55](./src/manifest.json)_ 128 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-leap-day -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | coverageReporters: ['lcov'], 3 | testMatch: ['**/test/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira-helper", 3 | "version": "2.16.0", 4 | "description": "jira-helper: Elements for vizualizations, tetris-planing with sprints, button template, swimline-viz", 5 | "repository": "https://github.com/Tinkoff/jira-helper.git", 6 | "license": "ISC", 7 | "contributors": [{ 8 | "name": "Pavel Akhmetchanov", 9 | "url": "https://github.com/pavelpower", 10 | "email:": "pavel.power@gmail.com" 11 | }, 12 | { 13 | "name": "Nataliya Bobrovskaya", 14 | "url": "https://github.com/bobrovskayaa", 15 | "email:": "nataliya.bobrovskaya@phystech.edu" 16 | }, 17 | { 18 | "name": "Elina Denisova", 19 | "url": "https://github.com/ElinRin", 20 | "email:": "elin.rinnel@gmail.com" 21 | }, 22 | { 23 | "name": "Danil Vakhrushev", 24 | "url": "https://github.com/davakh", 25 | "email:": "da.vakhr@gmail.com" 26 | }, 27 | { 28 | "name": "Alexey Sokolov", 29 | "url": "https://github.com/Polvista", 30 | "email:": "sokol789@yandex.ru" 31 | }, 32 | { 33 | "name": "Krotov Artem", 34 | "url": "https://github.com/timmson" 35 | }, 36 | { 37 | "name": "Dmitry", 38 | "url": "https://github.com/ddrozdov", 39 | "email:": "vodzord@gmail.com" 40 | }, 41 | { 42 | "name": "Max", 43 | "url": "https://github.com/Maksimall89", 44 | "email:": "maksimall89@gmail.com" 45 | }, 46 | { 47 | "name": "Vsevolod", 48 | "url": "https://github.com/vsevolodk", 49 | "email:": "" 50 | }, 51 | { 52 | "name": "Nikolay Kutnyashenko", 53 | "url": "https://github.com/Kvalafalm", 54 | "email:": "" 55 | } 56 | ], 57 | "scripts": { 58 | "bootstrap": "npm i --no-save", 59 | "test": "npx jest", 60 | "coverage": "npx jest --collectCoverage", 61 | "build": "cross-env NODE_ENV=production webpack -p --config webpack/webpack.config.prod.js", 62 | "prod": "npm run build && zip -r dist.zip ./dist", 63 | "clear": "rm -rf dist", 64 | "dev": "cross-env NODE_ENV=development webpack -d --config webpack/webpack.config.dev.js", 65 | "lint": "lint-staged" 66 | }, 67 | "husky": { 68 | "hooks": { 69 | "pre-commit": "npm run lint", 70 | "prepare-commit-msg": "bash ./tools/prepare-commit-message.sh ${HUSKY_GIT_PARAMS}" 71 | } 72 | }, 73 | "lint-staged": { 74 | "src/**/*.js": [ 75 | "eslint src --fix", 76 | "prettier --write" 77 | ] 78 | }, 79 | "dependencies": { 80 | "@tinkoff/request-core": "^0.8.8", 81 | "@tinkoff/request-plugin-cache-deduplicate": "^0.8.8", 82 | "@tinkoff/request-plugin-cache-memory": "^0.8.13", 83 | "@tinkoff/request-plugin-protocol-http": "^0.10.17", 84 | "@tinkoff/request-plugin-transform-url": "^0.8.7", 85 | "@tinkoff/utils": "^2.1.3", 86 | "core-js": "^3.8.3", 87 | "cross-env": "^5.2.0", 88 | "gsap": "^3.6.0", 89 | "simple-color-picker": "^1.0.4" 90 | }, 91 | "devDependencies": { 92 | "@babel/core": "^7.12.13", 93 | "@babel/plugin-proposal-class-properties": "^7.12.13", 94 | "@babel/plugin-transform-runtime": "^7.12.13", 95 | "@babel/preset-env": "^7.12.13", 96 | "babel-eslint": "^10.1.0", 97 | "babel-loader": "^8.2.2", 98 | "clean-webpack-plugin": "^3.0.0", 99 | "copy-webpack-plugin": "^5.1.2", 100 | "css-loader": "^3.6.0", 101 | "eslint": "^5.16.0", 102 | "eslint-config-airbnb": "^18.2.1", 103 | "eslint-config-prettier": "^6.15.0", 104 | "eslint-loader": "^3.0.4", 105 | "eslint-plugin-import": "^2.22.1", 106 | "eslint-plugin-jsx-a11y": "^6.4.1", 107 | "eslint-plugin-prettier": "^3.3.1", 108 | "eslint-plugin-react": "^7.24.0", 109 | "html-loader": "^0.5.5", 110 | "html-webpack-plugin": "3.0.4", 111 | "husky": "^4.3.8", 112 | "jest": "26.6.3", 113 | "jest-each": "26.6.2", 114 | "lint-staged": "^10.5.3", 115 | "prettier": "^1.19.1", 116 | "raw-loader": "0.5.1", 117 | "style-loader": "0.20.3", 118 | "terser-webpack-plugin": "^2.3.8", 119 | "webpack": "^4.46.0", 120 | "webpack-cli": "^3.3.12", 121 | "webpack-merge": "^4.2.2", 122 | "webpackbar": "^4.0.0", 123 | "ws": "^7.4.6" 124 | }, 125 | "engines": { 126 | "node": ">=10.0.0", 127 | "npm": ">=5.0.0" 128 | } 129 | } -------------------------------------------------------------------------------- /src/assets/jira_helper_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/assets/jira_helper_128x128.png -------------------------------------------------------------------------------- /src/assets/jira_helper_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/assets/jira_helper_16x16.png -------------------------------------------------------------------------------- /src/assets/jira_helper_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/assets/jira_helper_32x32.png -------------------------------------------------------------------------------- /src/assets/jira_helper_48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/assets/jira_helper_48x48.png -------------------------------------------------------------------------------- /src/assets/jira_helper_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/assets/jira_helper_64x64.png -------------------------------------------------------------------------------- /src/background/actions.js: -------------------------------------------------------------------------------- 1 | export const types = { 2 | SET_CARDS: 'BG_SET_CARDS', 3 | GET_CARDS: 'BG_GET_CARDS', 4 | SET_ROLES: 'BG_SET_ROLES', 5 | GET_ROLES: 'BG_GET_ROLES', 6 | 7 | TAB_URL_CHANGE: 'TAB_URL_CHANGE', 8 | }; 9 | 10 | export const setCards = ({ issues, epics, specialFields }) => ({ 11 | action: types.SET_CARDS, 12 | issues, 13 | epics, 14 | specialFields, 15 | }); 16 | export const getCards = () => ({ action: types.GET_CARDS }); 17 | 18 | export const setRoles = roles => ({ action: types.SET_ROLES, roles }); 19 | export const getRoles = () => ({ action: types.GET_ROLES }); 20 | -------------------------------------------------------------------------------- /src/background/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/background/background.js: -------------------------------------------------------------------------------- 1 | import { types } from './actions'; 2 | import { extensionApiService } from '../shared/ExtensionApiService'; 3 | 4 | const state = { 5 | jiraCards: { 6 | issues: null, 7 | epics: null, 8 | specialFields: {}, 9 | }, 10 | roles: {}, 11 | }; 12 | 13 | extensionApiService.onMessage((request, sender, sendResponse) => { 14 | switch (request.action) { 15 | case types.SET_CARDS: 16 | state.jiraCards = { 17 | issues: request.issues, 18 | epics: request.epics, 19 | specialFields: request.specialFields, 20 | }; 21 | return sendResponse('OK'); 22 | case types.GET_CARDS: 23 | return sendResponse(state.jiraCards); 24 | case types.SET_ROLES: 25 | state.roles = request.roles; 26 | return sendResponse('OK'); 27 | case types.GET_ROLES: 28 | return sendResponse(state.roles); 29 | default: 30 | } 31 | }); 32 | 33 | extensionApiService.onTabsUpdated((tabId, changeInfo) => { 34 | if (changeInfo.url) { 35 | extensionApiService.sendMessageToTab(tabId, { 36 | type: types.TAB_URL_CHANGE, 37 | url: changeInfo.url, 38 | }); 39 | } 40 | }); 41 | 42 | if (process.env.NODE_ENV === 'development') { 43 | require('../shared/trackChanges') // eslint-disable-line 44 | .default('refresh_background', () => extensionApiService.reload()); 45 | } 46 | -------------------------------------------------------------------------------- /src/blur-for-sensitive/blurSensitive.css: -------------------------------------------------------------------------------- 1 | /******** JIRA 7 ************/ 2 | html.blur a.issue-link, /**TOP Menu **/ 3 | html.blur .project-title a, /* title in right panel */ 4 | html.blur .subnav-page-header /* title in backlog */ 5 | html.blur .scope-filter .aui-scope-filter-spectrum span, 6 | html.blur .ghx-summary:not(span), /* backlog summory issue */ 7 | html.blur .ghx-summary span, 8 | html.blur .js-epic-name span, /* backlog right panel for epic and version */ 9 | html.blur .js-version-name span, 10 | html.blur .ghx-extra-field-content, /* epic label in baclog issue */ 11 | html.blur .ghx-extra-field-content h1, 12 | html.blur .ghx-extra-field-content h2, 13 | html.blur .ghx-extra-field-content h3, 14 | html.blur .ghx-extra-field-content h4, 15 | html.blur .project-shortcut span, 16 | html.blur .ghx-jql-preview span.field-value, /*** Settings Board ***/ 17 | html.blur #ghx-show-projects-in-board > li > strong, 18 | html.blur .ghx-project 19 | { 20 | color: transparent !important; 21 | text-shadow: #172b4d -1px 1px 10px !important; 22 | } 23 | 24 | 25 | html.blur .user-hover, /*** Settings Board ***/ 26 | html.blur .item .value a, 27 | html.blur a.link-title, 28 | html.blur .user-content-block a, 29 | html.blur #commentmodule .action-body a, 30 | html.blur #project-name-val, /*** View Issue ***/ 31 | html.blur .activity-new-val a, 32 | html.blur .activity-old-val a, 33 | html.blur .activity-comment a, 34 | html.blur .activity-item-summary a, 35 | html.blur .activity-item-description a, 36 | html.blur .user-content a, 37 | html.blur div.aui-inline-dialog-contents.contents a, /**Control chart**/ 38 | html.blur .issuerow td a /****SEarch *****/ 39 | { 40 | color: transparent !important; 41 | text-shadow: #172b4d -1px 1px 8px !important; 42 | } 43 | 44 | html.blur .item .value, 45 | html.blur #summary-val, 46 | html.blur .link-summary, 47 | html.blur .user-content-block, 48 | html.blur #descriptionmodule h1, 49 | html.blur #descriptionmodule h2, 50 | html.blur #descriptionmodule h3, 51 | html.blur #descriptionmodule h4, 52 | html.blur #descriptionmodule h5, 53 | html.blur #descriptionmodule h6, 54 | html.blur #commentmodule .action-body, 55 | html.blur .activity-new-val, 56 | html.blur .activity-old-val, 57 | html.blur .activity-comment, 58 | html.blur .activity-item-summary, 59 | html.blur .activity-item-description, 60 | html.blur .activity-item-description .user-content, 61 | html.blur .user-content, 62 | html.blur .user-content h1, 63 | html.blur .user-content h2, 64 | html.blur .user-content h3, 65 | html.blur .user-content h4, 66 | html.blur .user-content h5, 67 | html.blur .user-content h6, 68 | html.blur div.aui-inline-dialog-contents.contents span, 69 | html.blur .issuerow td /* search */ 70 | { 71 | color: transparent !important; 72 | text-shadow: #172b4d -1px 1px 6px !important; 73 | } 74 | 75 | /** Backlog **/ 76 | 77 | html.blur .js-key-link, /* link for issue key */ 78 | html.blur a.external-link, 79 | html.blur .js-epic-key-link, 80 | html.blur .avatar-with-name a, /*** release issues **/ 81 | html.blur a.issue-summary, 82 | html.blur a.issue-key, 83 | html.blur #issuekey-val > a, /*** Issu info panel ***/ 84 | html.blur .type-gh-epic-link a 85 | { 86 | color: transparent !important; 87 | text-shadow: #0052cc -1px 1px 10px !important; 88 | } 89 | 90 | html.blur .ghx-row-version-epic-subtasks span, /* epic label in baclog issue */ 91 | html.blur span[data-epickey] /*** Board ***/ 92 | { 93 | color: transparent !important; 94 | text-shadow: #fff -1px 1px 10px !important; 95 | } 96 | 97 | html.blur #descriptionmodule img { 98 | filter: blur(5px) !important; 99 | } 100 | 101 | /*** JIRA 8 ***/ 102 | html.blur .code-quote, 103 | html.blur .code-quote .code-keyword, 104 | html.blur .code-quote .code-object { 105 | color: transparent !important; 106 | text-shadow: #009100 -1px 1px 6px !important; 107 | } 108 | 109 | html.blur section.ghx-summary, 110 | html.blur div.ghx-parent-stub span.ghx-summary, 111 | html.blur div.ghx-parent-stub span.ghx-key { 112 | color: transparent !important; 113 | text-shadow: #172B4D -1px 1px 6px !important; 114 | } 115 | 116 | html.blur #breadcrumbs-container span, 117 | html.blur [data-test-id="issue.views.issue-base.foundation.breadcrumbs.breadcrumb-current-issue-container"] 118 | html.blur [data-test-id="issue.issue-view.views.common.issue-line-card.issue-line-card-view.summary"] div div 119 | span.css-eaycls { 120 | color: transparent !important; 121 | text-shadow: #000000 -1px 1px 6px !important; 122 | } 123 | 124 | html.blur [data-test-id="issue.views.issue-base.foundation.summary.heading"], 125 | html.blur [data-test-id="issue.views.field.user.assignee"] > div, 126 | html.blur [data-test-id="profilecard-next.ui.profilecard.profilecard-trigger"] > div, 127 | html.blur span.css-eaycls, 128 | html.blur [class^="SingleLineTextInput"], 129 | html.blur .ak-renderer-document > *, 130 | html.blur [class^="ItemParts__"], 131 | html.blur [class^="ItemParts__"] span span, 132 | html.blur [data-test-id="navigation-apps.scope-switcher-v2"] div div button div div { 133 | color: transparent !important; 134 | text-shadow: #000000 -1px 1px 10px !important; 135 | } 136 | 137 | html.blur [data-test-id="issue.views.issue-base.context.context-items.primary-items"] a[color="blueLight"]{ 138 | color: transparent !important; 139 | text-shadow: #0052CC -1px 1px 10px !important; 140 | } 141 | 142 | html.blur a[data-test-id="issue.issue-view.views.common.issue-line-card.issue-line-card-view.key"] { 143 | color: transparent !important; 144 | text-shadow: rgb(137, 147, 164) -1px 1px 10px !important; 145 | } 146 | 147 | html.blur span[class^="FieldStyles"], 148 | html.blur div[data-test-id="issue.issue-view.views.common.issue-line-card.issue-line-card-view.summary"] div div, 149 | html.blur #ghx-field-viewname, 150 | html.blur .jira-system-avatar ~ span, 151 | html.blur #ghx-create-permissioninfo li span{ 152 | color: transparent !important; 153 | text-shadow: #172b4d -1px 1px 10px !important; 154 | } 155 | 156 | html.blur div[data-test-id="navigation-apps.project-switcher-v2"] div div div button div[class^="css-"] { 157 | color: transparent !important; 158 | text-shadow: rgb(66, 82, 110) -1px 1px 10px !important; 159 | } 160 | 161 | html.blur div[class^="BreadcrumbsItem__"] a span span { 162 | color: transparent !important; 163 | text-shadow: #6B778C -1px 1px 10px !important; 164 | } 165 | -------------------------------------------------------------------------------- /src/blur-for-sensitive/blurSensitive.js: -------------------------------------------------------------------------------- 1 | const setBlurSensitive = isBlure => { 2 | const html = document.getElementsByTagName('html')[0]; 3 | if (isBlure) { 4 | html.classList.add('blur'); 5 | return; 6 | } 7 | html.classList.remove('blur'); 8 | }; 9 | 10 | const cnahgeBlureSensitive = isBlure => { 11 | localStorage.setItem('blurSensitive', isBlure); 12 | setBlurSensitive(isBlure); 13 | }; 14 | 15 | export const setUpBlurSensitiveOnPage = () => { 16 | const isBlure = localStorage.getItem('blurSensitive') === 'true'; 17 | setBlurSensitive(!!isBlure); 18 | }; 19 | 20 | export const initBlurSensitive = () => { 21 | window.chrome.runtime.onMessage.addListener((request, sender) => { 22 | if (!sender.tab && Object.prototype.hasOwnProperty.call(request, 'blurSensitive')) { 23 | cnahgeBlureSensitive(request.blurSensitive); 24 | } 25 | }); 26 | 27 | window.chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 28 | if (!sender.tab && Object.prototype.hasOwnProperty.call(request, 'getBlurSensitive')) { 29 | sendResponse({ blurSensitive: localStorage.getItem('blurSensitive') === 'true' }); 30 | } 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/blur-for-sensitive/todo.md: -------------------------------------------------------------------------------- 1 | ## Blur Sensitive data 2 | 3 | Функция скрывает секретные данные, оставляя в показе только функционал. 4 | 5 | ### ToDo JIRA 7 6 | 7 | - [ ] Добавить blure классы для топ-меню 8 | - [x] Добавить blure классы для бэклога 9 | - [ ] Добавить blure классы для доски 10 | - [ ] Добавить blure классы для search-панели 11 | - [ ] ?Добавить blure классы для Dashboard-панели? 12 | - [ ] ?Добавить blure классы для /projects//summary панели 13 | 14 | 15 | ### ToDo общее 16 | 17 | - [ ] создать файл js, который включает и выключает по нажатию кнопки blure-классы, 18 | добавляя к html тегу .blure-sensitive css класс. 19 | -------------------------------------------------------------------------------- /src/bug-template/BugTemplate.js: -------------------------------------------------------------------------------- 1 | import { PageModification } from '../shared/PageModification'; 2 | import style from './styles.css'; 3 | 4 | import defaultIframeTemplate from './template.html'; 5 | 6 | const defaultTextareaTemplate = defaultIframeTemplate.replace(/
/g, '\n'); 7 | const createIssueDialogIdentifiers = ['#create-issue-dialog', '#issue-create', '#create-subtask-dialog']; 8 | const descriptionInDialogSelector = '.jira-wikifield'; 9 | const buttonAddCls = style.buttonJiraAddTemplateForBug; 10 | const buttonSaveCls = style.buttonJiraSaveTemplateForBug; 11 | const localStorageTemplateTextarea = 'jira_helper_textarea_bug_template'; 12 | const textToHtml = text => text.replace(/\n/g, '
'); 13 | 14 | export default class extends PageModification { 15 | getModificationId() { 16 | return 'bug-template'; 17 | } 18 | 19 | getTextareaContainer() { 20 | for (const dialogId of createIssueDialogIdentifiers) { 21 | const container = document.querySelector(`${dialogId} ${descriptionInDialogSelector}`); 22 | 23 | if (container) { 24 | return container; 25 | } 26 | } 27 | } 28 | 29 | apply() { 30 | this.applyTemplate(); 31 | 32 | const elements = createIssueDialogIdentifiers.map(selector => this.waitForElement(selector, document.body)); 33 | 34 | Promise.race(elements).then(target => { 35 | this.onDOMChange(`#${target.id}`, this.applyTemplate, { childList: true, subtree: true }); 36 | }); 37 | 38 | this.onDOMChange('body', mutationEvents => { 39 | mutationEvents.forEach(event => { 40 | event.removedNodes.forEach(node => { 41 | if (createIssueDialogIdentifiers.includes(`#${node.id}`)) { 42 | this.clear(); 43 | this.apply(); 44 | } 45 | }); 46 | }); 47 | }); 48 | } 49 | 50 | applyTemplate = () => { 51 | if (!this.getTextareaContainer()) return; 52 | 53 | const isButtonsAlreadyAppended = document.querySelectorAll(`.${buttonAddCls}, .${buttonSaveCls}`).length > 0; 54 | if (isButtonsAlreadyAppended) return; 55 | 56 | this.makeButton({ 57 | text: '✎', 58 | title: 'Add template', 59 | handleClick: this.addTemplate, 60 | cls: buttonAddCls, 61 | }); 62 | this.makeButton({ 63 | text: '💾', 64 | title: 'Save template', 65 | handleClick: this.saveTemplate, 66 | cls: buttonSaveCls, 67 | }); 68 | }; 69 | 70 | makeButton({ text, title, handleClick, cls }) { 71 | const btn = this.insertHTML( 72 | this.getTextareaContainer(), 73 | 'beforeend', 74 | `` 75 | ); 76 | this.addEventListener(btn, 'click', handleClick); 77 | } 78 | 79 | addTemplate = () => { 80 | const iframe = createIssueDialogIdentifiers.reduce((acc, selector) => { 81 | return acc || document.querySelector(`${selector} ${descriptionInDialogSelector} iframe`); 82 | }, null); 83 | const textarea = createIssueDialogIdentifiers.reduce((acc, selector) => { 84 | return acc || document.querySelector(`${selector} ${descriptionInDialogSelector} textarea#description`); 85 | }, null); 86 | 87 | const textTextarea = localStorage.getItem(localStorageTemplateTextarea); 88 | const templateIframe = textTextarea ? textToHtml(textTextarea) : defaultIframeTemplate; 89 | const templateTextarea = textTextarea || defaultTextareaTemplate; 90 | 91 | if (iframe) { 92 | const text = iframe.contentDocument.getElementById('tinymce').firstChild; 93 | text.innerHTML = text.innerHTML.length > 0 ? `${text.innerHTML}
${templateIframe}` : templateIframe; 94 | } 95 | 96 | if (textarea) { 97 | textarea.value = textarea.value.length > 0 ? `${textarea.value}\n${templateTextarea}` : templateTextarea; 98 | } 99 | }; 100 | 101 | saveTemplate = () => { 102 | const textarea = createIssueDialogIdentifiers.reduce((acc, selector) => { 103 | return acc || document.querySelector(`${selector} ${descriptionInDialogSelector} textarea#description`); 104 | }, null); 105 | 106 | if (!window.confirm(`Are you sure you want to save the text "${textarea.value}" in the template?`)) { 107 | return; 108 | } 109 | 110 | localStorage.setItem(localStorageTemplateTextarea, textarea.value); 111 | }; 112 | 113 | onCloseDialog() {} 114 | } 115 | -------------------------------------------------------------------------------- /src/bug-template/styles.css: -------------------------------------------------------------------------------- 1 | :global(#create-issue-dialog .jira-wikifield), 2 | :global(#issue-create .jira-wikifield), 3 | :global(#create-subtask-dialog .jira-wikifield) { 4 | position: relative; 5 | } 6 | 7 | .buttonJiraAddTemplateForBug { 8 | top: 45px; 9 | left: -31px; 10 | position: absolute; 11 | z-index: 10; 12 | border-radius: 4px; 13 | padding: 5px; 14 | width: 30px; 15 | word-wrap: break-word; 16 | font-size: 11px; 17 | color: #707070; 18 | } 19 | 20 | .buttonJiraSaveTemplateForBug { 21 | top: 74px; 22 | left: -31px; 23 | position: absolute; 24 | z-index: 10; 25 | border-radius: 4px; 26 | padding: 5px; 27 | width: 30px; 28 | word-wrap: break-word; 29 | font-size: 11px; 30 | color: #707070; 31 | } 32 | 33 | :active, :hover, :focus { 34 | outline: 0; 35 | } 36 | -------------------------------------------------------------------------------- /src/bug-template/template.html: -------------------------------------------------------------------------------- 1 | Device:
2 | OS:
3 | Account:
4 | Condition:

5 | Playback Steps:

6 | Actual result:
7 | Expected Result:

8 | TK: -------------------------------------------------------------------------------- /src/charts/utils.js: -------------------------------------------------------------------------------- 1 | export const getChartTics = chartElement => { 2 | const ticks = [...chartElement.querySelectorAll('.tick')].filter( 3 | elem => elem.lastChild.attributes.y.value === '0' && elem.lastChild.textContent 4 | ); 5 | return ticks.map(elem => { 6 | const [, transform] = elem.attributes.transform.value.split(','); 7 | return { 8 | position: Number(transform.slice(0, -1)), 9 | value: Number(elem.lastChild.textContent), 10 | }; 11 | }); 12 | }; 13 | 14 | export const getChartLinePosition = (ticksVals, value) => { 15 | let nextTick = ticksVals[ticksVals.length - 1]; 16 | for (let i = 0; i < ticksVals.length; i++) { 17 | if (ticksVals[i].value >= value) { 18 | nextTick = ticksVals[i]; 19 | break; 20 | } 21 | } 22 | 23 | const maxTickValue = ticksVals[ticksVals.length - 1].value; 24 | 25 | if (maxTickValue >= 30) { 26 | const firstTick = ticksVals[0]; 27 | 28 | if (!firstTick || !nextTick) return 0; 29 | 30 | return ( 31 | firstTick.position - value ** (1 / 3) * ((firstTick.position - nextTick.position) / nextTick.value ** (1 / 3)) 32 | ); 33 | } 34 | 35 | let prevTick = ticksVals[0]; 36 | for (let i = ticksVals.length - 1; i >= 0; i--) { 37 | if (ticksVals[i].value <= value) { 38 | prevTick = ticksVals[i]; 39 | break; 40 | } 41 | } 42 | 43 | if (!prevTick || !nextTick) return 0; 44 | 45 | const percentDistance = 46 | nextTick.value === prevTick.value ? 0 : (value - prevTick.value) / (nextTick.value - prevTick.value); 47 | return prevTick.position - percentDistance * (prevTick.position - nextTick.position); 48 | }; 49 | 50 | export const getChartValueByPosition = (ticksVals, position) => { 51 | let nextTick = ticksVals[ticksVals.length - 1]; 52 | for (let i = 0; i < ticksVals.length; i++) { 53 | if (ticksVals[i].position <= position) { 54 | nextTick = ticksVals[i]; 55 | break; 56 | } 57 | } 58 | 59 | const maxTickValue = ticksVals[ticksVals.length - 1].value; 60 | 61 | if (maxTickValue >= 30) { 62 | const firstTick = ticksVals[0]; 63 | 64 | if (!firstTick || !nextTick) return 0; 65 | 66 | return ( 67 | ((firstTick.position - position) / ((firstTick.position - nextTick.position) / nextTick.value ** (1 / 3))) ** 3 68 | ); 69 | } 70 | 71 | let prevTick = ticksVals[0]; 72 | for (let i = ticksVals.length - 1; i >= 0; i--) { 73 | if (ticksVals[i].position >= position) { 74 | prevTick = ticksVals[i]; 75 | break; 76 | } 77 | } 78 | 79 | const percentDistance = 80 | nextTick.position === prevTick.position 81 | ? 0 82 | : (prevTick.position - position) / (prevTick.position - nextTick.position); 83 | return prevTick.value + percentDistance * (nextTick.value - prevTick.value); 84 | }; 85 | -------------------------------------------------------------------------------- /src/column-limits/BoardPage/index.js: -------------------------------------------------------------------------------- 1 | import map from '@tinkoff/utils/array/map'; 2 | import { PageModification } from '../../shared/PageModification'; 3 | import { BOARD_PROPERTIES } from '../../shared/constants'; 4 | import { mergeSwimlaneSettings } from '../../swimlane/utils'; 5 | import { findGroupByColumnId, generateColorByFirstChars } from '../shared/utils'; 6 | import styles from './styles.css'; 7 | 8 | export default class extends PageModification { 9 | shouldApply() { 10 | const view = this.getSearchParam('view'); 11 | return !view || view === 'detail'; 12 | } 13 | 14 | getModificationId() { 15 | return `add-wip-limits-${this.getBoardId()}`; 16 | } 17 | 18 | waitForLoading() { 19 | return this.waitForElement('.ghx-column-header-group'); 20 | } 21 | 22 | loadData() { 23 | return Promise.all([ 24 | this.getBoardEditData(), 25 | this.getBoardProperty(BOARD_PROPERTIES.WIP_LIMITS_SETTINGS), 26 | Promise.all([ 27 | this.getBoardProperty(BOARD_PROPERTIES.SWIMLANE_SETTINGS), 28 | this.getBoardProperty(BOARD_PROPERTIES.OLD_SWIMLANE_SETTINGS), 29 | ]).then(mergeSwimlaneSettings), 30 | ]); 31 | } 32 | 33 | apply([editData = {}, boardGroups = {}, swimlanesSettings = {}]) { 34 | this.boardGroups = boardGroups; 35 | this.swimlanesSettings = swimlanesSettings; 36 | this.mappedColumns = editData.rapidListConfig.mappedColumns; 37 | this.cssNotIssueSubTask = this.getCssSelectorNotIssueSubTask(editData); 38 | 39 | this.styleColumnHeaders(); 40 | this.styleColumnsWithLimitations(); 41 | 42 | this.onDOMChange('#ghx-pool', () => { 43 | this.styleColumnHeaders(); 44 | this.styleColumnsWithLimitations(); 45 | }); 46 | } 47 | 48 | styleColumnHeaders() { 49 | const columnsInOrder = this.getOrderedColumns(); 50 | // for jira v8 header. 51 | // One of the parents has overfow: hidden 52 | const headerGroup = document.querySelector('#ghx-pool-wrapper'); 53 | 54 | if (headerGroup != null) { 55 | headerGroup.style.paddingTop = '10px'; 56 | } 57 | 58 | columnsInOrder.forEach((columnId, index) => { 59 | const { name, value } = findGroupByColumnId(columnId, this.boardGroups); 60 | 61 | if (!name || !value) return; 62 | 63 | const columnByLeft = findGroupByColumnId(columnsInOrder[index - 1], this.boardGroups); 64 | const columnByRight = findGroupByColumnId(columnsInOrder[index + 1], this.boardGroups); 65 | 66 | const isColumnByLeftWithSameGroup = columnByLeft.name !== name; 67 | const isColumnByRightWithSameGroup = columnByRight.name !== name; 68 | 69 | if (isColumnByLeftWithSameGroup) 70 | document.querySelector(`.ghx-column[data-id="${columnId}"]`).style.borderTopLeftRadius = '10px'; 71 | if (isColumnByRightWithSameGroup) 72 | document.querySelector(`.ghx-column[data-id="${columnId}"]`).style.borderTopRightRadius = '10px'; 73 | 74 | const groupColor = this.boardGroups[name].customHexColor || generateColorByFirstChars(name); 75 | Object.assign(document.querySelector(`.ghx-column[data-id="${columnId}"]`).style, { 76 | backgroundColor: '#deebff', 77 | borderTop: `4px solid ${groupColor}`, 78 | }); 79 | }); 80 | } 81 | 82 | getIssuesInColumn(columnId, ignoredSwimlanes) { 83 | const swimlanesFilter = ignoredSwimlanes.map(swimlaneId => `:not([swimlane-id="${swimlaneId}"])`).join(''); 84 | 85 | return document.querySelectorAll( 86 | `.ghx-swimlane${swimlanesFilter} .ghx-column[data-column-id="${columnId}"] .ghx-issue:not(.ghx-done)${this.cssNotIssueSubTask}` 87 | ).length; 88 | } 89 | 90 | styleColumnsWithLimitations() { 91 | const columnsInOrder = this.getOrderedColumns(); 92 | if (!columnsInOrder.length) return; 93 | 94 | const ignoredSwimlanes = Object.keys(this.swimlanesSettings).filter( 95 | swimlaneId => this.swimlanesSettings[swimlaneId].ignoreWipInColumns 96 | ); 97 | const swimlanesFilter = ignoredSwimlanes.map(swimlaneId => `:not([swimlane-id="${swimlaneId}"])`).join(''); 98 | 99 | Object.values(this.boardGroups).forEach(group => { 100 | const { columns: groupColumns, max: groupLimit } = group; 101 | if (!groupColumns || !groupLimit) return; 102 | 103 | const amountOfGroupTasks = groupColumns.reduce( 104 | (acc, columnId) => acc + this.getIssuesInColumn(columnId, ignoredSwimlanes), 105 | 0 106 | ); 107 | 108 | if (groupLimit < amountOfGroupTasks) { 109 | groupColumns.forEach(columnId => { 110 | document 111 | .querySelectorAll(`.ghx-swimlane${swimlanesFilter} .ghx-column[data-column-id="${columnId}"]`) 112 | .forEach(el => { 113 | el.style.backgroundColor = '#ff5630'; 114 | }); 115 | }); 116 | } 117 | 118 | const leftTailColumnIndex = Math.min( 119 | ...groupColumns.map(columnId => columnsInOrder.indexOf(columnId)).filter(index => index != null) 120 | ); 121 | const leftTailColumnId = columnsInOrder[leftTailColumnIndex]; 122 | 123 | if (!leftTailColumnId) { 124 | // throw `Need rebuild WIP-limits of columns. WIP-limits used not exists column ${leftTailColumnId}`; 125 | return; 126 | } 127 | 128 | this.insertHTML( 129 | document.querySelector(`.ghx-column[data-id="${leftTailColumnId}"]`), 130 | 'beforeend', 131 | ` 132 | 133 | ${amountOfGroupTasks}/${groupLimit} 134 | Issues per group / Max number of issues per group 135 | ` 136 | ); 137 | }); 138 | 139 | this.mappedColumns 140 | .filter(column => column.max) 141 | .forEach(column => { 142 | const totalIssues = this.getIssuesInColumn(column.id, []); 143 | const filteredIssues = this.getIssuesInColumn(column.id, ignoredSwimlanes); 144 | 145 | if (column.max && totalIssues > Number(column.max) && filteredIssues <= Number(column.max)) { 146 | const columnHeaderElement = document.querySelector(`.ghx-column[data-id="${column.id}"]`); 147 | columnHeaderElement.classList.remove('ghx-busted', 'ghx-busted-max'); 148 | 149 | // задачи в облачной джире 150 | document.querySelectorAll(`.ghx-column[data-column-id="${column.id}"]`).forEach(issue => { 151 | issue.classList.remove('ghx-busted', 'ghx-busted-max'); 152 | }); 153 | } 154 | }); 155 | } 156 | 157 | getOrderedColumns() { 158 | return map( 159 | column => column.dataset.columnId, 160 | document.querySelectorAll('.ghx-first ul.ghx-columns > li.ghx-column') 161 | ); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/column-limits/BoardPage/styles.css: -------------------------------------------------------------------------------- 1 | .limitColumnBadge { 2 | position: absolute; 3 | top: -24%; 4 | left: 4%; 5 | padding: 2px 4px; 6 | border-radius: 5px; 7 | font-size: 0.8rem; 8 | margin: 0 10px; 9 | color: white; 10 | background-color: #1b855c; 11 | } 12 | 13 | .limitColumnBadge__hint { 14 | visibility: hidden; 15 | position: absolute; 16 | padding: 2px 3px; 17 | margin-left: 8px; 18 | width: 130px; 19 | transition: 0s visibility; 20 | cursor: pointer; 21 | } 22 | 23 | .limitColumnBadge:hover .limitColumnBadge__hint { 24 | visibility: visible; 25 | display: inline; 26 | color: black; 27 | padding: 10px; 28 | background-color: white; 29 | border-radius: 5px; 30 | box-shadow: 0 0 5px rgba(0,0,0,0.5); /* Параметры тени */ 31 | z-index: 100000; 32 | transition-delay: 200ms; 33 | } 34 | -------------------------------------------------------------------------------- /src/column-limits/SettingsPage/htmlTemplates.js: -------------------------------------------------------------------------------- 1 | import style from './styles.css'; 2 | import { generateColorByFirstChars as generateColor } from '../shared/utils'; 3 | 4 | export const groupSettingsBtnTemplate = ({ groupOfBtnsId = '', openEditorBtn = '' }) => 5 | `
`; 6 | 7 | export const formTemplate = ({ leftBlock = '', rightBlock = '', id = 'jh-wip-limits-id' }) => 8 | `
9 |
${leftBlock}
10 |
${rightBlock}
11 |
`; 12 | 13 | export const groupsTemplate = ({ id = 'jh-groups-template', children = '' }) => `
${children}
`; 14 | 15 | export const groupTemplate = ({ 16 | dropzoneClass = '', 17 | groupLimitsClass = '', 18 | withoutGroupId = '', 19 | groupId = '', 20 | customGroupColor, 21 | groupMax = '', 22 | columnsHtml = '', 23 | }) => ` 24 |
28 | ${ 29 | groupId === withoutGroupId 30 | ? '' 31 | : `
32 | Limit for group: 33 | 34 |
` 35 | } 36 |
${columnsHtml}
37 |
38 | `; 39 | 40 | export const columnTemplate = ({ columnId = '', dataGroupId = '', columnTitle = '', draggableClass }) => 41 | `
${columnTitle}
`; 42 | 43 | export const dragOverHereTemplate = ({ dropzoneId = '', dropzoneClass = '' }) => 44 | `
Drag column over here to create group
`; 45 | -------------------------------------------------------------------------------- /src/column-limits/SettingsPage/styles.css: -------------------------------------------------------------------------------- 1 | .jiraHelperSubgroupMaximumWrapper { 2 | text-align: center; 3 | background-color: white; 4 | padding: 10px 0; 5 | color: gray; 6 | border: 1px solid gray; 7 | border-radius: 5px; 8 | margin-bottom: 10px; 9 | } 10 | 11 | .jiraHelperSubgroupMaximumWrapper input { 12 | padding: 5px; 13 | margin-top: 5px; 14 | border-radius: 3px; 15 | border: 1px solid gray; 16 | } 17 | 18 | .highlightUpdatedLimit { 19 | border: 1px solid #189c2c !important; 20 | } 21 | 22 | 23 | .columnDraggableJH { 24 | padding: 5px 10px; 25 | background: gray; 26 | border-radius: 5px; 27 | color: white; 28 | margin: 2px; 29 | cursor: move; 30 | } 31 | 32 | .columnListJH { 33 | display: flex; 34 | flex-direction: row; 35 | padding: 10px; 36 | flex-wrap: wrap; 37 | border: 2px solid white; 38 | cursor: pointer; 39 | } 40 | 41 | .columnGroupJH { 42 | margin-bottom: 10px; 43 | background-color: lightgray; 44 | } 45 | 46 | .columnGroupLimitsJH { 47 | display: block; 48 | background: white; 49 | padding: 5px 10px; 50 | } 51 | 52 | .addGroupDropzoneJH { 53 | height: 60px; 54 | background: #ddedf3; 55 | border: 2px dashed #90b8f3; 56 | display: flex; 57 | justify-content: center; 58 | align-items: center; 59 | font-size: 15px; 60 | color: darkgray; 61 | } 62 | 63 | .addGroupDropzoneActiveJH { 64 | border: 2px dashed black; 65 | } 66 | 67 | 68 | .jhGroupOfBtns { 69 | margin-top: 1rem; 70 | } 71 | 72 | .form { 73 | display: flex; 74 | } 75 | 76 | .formLeftBlock { 77 | width: 200px; 78 | min-height: 400px; 79 | margin-right: 20px; 80 | } 81 | 82 | .formRightBlock { 83 | flex: 1; 84 | } 85 | -------------------------------------------------------------------------------- /src/column-limits/shared/utils.js: -------------------------------------------------------------------------------- 1 | import keys from '@tinkoff/utils/object/keys'; 2 | 3 | export function findGroupByColumnId(columnId, groupsFromAPI) { 4 | let result = {}; 5 | 6 | Object.entries(groupsFromAPI || {}).forEach(([group, data]) => { 7 | if (data.columns && data.columns.indexOf(columnId) > -1) { 8 | result = { 9 | name: group, 10 | value: data.columns, 11 | }; 12 | } 13 | }); 14 | 15 | return result; 16 | } 17 | 18 | const colors = [ 19 | '#70cde0', 20 | '#d3d1ff', 21 | '#f9aa9b', 22 | '#90bfb7', 23 | '#fff9b8', 24 | '#c3ceed', 25 | '#76ad75', 26 | '#94bcdb', 27 | '#dfca98', 28 | '#c8afd4', 29 | '#fddcea', 30 | '#aacde1', 31 | '#fedfb6', 32 | '#ce9ef1', 33 | '#ec8ba0', 34 | '#74af84', 35 | '#ffc1b8', 36 | '#a391bd', 37 | '#dd9294', 38 | '#69c58f', 39 | '#40aca4', 40 | '#f192b4', 41 | ]; 42 | 43 | const strLengthForGenerating = 5; 44 | 45 | export const generateColorByFirstChars = str => { 46 | const integerCharCodes = str 47 | .replace(/[^а-яёА-ЯЁA-Za-z0-9]/gi, '') // exclude all symbols except а-яёА-ЯЁА-Za-z0-9 48 | .split('') 49 | .slice(0, strLengthForGenerating) 50 | .map(char => char.charCodeAt(0)); 51 | 52 | const sumOfIntegers = integerCharCodes.reduce((sum, integer) => sum + integer, 0); 53 | 54 | const generatedColorIndex = sumOfIntegers % colors.length; 55 | 56 | return colors[generatedColorIndex]; 57 | }; 58 | 59 | export const mapColumnsToGroups = ({ columnsHtmlNodes = [], wipLimits = {}, withoutGroupId = 'Without group' }) => { 60 | const resultGroupsMap = { 61 | allGroupIds: [...keys(wipLimits), withoutGroupId], 62 | byGroupId: {}, 63 | }; 64 | 65 | columnsHtmlNodes.forEach(column => { 66 | const { columnId } = column.dataset; 67 | let { name } = findGroupByColumnId(columnId, wipLimits); 68 | 69 | if (!name) name = withoutGroupId; 70 | if (!resultGroupsMap.byGroupId[name]) resultGroupsMap.byGroupId[name] = { allColumnIds: [], byColumnId: {} }; 71 | 72 | resultGroupsMap.byGroupId[name].allColumnIds.push(columnId); 73 | resultGroupsMap.byGroupId[name].byColumnId[columnId] = { column, id: columnId }; 74 | }); 75 | 76 | return resultGroupsMap; 77 | }; 78 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | import { Routes } from './routing'; 2 | import { isJira } from './shared/utils'; 3 | import AddSlaLine from './charts/AddSlaLine'; 4 | import AddChartGrid from './charts/AddChartGrid'; 5 | import runModifications from './shared/runModifications'; 6 | import SwimlaneStats from './swimlane/SwimlaneStats'; 7 | import SwimlaneLimits from './swimlane/SwimlaneLimits'; 8 | import SwimlaneSettingsPopup from './swimlane/SwimlaneSettingsPopup'; 9 | import WIPLimitsSettingsPage from './column-limits/SettingsPage'; 10 | import WIPLimitsBoardPage from './column-limits/BoardPage'; 11 | import TetrisPlanningButton from './tetris-planning/TetrisPlanningButton'; 12 | import TetrisPlanning from './tetris-planning/TetrisPlanning'; 13 | import BugTemplate from './bug-template/BugTemplate'; 14 | import MarkFlaggedIssues from './issue/MarkFlaggedIssues'; 15 | import FieldLimitsSettingsPage from './field-limits/SettingsPage'; 16 | import FieldLimitsBoardPage from './field-limits/BoardPage'; 17 | import PrintCards from './printcards/PrintCards'; 18 | import { setUpBlurSensitiveOnPage, initBlurSensitive } from './blur-for-sensitive/blurSensitive'; 19 | import PersonLimitsSettings from './person-limits/PersonLimitsSettings'; 20 | import PersonLimits from './person-limits/PersonLimits'; 21 | import WiplimitOnCells from './wiplimit-on-cells/WipLimitOnCells'; 22 | import WiplimitOnCellsSettings from './wiplimit-on-cells/WiplimitOnCellsSettingsPopup'; 23 | 24 | const domLoaded = () => 25 | new Promise(resolve => { 26 | if (document.readyState === 'interactive' || document.readyState === 'complete') return resolve(); 27 | window.addEventListener('DOMContentLoaded', resolve); 28 | }); 29 | 30 | async function start() { 31 | if (!isJira) return; 32 | 33 | await domLoaded(); 34 | 35 | setUpBlurSensitiveOnPage(); 36 | 37 | const modificationsMap = { 38 | [Routes.BOARD]: [ 39 | WIPLimitsBoardPage, 40 | SwimlaneStats, 41 | SwimlaneLimits, 42 | TetrisPlanning, 43 | MarkFlaggedIssues, 44 | PersonLimits, 45 | FieldLimitsBoardPage, 46 | WiplimitOnCells, 47 | ], 48 | [Routes.SETTINGS]: [ 49 | SwimlaneSettingsPopup, 50 | WIPLimitsSettingsPage, 51 | PersonLimitsSettings, 52 | TetrisPlanningButton, 53 | FieldLimitsSettingsPage, 54 | WiplimitOnCellsSettings, 55 | ], 56 | [Routes.ISSUE]: [MarkFlaggedIssues], 57 | [Routes.SEARCH]: [MarkFlaggedIssues, PrintCards], 58 | [Routes.REPORTS]: [AddSlaLine, AddChartGrid], 59 | [Routes.ALL]: [BugTemplate], 60 | }; 61 | 62 | runModifications(modificationsMap); 63 | } 64 | 65 | initBlurSensitive(); 66 | start(); 67 | -------------------------------------------------------------------------------- /src/contextMenu.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | // Blure 3 | const blurSecretDataJira = (info, tab) => { 4 | chrome.tabs.sendMessage(tab.id, { blurSensitive: info.checked }); 5 | }; 6 | 7 | const createContextMenu = tabId => { 8 | chrome.contextMenus.removeAll(() => { 9 | chrome.tabs.sendMessage(tabId, { getBlurSensitive: true }, response => { 10 | if (response && Object.prototype.hasOwnProperty.call(response, 'blurSensitive')) { 11 | const checked = response.blurSensitive; 12 | chrome.contextMenus.create({ 13 | title: 'Blur secret data', 14 | type: 'checkbox', 15 | checked, 16 | onclick: blurSecretDataJira, 17 | }); 18 | } 19 | }); 20 | }); 21 | }; 22 | 23 | chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { 24 | if (changeInfo.status === 'complete') { 25 | createContextMenu(tabId); 26 | } 27 | }); 28 | 29 | chrome.tabs.onActivated.addListener(activeInfo => { 30 | createContextMenu(activeInfo.tabId); 31 | }); 32 | -------------------------------------------------------------------------------- /src/field-limits/BoardPage/htmlTemplates.js: -------------------------------------------------------------------------------- 1 | import s from './styles.css'; 2 | 3 | export const fieldLimitsTemplate = ({ listBody }) => `
${listBody}
`; 4 | 5 | export const fieldLimitBlockTemplate = ({ blockClass, dataFieldLimitKey, innerText, bkgColor, issuesCountClass }) => ` 6 |
9 |
${innerText}
10 |
11 |
`; 12 | 13 | export const fieldLimitTitleTemplate = ({ limit = 0, current = 0, fieldName, fieldValue }) => 14 | `current: ${current} \nlimit: ${limit} \nfield name: ${fieldName}\nfield value: ${fieldValue}`; 15 | -------------------------------------------------------------------------------- /src/field-limits/BoardPage/styles.css: -------------------------------------------------------------------------------- 1 | .fieldLimitsList { 2 | display: inline-flex; 3 | margin-left: 20px; 4 | padding-left: 10px; 5 | position: absolute; 6 | border-left: 2px solid #f4f5f7; 7 | flex-wrap: wrap; 8 | margin-right: 200px; 9 | } 10 | 11 | .fieldLimitsItem { 12 | margin-bottom: 2px; 13 | height: 25px; 14 | font-size: 0.8rem; 15 | line-height: 25px; 16 | margin-left: 4px; 17 | text-align: center; 18 | border-radius: 5px; 19 | color: white; 20 | cursor: help; 21 | position: relative; 22 | background-color: #3366ff; 23 | padding-left: 3px; 24 | padding-right: 3px; 25 | } 26 | 27 | .limitStats { 28 | position: absolute; 29 | top: -10px; 30 | right: -6px; 31 | border-radius: 50%; 32 | background: grey; 33 | color: white; 34 | padding: 5px 2px; 35 | font-size: 12px; 36 | line-height: 12px; 37 | font-weight: 400; 38 | } 39 | -------------------------------------------------------------------------------- /src/field-limits/SettingsPage/htmlTemplates.js: -------------------------------------------------------------------------------- 1 | import style from './styles.css'; 2 | 3 | export const settingsEditBtnTemplate = btnId => `
4 | 5 |
`; 6 | 7 | export const fieldLimitsTableTemplate = ({ 8 | tableId, 9 | tableBodyId, 10 | addLimitBtnId, 11 | editLimitBtnId, 12 | fieldValueInputId, 13 | visualNameInputId, 14 | columnsSelectId, 15 | swimlanesSelectId, 16 | wipLimitInputId, 17 | applySwimlanesId, 18 | applyColumnsId, 19 | selectFieldId, 20 | selectFieldOptions = [], 21 | swimlaneOptions = [], 22 | columnOptions = [], 23 | }) => ` 24 |
25 |
26 | 27 | 28 | 55 | 71 | 72 | 73 | 74 | 86 | 87 |
29 |
30 | 31 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 | 49 |
50 |
51 | 52 | 53 |
54 |
56 |
57 | 58 | 61 | 62 |
63 |
64 | 65 | 68 | 69 |
70 |
  75 |
76 |
77 | 80 | 83 |
84 |
85 |
88 |
89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 |
Field NameField ValueVisual NameLimitColumnsSwimlanes
106 |
107 | `; 108 | 109 | export const fieldRowTemplate = ({ 110 | limitKey, 111 | fieldId, 112 | fieldName, 113 | fieldValue, 114 | visualValue, 115 | bkgColor, 116 | limit, 117 | columns = [], 118 | swimlanes = [], 119 | editClassBtn, 120 | deleteClassBtn, 121 | }) => ` 122 | 123 | 124 | ${fieldName} 125 | ${fieldValue} 126 |
${visualValue}
128 | ${limit} 129 | ${columns.map(c => c.name).join(', ')} 130 | ${swimlanes.map(s => s.name).join(', ')} 131 | 132 |
133 | 134 | 135 | 136 | `; 137 | -------------------------------------------------------------------------------- /src/field-limits/SettingsPage/styles.css: -------------------------------------------------------------------------------- 1 | .addFieldLimitTable { 2 | width: 100%; 3 | } 4 | 5 | .jhControlRowBtn { 6 | margin-left: 0px !important; 7 | margin-bottom: 2px !important; 8 | } 9 | 10 | .editFieldLimitBtn { 11 | margin-top: 1rem !important; 12 | } 13 | 14 | .addFieldLimitBtn { 15 | margin-top: 1rem !important; 16 | } 17 | 18 | .settingsEditBtn { 19 | margin-top: 1rem !important; 20 | } 21 | 22 | .visualName { 23 | color: #ffffff; 24 | height: 32px; 25 | border-radius: 25%; 26 | background-color: #3366ff; 27 | text-align: center; 28 | width: 40px; 29 | font-size: 0.8rem; 30 | vertical-align: middle; 31 | line-height: 32px; 32 | overflow: hidden; 33 | cursor: pointer; 34 | } -------------------------------------------------------------------------------- /src/field-limits/shared.js: -------------------------------------------------------------------------------- 1 | import indexBy from '@tinkoff/utils/array/indexBy'; 2 | import pluck from '@tinkoff/utils/array/pluck'; 3 | 4 | export const limitsKey = { 5 | encode: (fieldValue, fieldId) => `${fieldValue}@@@${fieldId}`, 6 | decode: limitKey => { 7 | const [fieldValue, fieldId] = limitKey.split('@@@'); 8 | return { 9 | fieldValue, 10 | fieldId, 11 | }; 12 | }, 13 | }; 14 | 15 | export const normalize = (byField, obj) => ({ 16 | byId: indexBy(x => x[byField], obj), 17 | allIds: pluck(byField, obj), 18 | }); 19 | -------------------------------------------------------------------------------- /src/issue/MarkFlaggedIssues.js: -------------------------------------------------------------------------------- 1 | import each from '@tinkoff/utils/array/each'; 2 | import { PageModification } from '../shared/PageModification'; 3 | import { getCurrentRoute, getIssueId, Routes } from '../routing'; 4 | import { loadFlaggedIssues, loadNewIssueViewEnabled } from '../shared/jiraApi'; 5 | import { issueDOM } from './domSelectors'; 6 | import { extensionApiService } from '../shared/ExtensionApiService'; 7 | 8 | const RelatedIssue = { 9 | LINKED: 'LINKED', 10 | EPIC_ISSUE: 'EPIC_ISSUE', 11 | SUB_TASK: 'SUB_TASK', 12 | LINKED_NEW: 'LINKED_NEW', 13 | }; 14 | 15 | const getFlag = newIssueView => { 16 | const flag = document.createElement('img'); 17 | flag.src = extensionApiService.getUrl(newIssueView ? '/img/flagNew.svg' : '/img/flag.png'); 18 | flag.style.width = '16px'; 19 | flag.style.height = '16px'; 20 | return flag; 21 | }; 22 | 23 | const getIssueSelector = () => { 24 | if (getCurrentRoute() === Routes.BOARD) { 25 | return `[data-issuekey='${getIssueId()}'] ${issueDOM.detailsBlock}`; // При переходе по задачам на доске надо дождаться загрузки нужной задачи 26 | } 27 | 28 | if (getCurrentRoute() === Routes.SEARCH) { 29 | return `[data-issue-key='${getIssueId()}']`; 30 | } 31 | 32 | return issueDOM.detailsBlock; 33 | }; 34 | 35 | export default class extends PageModification { 36 | shouldApply() { 37 | return getIssueId() != null; 38 | } 39 | 40 | getModificationId() { 41 | return `mark-flagged-issues-${getIssueId()}`; 42 | } 43 | 44 | preloadData() { 45 | return (this.getSearchParam('oldIssueView') ? Promise.resolve(false) : loadNewIssueViewEnabled()).then( 46 | newIssueView => { 47 | this.newIssueView = newIssueView; 48 | } 49 | ); 50 | } 51 | 52 | waitForLoading() { 53 | if (this.newIssueView) { 54 | return this.waitForElement(issueDOM.linkButton); 55 | } 56 | 57 | return this.waitForElement(getIssueSelector()); 58 | } 59 | 60 | async apply() { 61 | const issuesElements = {}; 62 | const addIssue = (key, element, type) => { 63 | if (!key) return; 64 | if (!issuesElements[key]) issuesElements[key] = []; 65 | 66 | issuesElements[key].push({ type, element }); 67 | }; 68 | 69 | if (this.newIssueView) { 70 | each(issueLink => { 71 | const key = issueLink.textContent; 72 | addIssue(key, issueLink.parentElement.parentElement, RelatedIssue.LINKED_NEW); 73 | }, document.querySelectorAll(issueDOM.subIssueLink)); 74 | } else { 75 | each(issueLink => { 76 | const key = issueLink.querySelector('a').dataset.issueKey; 77 | addIssue(key, issueLink.parentElement, RelatedIssue.LINKED); 78 | }, document.querySelectorAll(issueDOM.subIssue)); 79 | 80 | each(epicIssue => { 81 | const key = epicIssue.dataset.issuekey; 82 | addIssue(key, epicIssue, RelatedIssue.SUB_TASK); 83 | }, document.querySelectorAll(issueDOM.subTaskLink)); 84 | 85 | each(epicIssue => { 86 | const key = epicIssue.dataset.issuekey; 87 | addIssue(key, epicIssue, RelatedIssue.EPIC_ISSUE); 88 | }, document.querySelectorAll(issueDOM.epicIssueLink)); 89 | } 90 | 91 | const issueId = getIssueId(); 92 | const flaggedIssues = await loadFlaggedIssues([...Object.keys(issuesElements), issueId]); 93 | 94 | flaggedIssues.forEach(issueKey => { 95 | (issuesElements[issueKey] || []).forEach(({ type, element }) => { 96 | element.style.backgroundColor = this.newIssueView ? '#fffae6' : '#ffe9a8'; 97 | 98 | const flag = getFlag(this.newIssueView); 99 | 100 | switch (type) { 101 | case RelatedIssue.LINKED: { 102 | const snap = element.querySelector('.link-snapshot'); 103 | snap.insertBefore(flag, snap.children[0]); 104 | break; 105 | } 106 | case RelatedIssue.SUB_TASK: 107 | case RelatedIssue.EPIC_ISSUE: { 108 | flag.style.verticalAlign = 'top'; 109 | const status = element.querySelector('.status'); 110 | status.insertBefore(flag, null); 111 | break; 112 | } 113 | case RelatedIssue.LINKED_NEW: { 114 | const summary = element.querySelector(issueDOM.subIssueSummary); 115 | flag.style.marginRight = '4px'; 116 | summary.parentElement.insertBefore(flag, summary.nextElementSibling); 117 | break; 118 | } 119 | default: 120 | } 121 | }); 122 | 123 | if (!this.newIssueView && issueKey === issueId) { 124 | const mainField = document.querySelector('#priority-val') || document.querySelector('#type-val'); 125 | mainField.insertBefore(getFlag(this.newIssueView), null); 126 | } 127 | }); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/issue/domSelectors.js: -------------------------------------------------------------------------------- 1 | export const issueDOM = { 2 | // old view 3 | detailsBlock: '#details-module', 4 | subIssue: '#linkingmodule .link-content', 5 | subTaskLink: '#view-subtasks .issuerow', 6 | epicIssueLink: '#greenhopper-epics-issue-web-panel .issuerow', 7 | // new view 8 | linkButton: '[data-test-id="issue.issue-view.views.issue-base.foundation.quick-add.quick-add-item.link-issue"]', 9 | subIssueLink: '[data-test-id="issue.issue-view.views.common.issue-line-card.issue-line-card-view.key"]', 10 | subIssueSummary: '[data-test-id="issue.issue-view.views.common.issue-line-card.issue-line-card-view.summary"]', 11 | }; 12 | -------------------------------------------------------------------------------- /src/issue/img/flag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/issue/img/flag.png -------------------------------------------------------------------------------- /src/issue/img/flagNew.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Artboard 2 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "jira-helper", 4 | "short_name": "jira-helper", 5 | "version": "1.0.3", 6 | "author": "pavel.power@gmail.com", 7 | "description": "jira-helper", 8 | "icons": { 9 | "16": "src/jira_helper_16x16.png", 10 | "32": "src/jira_helper_32x32.png", 11 | "48": "src/jira_helper_48x48.png", 12 | "64": "src/jira_helper_64x64.png", 13 | "128": "src/jira_helper_128x128.png" 14 | }, 15 | "options_page": "options.html", 16 | "minimum_chrome_version": "55", 17 | "browser_action": { 18 | "default_title": "Jira Helper", 19 | "default_icon": "src/jira_helper_128x128.png" 20 | }, 21 | "background": { 22 | "scripts": ["background.js", "contextMenu.js"] 23 | }, 24 | "web_accessible_resources": [ 25 | "printcards.html", 26 | "openModal.js", 27 | "nativeModalScript.js", 28 | "img/*" 29 | ], 30 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", 31 | "content_scripts": [ 32 | { 33 | "matches": [ "*://*/*" ], 34 | "js": [ "content.js" ], 35 | "css": [ "src/blurSensitive.css" ] 36 | } 37 | ], 38 | "permissions": [ 39 | "storage", 40 | "tabs", 41 | "contextMenus", 42 | "http://*/*", "https://*/*" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Jira Helper 6 | 8 | 55 | 56 | 57 |
58 | 59 | Jira Helper 60 | 61 |
62 |
63 | 94 |
95 | 96 | 97 | -------------------------------------------------------------------------------- /src/options/options.js: -------------------------------------------------------------------------------- 1 | window.onload = () => 2 | (function(global) { 3 | const { document } = global; 4 | 5 | function printPdf(url) { 6 | const iframe = document.createElement('iframe'); 7 | iframe.className = 'pdfIframe'; 8 | document.body.appendChild(iframe); 9 | iframe.style.display = 'none'; 10 | iframe.onload = function() { 11 | setTimeout(function() { 12 | iframe.focus(); 13 | iframe.contentWindow.print(); 14 | URL.revokeObjectURL(url); 15 | }, 1); 16 | }; 17 | iframe.src = url; 18 | } 19 | 20 | function initBtnPrint() { 21 | const printBtn = document.querySelector('#print_template_btn'); 22 | printBtn.onclick = () => { 23 | printPdf('/options_static/jira_stickers_template.pdf'); 24 | }; 25 | } 26 | 27 | initBtnPrint(); 28 | })(window); 29 | -------------------------------------------------------------------------------- /src/options/static/jira_stickers_template.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/options/static/jira_stickers_template.pdf -------------------------------------------------------------------------------- /src/person-limits/PersonLimitsSettings.js: -------------------------------------------------------------------------------- 1 | import { PageModification } from '../shared/PageModification'; 2 | import { getSettingsTab } from '../routing'; 3 | import { btnGroupIdForColumnsSettingsPage, BOARD_PROPERTIES } from '../shared/constants'; 4 | import { openPersonLimitsModal } from './personLimitsModal'; 5 | 6 | export default class extends PageModification { 7 | async shouldApply() { 8 | return (await getSettingsTab()) === 'columns'; 9 | } 10 | 11 | getModificationId() { 12 | return `add-person-settings-${this.getBoardId()}`; 13 | } 14 | 15 | waitForLoading() { 16 | return this.waitForElement(`#columns #${btnGroupIdForColumnsSettingsPage}`); 17 | } 18 | 19 | loadData() { 20 | return Promise.all([this.getBoardEditData(), this.getBoardProperty(BOARD_PROPERTIES.PERSON_LIMITS)]); 21 | } 22 | 23 | apply([boardData = {}, personLimits = { limits: [] }]) { 24 | if (!boardData.canEdit) return; 25 | 26 | this.boardData = boardData; 27 | this.personLimits = personLimits; 28 | 29 | this.appendPersonLimitsButton(); 30 | this.onDOMChange('#columns', () => { 31 | this.appendPersonLimitsButton(); 32 | }); 33 | } 34 | 35 | appendPersonLimitsButton() { 36 | const personLimitsButton = this.insertHTML( 37 | document.getElementById(btnGroupIdForColumnsSettingsPage), 38 | 'beforeend', 39 | '' 40 | ); 41 | 42 | this.addEventListener(personLimitsButton, 'click', () => 43 | openPersonLimitsModal(this, this.boardData, this.personLimits) 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/person-limits/nativeModalScript.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const dialog = window.AJS.dialog2('#person-limits-dialog'); 3 | dialog.show(); 4 | })(); 5 | -------------------------------------------------------------------------------- /src/person-limits/personLimitsModal.html: -------------------------------------------------------------------------------- 1 | 68 | -------------------------------------------------------------------------------- /src/person-limits/personLimitsModal.js: -------------------------------------------------------------------------------- 1 | import personLimitsModal from './personLimitsModal.html'; 2 | import { extensionApiService } from '../shared/ExtensionApiService'; 3 | import { getUser } from '../shared/jiraApi'; 4 | import { BOARD_PROPERTIES } from '../shared/constants'; 5 | 6 | const renderRow = ({ id, person, limit, columns, swimlanes }, deleteLimit, onEdit) => { 7 | document.querySelector('#persons-limit-body').insertAdjacentHTML( 8 | 'beforeend', 9 | ` 10 | 11 | 12 | ${person.displayName} 13 | ${limit} 14 | ${columns.map(c => c.name).join(', ')} 15 | ${swimlanes.map(s => s.name).join(', ')} 16 |

17 | 18 | ` 19 | ); 20 | 21 | document.querySelector(`#delete-${id}`).addEventListener('click', async () => { 22 | await deleteLimit(id); 23 | document.querySelector(`#row-${id}`).remove(); 24 | }); 25 | 26 | document.querySelector(`#edit-${id}`).addEventListener('click', async () => { 27 | await onEdit(id); 28 | }); 29 | }; 30 | 31 | const renderAllRow = ({ modal, personLimits, deleteLimit, onEdit }) => { 32 | modal.querySelectorAll('.person-row').forEach(row => row.remove()); 33 | personLimits.limits.forEach(personLimit => renderRow(personLimit, deleteLimit, onEdit)); 34 | }; 35 | 36 | export const openPersonLimitsModal = async (modification, boardData, personLimits) => { 37 | const deleteLimit = async id => { 38 | personLimits.limits = personLimits.limits.filter(limit => limit.id !== id); 39 | await modification.updateBoardProperty(BOARD_PROPERTIES.PERSON_LIMITS, personLimits); 40 | }; 41 | 42 | const onEdit = async id => { 43 | const personalWIPLimit = personLimits.limits.find(limit => limit.id === id); 44 | 45 | document.querySelector('#limit').value = personalWIPLimit.limit; 46 | document.querySelector('#person-name').value = personalWIPLimit.person.name; 47 | 48 | const columns = document.querySelector('#column-select'); 49 | const selectedColumnsIds = personalWIPLimit.columns.map(c => c.id); 50 | 51 | columns.options.forEach(option => { 52 | option.selected = selectedColumnsIds.indexOf(option.value) > -1; 53 | }); 54 | 55 | const swimlanes = document.querySelector('#swimlanes-select'); 56 | const selectedSwimlanesIds = personalWIPLimit.swimlanes.map(c => c.id); 57 | 58 | swimlanes.options.forEach(option => { 59 | option.selected = selectedSwimlanesIds.indexOf(option.value) > -1; 60 | }); 61 | 62 | const editBtn = document.querySelector('#person-limit-edit-button'); 63 | editBtn.disabled = false; 64 | editBtn.setAttribute('person-id', id); 65 | document.querySelector(`#row-${id}`).style.background = '#ffd989c2'; 66 | 67 | await modification.updateBoardProperty(BOARD_PROPERTIES.PERSON_LIMITS, personLimits); 68 | }; 69 | 70 | const modal = modification.insertHTML(document.body, 'beforeend', personLimitsModal); 71 | 72 | const columnsSelect = modal.querySelector('.columns select'); 73 | boardData.rapidListConfig.mappedColumns.forEach(({ id, name }) => { 74 | const option = document.createElement('option'); 75 | option.text = name; 76 | option.value = id; 77 | option.selected = true; 78 | columnsSelect.appendChild(option); 79 | }); 80 | 81 | const swimlanesSelect = modal.querySelector('.swimlanes select'); 82 | boardData.swimlanesConfig.swimlanes.forEach(({ id, name }) => { 83 | const option = document.createElement('option'); 84 | option.text = name; 85 | option.value = id; 86 | option.selected = true; 87 | swimlanesSelect.appendChild(option); 88 | }); 89 | 90 | const getDataForm = () => { 91 | const name = modal.querySelector('#person-name').value; 92 | const limit = modal.querySelector('#limit').valueAsNumber; 93 | const columns = [...columnsSelect.selectedOptions].map(option => ({ id: option.value, name: option.text })); 94 | const swimlanes = [...swimlanesSelect.selectedOptions].map(option => ({ id: option.value, name: option.text })); 95 | 96 | return { 97 | person: { 98 | name, 99 | }, 100 | limit, 101 | columns, 102 | swimlanes, 103 | }; 104 | }; 105 | 106 | modal.querySelector('#person-limit-edit-button').addEventListener('click', async e => { 107 | e.preventDefault(); 108 | const personId = parseInt(e.target.getAttribute('person-id'), 10); 109 | e.target.disabled = true; 110 | 111 | if (!personId) return; 112 | 113 | const index = personLimits.limits.findIndex(pl => pl.id === personId); 114 | 115 | if (index === -1) return; 116 | 117 | const data = getDataForm(); 118 | 119 | personLimits.limits[index] = { 120 | ...personLimits.limits[index], 121 | ...data, 122 | person: { 123 | ...data.person, 124 | ...personLimits.limits[index].person, 125 | }, 126 | }; 127 | 128 | await modification.updateBoardProperty(BOARD_PROPERTIES.PERSON_LIMITS, personLimits); 129 | 130 | renderAllRow({ modal, personLimits, deleteLimit, onEdit }); 131 | }); 132 | 133 | modal.querySelector('#person-limit-save-button').addEventListener('click', async e => { 134 | e.preventDefault(); 135 | 136 | const data = getDataForm(); 137 | const fullPerson = await getUser(data.person.name); 138 | 139 | const personLimit = { 140 | id: Date.now(), 141 | person: { 142 | name: fullPerson.name ?? fullPerson.displayName, 143 | displayName: fullPerson.displayName, 144 | self: fullPerson.self, 145 | avatar: fullPerson.avatarUrls['32x32'], 146 | }, 147 | limit: data.limit, 148 | columns: data.columns, 149 | swimlanes: data.swimlanes, 150 | }; 151 | 152 | personLimits.limits.push(personLimit); 153 | 154 | await modification.updateBoardProperty(BOARD_PROPERTIES.PERSON_LIMITS, personLimits); 155 | 156 | renderRow(personLimit, deleteLimit, onEdit); 157 | }); 158 | 159 | modal.querySelector('#apply-columns').addEventListener('click', async e => { 160 | e.preventDefault(); 161 | 162 | const columns = [...columnsSelect.selectedOptions].map(option => ({ id: option.value, name: option.text })); 163 | const persons = [...modal.querySelectorAll('.select-user-chb:checked')].map(elem => Number(elem.dataset.id)); 164 | 165 | personLimits.limits = personLimits.limits.map(limit => 166 | persons.includes(limit.id) 167 | ? { 168 | ...limit, 169 | columns, 170 | } 171 | : limit 172 | ); 173 | 174 | await modification.updateBoardProperty(BOARD_PROPERTIES.PERSON_LIMITS, personLimits); 175 | renderAllRow({ modal, personLimits, deleteLimit, onEdit }); 176 | }); 177 | 178 | modal.querySelector('#apply-swimlanes').addEventListener('click', async e => { 179 | e.preventDefault(); 180 | 181 | const swimlanes = [...swimlanesSelect.selectedOptions].map(option => ({ id: option.value, name: option.text })); 182 | const persons = [...modal.querySelectorAll('.select-user-chb:checked')].map(elem => Number(elem.dataset.id)); 183 | 184 | personLimits.limits = personLimits.limits.map(limit => 185 | persons.includes(limit.id) 186 | ? { 187 | ...limit, 188 | swimlanes, 189 | } 190 | : limit 191 | ); 192 | 193 | await modification.updateBoardProperty(BOARD_PROPERTIES.PERSON_LIMITS, personLimits); 194 | 195 | modal.querySelectorAll('.person-row').forEach(row => row.remove()); 196 | personLimits.limits.forEach(personLimit => renderRow(personLimit, deleteLimit, onEdit)); 197 | }); 198 | 199 | personLimits.limits.forEach(personLimit => renderRow(personLimit, deleteLimit, onEdit)); 200 | 201 | // window.AJS is not available here 202 | const script = document.createElement('script'); 203 | script.setAttribute('src', extensionApiService.getUrl('nativeModalScript.js')); 204 | document.body.appendChild(script); 205 | }; 206 | -------------------------------------------------------------------------------- /src/popup/chromePlugin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= htmlWebpackPlugin.options.title %> 6 | 90 | 91 | 92 |
93 |
94 | 95 | 96 |
97 |
98 | 99 | 100 | -------------------------------------------------------------------------------- /src/popup/chromePlugin.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | const buttonTetris = document.getElementById('btn_settings'); 3 | 4 | buttonTetris.addEventListener('click', () => { 5 | window.chrome.tabs.query({ active: true, currentWindow: true }, function(tabs) { 6 | const tab = tabs[0]; 7 | 8 | if (/rapidView=(\d*)/im.test(tab.url)) { 9 | buttonTetris.addEventListener('click', () => { 10 | window.chrome.tabs.executeScript(null, { 11 | code: 'window.openTetrisPlanningWindow && window.openTetrisPlanningWindow();', 12 | }); 13 | }); 14 | } 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/printcards/PrintCards.js: -------------------------------------------------------------------------------- 1 | import { PageModification } from '../shared/PageModification'; 2 | import { SpecialFields } from './services/specialFields'; 3 | import { PopupService } from './services/popupService'; 4 | import { PrintCardButton } from './services/printButton'; 5 | import { extensionApiService as extensionService } from '../shared/ExtensionApiService'; 6 | 7 | export default class extends PageModification { 8 | getModificationId() { 9 | return 'print-cards'; 10 | } 11 | 12 | waitForLoading() { 13 | return Promise.any([this.waitForElement('#jql'), this.waitForElement('[data-testid="jql-editor-input"]')]); 14 | } 15 | 16 | apply() { 17 | const specialFieldsService = new SpecialFields({ extensionService }); 18 | const popupService = new PopupService({ extensionService, specialFieldsService }); 19 | 20 | const printCardButton = new PrintCardButton({ extensionService, popupService }); 21 | printCardButton.render(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/printcards/cardsRender/cardsRender.md: -------------------------------------------------------------------------------- 1 | # Рендер карт 2 | 3 | Это отдельная страница, которая открывается по нажатию кнопки печати во всплывающем окне. 4 | Здесь содержится логика именно по рисованию карточек. 5 | 6 | *Собирается отдельно* от `printcards` основной. 7 | 8 | Находится на отдельной странице: `chrome-extension:///printcards.html` 9 | -------------------------------------------------------------------------------- /src/printcards/cardsRender/fonts/gostA.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/printcards/cardsRender/fonts/gostA.woff -------------------------------------------------------------------------------- /src/printcards/cardsRender/fonts/gostB.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/printcards/cardsRender/fonts/gostB.ttf -------------------------------------------------------------------------------- /src/printcards/cardsRender/fonts/gostB.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/printcards/cardsRender/fonts/gostB.woff -------------------------------------------------------------------------------- /src/printcards/cardsRender/printCard.css: -------------------------------------------------------------------------------- 1 | .root { 2 | background-color: #fff; 3 | box-sizing: border-box; 4 | border: 1px solid #ddd; 5 | margin: 2cm 0.5cm 0; 6 | padding: 0.3cm; 7 | overflow: hidden; 8 | height: 7.6cm; 9 | width: 7.6cm; 10 | position: relative; 11 | float: left; 12 | 13 | } 14 | 15 | .content { 16 | box-sizing: border-box; 17 | height: 100%; 18 | overflow: hidden; 19 | position: relative; 20 | } 21 | 22 | .header { 23 | top: 0; 24 | overflow: hidden; 25 | position: absolute; 26 | vertical-align: middle; 27 | width: 100%; 28 | } 29 | 30 | .epic { 31 | float: left; 32 | padding: 3px; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | vertical-align: middle; 36 | position: relative; 37 | color: #fff; 38 | font-size: 20px; 39 | font-weight: bold; 40 | } 41 | 42 | 43 | .info { 44 | margin-top: 35px; 45 | position: absolute; 46 | font-size: 18px; 47 | overflow: hidden; 48 | height: 5cm; 49 | width: 100%; 50 | display: flex; 51 | } 52 | 53 | .summary { 54 | overflow: hidden; 55 | margin-bottom: 5px; 56 | display: table-cell; 57 | margin-top: auto; 58 | vertical-align: bottom; 59 | } 60 | 61 | .summary span{ 62 | vertical-align: bottom; 63 | } 64 | 65 | .space { 66 | width: 100%; 67 | height: 1.8cm; 68 | position: absolute; 69 | background-color: #fff; 70 | top:0; 71 | } 72 | .people { 73 | position: relative; 74 | top: 0; 75 | background-color: #fff; 76 | float: right; 77 | } 78 | 79 | .name { 80 | position: relative; 81 | vertical-align: middle; 82 | } 83 | 84 | .footer { 85 | bottom: 0; 86 | overflow: hidden; 87 | position: absolute; 88 | vertical-align: middle; 89 | width: 100%; 90 | } 91 | 92 | .number { 93 | float: left; 94 | } 95 | 96 | .icon, .key { 97 | display: inline-block; 98 | margin-right: 5px; 99 | vertical-align: middle; 100 | } 101 | 102 | .icon img { 103 | height: 28px; 104 | width: 28px; 105 | } 106 | 107 | .key { 108 | font-size: 20px; 109 | font-weight: bold; 110 | } 111 | 112 | .point { 113 | position: relative; 114 | overflow: hidden; 115 | text-align: center; 116 | margin-right: 2px; 117 | vertical-align: middle; 118 | float: right; 119 | font-size: 28px; 120 | font-weight: bold; 121 | } 122 | 123 | .rootDescription { 124 | padding: 1rem; 125 | font-size: 1.3rem; 126 | font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; 127 | } 128 | 129 | .submitQuestionsCards { 130 | border: none; 131 | border-radius: 5px; 132 | cursor: pointer; 133 | padding: 15px 30px; 134 | background-color: #ffdd2d; 135 | font-weight: 500; 136 | } 137 | 138 | .submitQuestionsCards:hover, .submitQuestionsCards:focus { 139 | background-color: #FCC521; 140 | } 141 | 142 | .askQuestionsForm { 143 | padding: 1rem; 144 | } 145 | 146 | .askQuestionsForm > label { 147 | text-transform: lowercase; 148 | } 149 | 150 | .roleName { 151 | text-decoration: underline; 152 | } 153 | -------------------------------------------------------------------------------- /src/printcards/cardsRender/printcards.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 26 | Document 27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /src/printcards/cardsRender/printcards.js: -------------------------------------------------------------------------------- 1 | import { getCards, getRoles } from '../../background/actions'; 2 | import { getEpicKey } from '../utils/common'; 3 | import { renderSingleCardToString } from './renderSingleCardToString'; 4 | import { extensionApiService } from '../../shared/ExtensionApiService'; 5 | 6 | function renderCards(issues, epics, neededFields, specialFields) { 7 | const cards = issues.sort( 8 | (a, b) => (getEpicKey(a, specialFields.epic) || '').localeCompare(getEpicKey(b, specialFields.epic) || '') || [] 9 | ); 10 | 11 | document 12 | .querySelector('.root') 13 | .insertAdjacentHTML( 14 | 'beforeend', 15 | cards.map(issue => renderSingleCardToString({ issue, epics, neededFields, specialFields })).join('') 16 | ); 17 | } 18 | 19 | function init() { 20 | Promise.all([extensionApiService.bgRequest(getCards()), extensionApiService.bgRequest(getRoles())]) 21 | .then(([jiraCards, roles]) => { 22 | if (!jiraCards.issues || !jiraCards.epics) { 23 | return alert('Data is not found. Repeat please search issues on the page of search.'); 24 | } 25 | 26 | const { issues, epics, specialFields } = jiraCards; 27 | 28 | // askSettings(issues, epics, host); 29 | return renderCards(issues, epics, roles, specialFields); 30 | }) 31 | .catch(err => { 32 | console.error('printcards-page Error: ', err); // eslint-disable-line no-console 33 | }); 34 | } 35 | 36 | init(); 37 | -------------------------------------------------------------------------------- /src/printcards/cardsRender/renderSingleCardToString.js: -------------------------------------------------------------------------------- 1 | import styles from './printCard.css'; 2 | import { sortTitle, getDisplayName, getEpicByKey, getEpicColors, getRoleName } from '../utils/common'; 3 | 4 | const MAX_LEN_SUMMARY = 120; 5 | const MAX_LEN_EPIC_NAME = 25; 6 | 7 | const defaultEpicColors = { 8 | bgColor: '#f4f5f7', 9 | color: '#0052cc', 10 | borderColor: '#dfe1e6', 11 | }; 12 | 13 | export function renderSingleCardToString({ issue, epics, neededFields = [], specialFields }) { 14 | const { fields = '', key = '' } = issue; 15 | const { epicStyles = {} } = specialFields; 16 | const { 17 | [specialFields.epic]: epicKey = '', 18 | [specialFields.storyPoints]: storyPoints = '', 19 | summary = '', 20 | issuetype, 21 | priority, 22 | } = fields; 23 | const { iconUrl: issuetypeIcon = '' } = issuetype || {}; 24 | const { iconUrl: priorityIcon } = priority || {}; 25 | const epic = (epics && epicKey && getEpicByKey(epics, epicKey)) || ''; 26 | const { [specialFields.epicName]: epicName = '', [specialFields.epicColor]: cssClassEpicColor } = epic && epic.fields; 27 | 28 | const { backgroundColor: epicBgColor, color: epicColor } = getEpicColors(cssClassEpicColor, epicStyles); 29 | 30 | const resultBgEpicColor = epicBgColor || defaultEpicColors.bgColor; 31 | const resultEpicColor = epicColor || defaultEpicColors.color; 32 | 33 | return ` 34 |
35 |
36 |
37 | 40 | ${issuetype.name === 'Epic' ? '' : sortTitle(epicName, MAX_LEN_EPIC_NAME)} 41 | 42 |
43 |
44 |
45 |
46 | 47 | ${specialFields.roleFields 48 | .map(roleField => { 49 | const field = fields[roleField.id]; 50 | return field && neededFields.includes(roleField.id) 51 | ? `
52 | ${getRoleName(roleField.name)} 53 | ${getDisplayName(field)}
` 54 | : ''; 55 | }) 56 | .join('')} 57 |
58 |
59 |
60 | 61 | ${issuetype.name === 'Epic' ? sortTitle(epicName, MAX_LEN_SUMMARY) : sortTitle(summary, MAX_LEN_SUMMARY)} 62 | 63 |
64 |
65 |
66 |
67 |
68 | ${priorityIcon ? `
` : ''} 69 |
${key}
70 |
71 |
${storyPoints || ''}
72 |
73 |
74 |
`; 75 | } 76 | -------------------------------------------------------------------------------- /src/printcards/cardsRender/styleColorEpic.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-commonjs */ 2 | 3 | module.exports = { 4 | 'ghx-label-0': { 5 | backgroundColor: '#fff', 6 | borderColor: '#707070', 7 | color: '#707070', 8 | }, 9 | 10 | 'ghx-label-1': { 11 | color: '#fff', 12 | backgroundColor: '#815b3a', 13 | borderColor: '#815b3a', 14 | }, 15 | 16 | 'ghx-label-2': { 17 | color: '#fff', 18 | backgroundColor: '#f79232', 19 | borderColor: '#f79232', 20 | }, 21 | 22 | 'ghx-label-3': { 23 | color: '#fff', 24 | backgroundColor: '#d39c3f', 25 | borderColor: '#d39c3f', 26 | }, 27 | 28 | 'ghx-label-4': { 29 | color: '#fff', 30 | backgroundColor: '#3b7fc4', 31 | borderColor: '#3b7fc4', 32 | }, 33 | 34 | 'ghx-label-5': { 35 | color: '#fff', 36 | backgroundColor: '#4a6785', 37 | borderColor: '#4a6785', 38 | }, 39 | 40 | 'ghx-label-6': { 41 | color: '#fff', 42 | backgroundColor: '#8eb021', 43 | borderColor: '#8eb021', 44 | }, 45 | 46 | 'ghx-label-7': { 47 | color: '#fff', 48 | backgroundColor: '#ac707a', 49 | borderColor: '#ac707a', 50 | }, 51 | 52 | 'ghx-label-8': { 53 | color: '#fff', 54 | backgroundColor: '#654982', 55 | borderColor: '#654982', 56 | }, 57 | 58 | 'ghx-label-9': { 59 | color: '#fff', 60 | backgroundColor: '#f15c75', 61 | borderColor: '#f15c75', 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/printcards/img/printIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/jira-helper/5a6d6523910dcdb90d84c1695c6c93414ef66f00/src/printcards/img/printIcon.png -------------------------------------------------------------------------------- /src/printcards/jiraHelperCards.md: -------------------------------------------------------------------------------- 1 | ## Jira Helper - извлечение полей 2 | ### Проблема 3 | Среди различных платформ jira (jira2, jira, etc) существуют различные идентификаторы полей 4 | для ролей, эпиков и стилей. Тем более, различным проектам внутри пространства JIRA могут потребоваться 5 | различные роли для отображения на стикерах. 6 | 7 | ### Решение - Конфигурация Проекта / Локальная конфигурация 8 | 9 | Поля чаще всего обозначены в структуре проекта как `customfield__**` у каждого типа задачи. 10 | Среди них мы можем предоставить пользователю на выбор 5 полей (огр. размера карточки) для ролей. 11 | И три поля, которые необходимы для адекватной работоспособности нашего расширения: селекторы стилей, 12 | поле storyPoints, поле Epic задачи. 13 | 14 | Поля будут храниться *локально и на Jira-проекте*. 15 | 16 | #### Схемы запросов полей 17 | Поля для ролей считаю необходимым явно объявлять в конфигурации, либо парсить/искать по каким-то 18 | ключевым словам. 19 | 20 | Поля для стилей, стори-поинтов и эпик-задач считаю возможным искать по всем JIRA-полям, и извлечённые значения 21 | устанавливать как дефолтные. С возможностью редактирования идентификатора на каждое поле. 22 | 23 | Добавить возможность шарить конфигурацию полей для админов проекта. 24 | 25 | Схема примерно следующая (по приоритетам извлечения конфигурации): 26 | 27 | запрос локально запрос через проект JIRA поиск дефолтных по схемам 28 | | | (if prev. failed) | (if prev. failed) 29 | ------- | ------------------------ | ------------------------- | -----------------------> рендер карт 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/printcards/services/popupService/helpers/popupTemplates.js: -------------------------------------------------------------------------------- 1 | export const getAboutTemplate = ({ optionsURL, text }) => ` 2 | ${text} 3 |

4 | `; 5 | 6 | export const getProgressTemplate = ({ id, desc, completePercent, inProgressPercent }) => ` 7 |
8 | 36 |
37 |
38 |
39 |
40 |
41 |
42 | 45 |
46 | `; 47 | 48 | export const getTaskLimitTemplate = ({ startBtnId, maxTasksInputId, maxIssues }) => ` 49 |
50 | 61 |
62 |
63 | 64 |
65 |
66 | 67 |
68 |
69 | `; 70 | 71 | export const getRoleSettingsTemplate = ({ requiredRolesBlockId, fields }) => ` 72 |
73 |

Set card roles, maximum for 5 roles:

74 |
75 |
76 | ${fields 77 | .map( 78 | field => 79 | `` 80 | ) 81 | .join('
')} 82 |
83 |
84 | `; 85 | -------------------------------------------------------------------------------- /src/printcards/services/printButton.js: -------------------------------------------------------------------------------- 1 | import each from '@tinkoff/utils/array/each'; 2 | import styles from '../styles/printCards.css'; 3 | 4 | export class PrintCardButton { 5 | constructor({ extensionService, popupService }) { 6 | this.extensionService = extensionService; 7 | this.popupService = popupService; 8 | 9 | this.identifiers = { 10 | printCardsBtnId: 'print-cards-btn', 11 | }; 12 | this.iconSrc = this.extensionService.getUrl('/img/printIcon.png'); 13 | this.$printCardBtn = null; 14 | 15 | this.onClick = this.onClick.bind(this); 16 | } 17 | 18 | isExistJqElem(selector) { 19 | return document.querySelector(selector) != null; 20 | } 21 | 22 | getBtnTemplate() { 23 | const { printCardsBtnId } = this.identifiers; 24 | 25 | const htmlPrintIcon = ``; 26 | 27 | return `
28 | 31 |
`; 32 | } 33 | 34 | getAmountOfIssues() { 35 | // Есть несколько видов отображения результатов поиска запроса - списком, либо разделённым с деталями задач. 36 | // Переключатель есть справа сверху, под кнопкой настроек 37 | try { 38 | const listViewCounter = document.querySelector('.results-count-link'); 39 | const listAmountOfIssues = listViewCounter && listViewCounter.textContent; 40 | 41 | const detailsViewCounter = document.querySelector('#content .pager-container .showing'); 42 | const detailsAmountOfIssues = detailsViewCounter && detailsViewCounter.textContent.split(' ').pop(); 43 | 44 | return +(listAmountOfIssues || detailsAmountOfIssues || 0); 45 | } catch (err) { 46 | return 0; 47 | } 48 | } 49 | 50 | onClick() { 51 | const elm = document.querySelector('#jql'); 52 | let jql = ''; 53 | 54 | if (elm) { 55 | jql = document.querySelector('#jql').value; 56 | } else { 57 | jql = new URL(document.location).searchParams.get('jql'); 58 | } 59 | 60 | const issueCount = this.getAmountOfIssues(); 61 | if ((!jql && !jql.length) || jql === 'ORDER BY updated DESC') 62 | return alert('You should first search the query on the current page'); 63 | 64 | return this.popupService.renderPopup(jql, issueCount); 65 | } 66 | 67 | subscribeToSwitchingSearchMechanism() { 68 | each( 69 | link => link.addEventListener('click', () => setTimeout(() => this.render()), { once: true }), 70 | document.querySelectorAll('a[data-id="advanced"], a[data-id="basic"]') 71 | ); 72 | } 73 | 74 | bindBtb() { 75 | const { printCardsBtnId } = this.identifiers; 76 | this.$printCardBtn = document.querySelector(`#${printCardsBtnId}`); 77 | 78 | this.$printCardBtn.addEventListener('click', this.onClick); 79 | this.subscribeToSwitchingSearchMechanism(); 80 | } 81 | 82 | render() { 83 | const { printCardsBtnId } = this.identifiers; 84 | const isPageAlreadyContainsBtn = this.isExistJqElem(`#${printCardsBtnId}`); 85 | const cloudJiraContainer = document.querySelector('[data-testid="jql-editor-input"]'); 86 | const isOptionsContainerExist = this.isExistJqElem('.search-options-container'); 87 | 88 | if (isPageAlreadyContainsBtn) return; 89 | let container; 90 | 91 | if (cloudJiraContainer) { 92 | container = document.querySelector('[data-testid="jql-editor-input"]').parentElement.parentElement; 93 | container.insertAdjacentHTML('afterbegin', this.getBtnTemplate()); 94 | this.bindBtb(); 95 | const sampleBtn = container.querySelector('[role="presentation"]'); 96 | this.$printCardBtn.parentElement.classList.remove(styles.printCardBtn_Wrapper); 97 | sampleBtn.classList.forEach(cls => this.$printCardBtn.parentElement.classList.add(cls)); 98 | return; 99 | } 100 | 101 | if (!isOptionsContainerExist) return; 102 | container = document.querySelector('.search-container'); 103 | container.insertAdjacentHTML('beforeend', this.getBtnTemplate()); 104 | 105 | this.bindBtb(); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/printcards/services/specialFields.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import path from '@tinkoff/utils/object/path'; 3 | import isArray from '@tinkoff/utils/is/array'; 4 | import isEqual from '@tinkoff/utils/is/equal'; 5 | import mapObj from '@tinkoff/utils/object/map'; 6 | import compose from '@tinkoff/utils/function/compose'; 7 | import test from '@tinkoff/utils/string/test'; 8 | import keys from '@tinkoff/utils/object/keys'; 9 | import defaultEpicStyles from '../cardsRender/styleColorEpic'; 10 | import { getAllFields } from '../../shared/jiraApi'; 11 | 12 | const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24; 13 | function dayDifference(date1, date2) { 14 | // https://levelup.gitconnected.com/find-difference-between-two-dates-in-javascript-117be3e73caf 15 | const timeDiff = Math.abs(date2.getTime() - date1.getTime()); 16 | const diffDays = Math.ceil(timeDiff / MILLISECONDS_PER_DAY); 17 | return diffDays; 18 | } 19 | 20 | export class SpecialFields { 21 | constructor({ extensionService }) { 22 | this.extensionService = extensionService; 23 | 24 | this.EPIC_KEY_STORAGE_POSTFIX = '__epic_key'; 25 | } 26 | 27 | /* 28 | * Запрашивает ключ в локальном хранилище, если нет или прошло больше 2-х дней со дня обновления ключа, 29 | * то снова ищет ключ в JIRA (через запрос всех полей) 30 | * */ 31 | findSpecialFields() { 32 | return this.fetchSpecialFields().catch(() => 33 | this.updateSpecialFieldsThroughJira().then(() => this.fetchSpecialFields()) 34 | ); 35 | } 36 | 37 | fetchSpecialFields(host) { 38 | return this.fetchSpecialFieldsFromStorage(host).then(fields => this.normalizeSpecialFields(fields)); 39 | } 40 | 41 | // REQUEST FOR EPIC KEY 42 | updateSpecialFieldsThroughJira() { 43 | const epicStyles = this.getEpicStyles(); 44 | 45 | const rulesForSpecialFields = { 46 | epic: compose(isEqual('com.pyxis.greenhopper.jira:gh-epic-link'), path(['schema', 'custom'])), 47 | epicColor: compose(isEqual('com.pyxis.greenhopper.jira:gh-epic-color'), path(['schema', 'custom'])), 48 | epicName: compose(isEqual('com.pyxis.greenhopper.jira:gh-epic-label'), path(['schema', 'custom'])), 49 | storyPoints: compose(test(/story points/gim), path(['name'])), 50 | }; 51 | 52 | const ruleForRolesField = compose(isEqual('user'), path(['schema', 'type'])); 53 | 54 | return getAllFields().then(fields => { 55 | if (!isArray(fields)) return Promise.reject('Invalid data'); 56 | 57 | const requiredFieldsKeys = keys(rulesForSpecialFields); 58 | const matchedFields = mapObj(() => null, rulesForSpecialFields); 59 | 60 | const roleFields = []; 61 | 62 | fieldsLoop: for ( 63 | let i = 0, fieldsLength = fields.length; 64 | i < fieldsLength || keys(matchedFields).length !== requiredFieldsKeys.length; 65 | i++ 66 | ) { 67 | // matching rule for role customfields 68 | if (ruleForRolesField(fields[i])) { 69 | roleFields.push(fields[i]); 70 | continue; 71 | } 72 | 73 | // matching rules for specific customfields 74 | for (let j = 0, requiredFieldsLength = requiredFieldsKeys.length; j < requiredFieldsLength; j++) { 75 | const key = requiredFieldsKeys[j]; 76 | const validator = rulesForSpecialFields[key]; 77 | 78 | if (validator(fields[i])) { 79 | matchedFields[key] = fields[i].id; 80 | continue fieldsLoop; 81 | } 82 | } 83 | } 84 | 85 | return this.updateSpecialFieldsInStorage({ 86 | ...matchedFields, 87 | epicStyles, 88 | roleFields, 89 | }); 90 | }); 91 | } 92 | // ------------------------- 93 | 94 | // STORAGE MANIPULATING EPIC KEY 95 | updateSpecialFieldsInStorage(specialFieldsObj, options = { fieldsWasSettedByUser: false }) { 96 | const syncDate = new Date().toISOString(); 97 | const { host } = window.location; 98 | 99 | return this.extensionService.updateStorageValue( 100 | `${host}${this.EPIC_KEY_STORAGE_POSTFIX}`, 101 | JSON.stringify({ 102 | specialFields: specialFieldsObj, 103 | syncDate, 104 | fieldsWasSettedByUser: options.fieldsWasSettedByUser, 105 | }) 106 | ); 107 | } 108 | 109 | fetchSpecialFieldsFromStorage(jiraHost) { 110 | const host = jiraHost || window.location.host; 111 | 112 | return this.extensionService.fetchStorageValueByKey(`${host}${this.EPIC_KEY_STORAGE_POSTFIX}`).then(JSON.parse); 113 | } 114 | 115 | normalizeSpecialFields(epicField) { 116 | if (epicField.fieldsWasSettedByUser) { 117 | return Promise.resolve(epicField.specialFields); 118 | } 119 | 120 | const currentDate = new Date(); 121 | const fieldKeyDate = new Date(epicField.syncDate); 122 | 123 | const isOutdatedSpecialFields = dayDifference(currentDate, fieldKeyDate) > 2; 124 | 125 | if (isOutdatedSpecialFields) return Promise.reject('Outdated special Fields'); 126 | return Promise.resolve(epicField.specialFields); 127 | } 128 | // ------------------------- 129 | 130 | // SEARCHING FOR COLORS 131 | getEpicStyles() { 132 | const result = { 133 | /* 'ghx-color-': { 134 | backgroundColor: , 135 | borderColor: , 136 | color: 137 | } */ 138 | }; 139 | 140 | const elementForCalculations = document.createElement('div'); 141 | elementForCalculations.style.display = 'none'; 142 | document.body.appendChild(elementForCalculations); 143 | 144 | const colorsMaxRange = 9; 145 | const colorsClassPrefix = 'ghx-label-'; 146 | 147 | for (let i = 0, length = colorsMaxRange; i <= length; i++) { 148 | const currentClassName = `${colorsClassPrefix}${i}`; 149 | 150 | elementForCalculations.classList.add(currentClassName); 151 | const { backgroundColor, color, borderColor } = window.getComputedStyle(elementForCalculations); 152 | const { 153 | backgroundColor: mainJiraBgColor, 154 | color: mainJiraColor, 155 | borderColor: mainJiraBorderColor, 156 | } = defaultEpicStyles[currentClassName]; 157 | 158 | result[currentClassName] = { 159 | backgroundColor: backgroundColor || mainJiraBgColor, 160 | color: color || mainJiraColor, 161 | borderColor: borderColor || mainJiraBorderColor, 162 | }; 163 | 164 | elementForCalculations.classList.remove(currentClassName); 165 | } 166 | 167 | return result; 168 | } 169 | // ----------------------- 170 | } 171 | -------------------------------------------------------------------------------- /src/printcards/styles/printCards.css: -------------------------------------------------------------------------------- 1 | .printCardBtn_Wrapper { 2 | line-height: 37px; 3 | white-space: nowrap; 4 | vertical-align: top; 5 | display: table-cell; 6 | width: 1px; /* hack for bigger jql-input */ 7 | } 8 | 9 | .printCardBtn { 10 | border: none; 11 | cursor: pointer; 12 | background: none; 13 | } 14 | 15 | .printCardIcon { 16 | width: 20px; 17 | } 18 | 19 | 20 | .loader { 21 | margin: auto auto; 22 | font-size: 5px; 23 | position: relative; 24 | text-indent: -9999em; 25 | /*border-top: 2px solid mix(white,black,80%);*/ 26 | /*border-right: 2px solid mix(white,black,80%);*/ 27 | /*border-bottom: 2px solid mix(white,black,80%);*/ 28 | border-left: 2px solid #344563; 29 | -webkit-animation: load8 .5s infinite linear; 30 | animation: load8 .5s infinite linear; 31 | } 32 | .loader, 33 | .loader:after { 34 | border-radius: 50%; 35 | width:17px; 36 | height: 17px; 37 | } 38 | @-webkit-keyframes load8 { 39 | 0% { 40 | -webkit-transform: rotate(0deg); 41 | transform: rotate(0deg); 42 | } 43 | 100% { 44 | -webkit-transform: rotate(360deg); 45 | transform: rotate(360deg); 46 | } 47 | } 48 | @keyframes load8 { 49 | 0% { 50 | -webkit-transform: rotate(0deg); 51 | transform: rotate(0deg); 52 | } 53 | 100% { 54 | -webkit-transform: rotate(360deg); 55 | transform: rotate(360deg); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/printcards/utils/common.js: -------------------------------------------------------------------------------- 1 | import path from '@tinkoff/utils/object/path'; 2 | 3 | /** 4 | * Возвращает цвет эпика 5 | * @name getEpicColors 6 | * @function 7 | * @param {string} epicCssColorClass - класс для эпика типа ghx-color-N, где N = [0, 9] 8 | * @param {object} epicStyles - объект со стилями для эпика 9 | * @return {string} - цвет для background-color 10 | */ 11 | export function getEpicColors(epicCssColorClass, epicStyles) { 12 | const styles = epicStyles[epicCssColorClass]; 13 | return styles || {}; 14 | } 15 | 16 | /** 17 | * Возвращает эпик из списка задач-эпиков 18 | * @name getEpicByKey 19 | * @function 20 | * @param {array} epics - задачи-эпики 21 | * @param {string} epicKey - идентификатор проекта 22 | * @return {object} - эпик 23 | */ 24 | export function getEpicByKey(epics = [], epicKey = '') { 25 | return epics.find(ep => ep.key === epicKey); 26 | } 27 | 28 | /** 29 | * Сокращает длину заголовка 30 | * @name sortTitle 31 | * @function 32 | * @param {string} title - заголовок 33 | * @param {integer} maxLen - максимальная длина заголовка 34 | * @return {string} title - укороченный заголовок 35 | */ 36 | export function sortTitle(title, maxLen) { 37 | return title ? title.substr(0, maxLen) + (title.length > maxLen ? '…' : '') : ''; 38 | } 39 | 40 | /** 41 | * Возвращает имя и фамилию 42 | * @name getDisplayName 43 | * @function 44 | * @param {object} field - поля сотрудника 45 | * @return {string} - Имя Фамилия 46 | */ 47 | export function getDisplayName(field) { 48 | return ( 49 | field && field.displayName && `${field.displayName.split(' ')[0] || ''} ${field.displayName.split(' ')[1] || ''}` 50 | ); 51 | } 52 | 53 | export function getRoleName(name) { 54 | return (name && `${name.substring(0, 3)}.`) || ''; 55 | } 56 | 57 | /** 58 | * Возвращает ID эпика из поля 59 | * @name getEpicKey 60 | * @function 61 | * @param {object} issue - задача 62 | * @param {string} epicFieldName - идентификатор поля типа customfield_999999 на JIRA 63 | * @return {*|string} - номер эпика 64 | */ 65 | export function getEpicKey(issue, epicFieldName) { 66 | return path(['fields', epicFieldName], issue); 67 | } 68 | 69 | export function getProject(issue) { 70 | return path(['fields', 'project'], issue); 71 | } 72 | -------------------------------------------------------------------------------- /src/related-tasks/todo.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | # Показывать связи задач прямо на доске 4 | 5 | Предпологается использовать рисование связей векторными стрелками поверх доски 6 | по включению кнопки в окошке плагина. 7 | 8 | Разного типа связи показываются разными цветами. 9 | -------------------------------------------------------------------------------- /src/routing.js: -------------------------------------------------------------------------------- 1 | import { types } from './background/actions'; 2 | import { waitForElement } from './shared/utils'; 3 | import { extensionApiService } from './shared/ExtensionApiService'; 4 | 5 | export const Routes = { 6 | BOARD: 'BOARD', 7 | SETTINGS: 'SETTINGS', 8 | SEARCH: 'SEARCH', 9 | REPORTS: 'REPORTS', 10 | ISSUE: 'ISSUE', 11 | ALL: 'ALL', 12 | }; 13 | 14 | export const getSearchParam = param => { 15 | return new URLSearchParams(window.location.search).get(param); 16 | }; 17 | 18 | /* 19 | sheme new 2022: https://companyname.atlassian.net/jira/software/c/projects/{KEY}/boards/41/reports/control-chart?days=0 20 | */ 21 | export const getReportNameFromURL = () => { 22 | // eslint-disable-next-line no-useless-escape 23 | const matchRapidView = window.location.pathname.match(/reports\/([^\/\?]*)/im); 24 | if (matchRapidView != null) { 25 | return matchRapidView[1]; 26 | } 27 | return null; 28 | }; 29 | 30 | /* 31 | sheme old https://companyname.atlassian.net/secure/RapidBoard.jspa?projectKey=PN&rapidView=12 32 | sheme new https://companyname.atlassian.net/jira/software/c/projects/{KEY}/boards/12 33 | sheme new 2022: https://companyname.atlassian.net/jira/software/c/projects/{KEY}/boards/41/reports/control-chart?days=0 34 | */ 35 | export const getBoardIdFromURL = () => { 36 | if (window.location.href.indexOf('rapidView') > 0) { 37 | return getSearchParam('rapidView'); 38 | } 39 | 40 | const matchRapidView = window.location.pathname.match(/boards\/(\d+)/im); 41 | if (matchRapidView != null) { 42 | return matchRapidView[1]; 43 | } 44 | 45 | return null; 46 | }; 47 | 48 | export const getProjectKeyFromURL = () => { 49 | if (window.location.href.indexOf('projectKey') > 0) { 50 | return getSearchParam('projectKey'); 51 | } 52 | 53 | // eslint-disable-next-line no-useless-escape 54 | const matchProjectKey = window.location.pathname.match(/projects\/([^\/]+)/im); 55 | if (matchProjectKey != null) { 56 | return matchProjectKey[1]; 57 | } 58 | 59 | return null; 60 | }; 61 | 62 | /* 63 | cloud update 2021-09-30 64 | https://mycompany.atlassian.net/jira/software/c/projects/MP/boards/138?config=filter 65 | https://mycompany.atlassian.net/jira/software/c/projects/MP/boards/138?config=columns 66 | https://mycompany.atlassian.net/jira/software/c/projects/MP/boards/138?config=swimlanes 67 | https://mycompany.atlassian.net/jira/software/c/projects/MP/boards/138?config=swimlanes 68 | https://mycompany.atlassian.net/jira/software/c/projects/MP/boards/138?config=cardColors 69 | https://mycompany.atlassian.net/jira/software/c/projects/MP/boards/138?config=cardLayout 70 | https://mycompany.atlassian.net/jira/software/c/projects/MP/boards/138?config=cardLayout 71 | https://mycompany.atlassian.net/jira/software/c/projects/MP/boards/138?config=detailView 72 | https://mycompany.atlassian.net/jira/software/c/projects/MP/boards/138?config=roadmapConfig 73 | */ 74 | 75 | export const getCurrentRoute = () => { 76 | const { pathname, search } = window.location; 77 | const params = new URLSearchParams(search); 78 | 79 | if (pathname.includes('RapidView.jspa')) return Routes.SETTINGS; 80 | 81 | if (pathname.includes('RapidBoard.jspa')) { 82 | if (params.get('config')) return Routes.SETTINGS; 83 | if (params.get('view') === 'reporting') return Routes.REPORTS; 84 | 85 | return Routes.BOARD; 86 | } 87 | 88 | // cloud update 2021-09-30 89 | if (/boards\/(\d+)/im.test(pathname)) { 90 | if (params.get('config')) return Routes.SETTINGS; 91 | if (params.get('view') === 'reporting') return Routes.REPORTS; 92 | // https://{server}/jira/software/c/projects/{key}/boards/{id}/reports/control-chart?days=0 93 | if (/reports/im.test(pathname)) return Routes.REPORTS; 94 | 95 | return Routes.BOARD; 96 | } 97 | 98 | if (pathname.startsWith('/browse')) { 99 | return params.get('jql') ? Routes.SEARCH : Routes.ISSUE; 100 | } 101 | 102 | // https://server.atlassian.net/jira/software/c/projects/{KEY}/issues/?jql=... 103 | if (pathname.endsWith('/issues/')) return Routes.SEARCH; 104 | 105 | return null; 106 | }; 107 | 108 | export const getSettingsTab = () => { 109 | const search = new URLSearchParams(window.location.search); 110 | 111 | const tabFromUrl = search.get('tab') || search.get('config'); 112 | 113 | return tabFromUrl 114 | ? Promise.resolve(tabFromUrl) 115 | : waitForElement('.aui-nav-selected').promise.then(selectedNav => selectedNav.dataset.tabitem); 116 | }; 117 | 118 | export const getIssueId = () => { 119 | if (window.location.pathname.startsWith('/browse')) { 120 | return window.location.pathname.split('/')[2]; 121 | } 122 | 123 | if (getSearchParam('selectedIssue') && (getSearchParam('view') || getSearchParam('modal'))) 124 | return getSearchParam('selectedIssue'); 125 | 126 | return null; 127 | }; 128 | 129 | export const onUrlChange = cb => { 130 | extensionApiService.onMessage(message => { 131 | if (message.type === types.TAB_URL_CHANGE) { 132 | cb(message.url); 133 | } 134 | }); 135 | }; 136 | -------------------------------------------------------------------------------- /src/shared/ExtensionApiService.js: -------------------------------------------------------------------------------- 1 | class ExtensionApiService { 2 | constructor() { 3 | this.extensionAPI = window.chrome || window.browser; 4 | } 5 | 6 | getUrl(resource) { 7 | return this.extensionAPI.runtime.getURL(resource); 8 | } 9 | 10 | onMessage(cb) { 11 | return this.extensionAPI.runtime.onMessage.addListener(cb); 12 | } 13 | 14 | onTabsUpdated(cb) { 15 | return this.extensionAPI.tabs.onUpdated.addListener(cb); 16 | } 17 | 18 | sendMessageToTab(tabId, message) { 19 | return this.extensionAPI.tabs.sendMessage(tabId, message); 20 | } 21 | 22 | reload() { 23 | this.extensionAPI.runtime.reload(); 24 | } 25 | 26 | bgRequest(action) { 27 | return new Promise(resolve => { 28 | this.extensionAPI.runtime.sendMessage(action, response => { 29 | resolve(response); 30 | }); 31 | }); 32 | } 33 | 34 | updateStorageValue(key, value) { 35 | return new Promise(resolve => { 36 | this.extensionAPI.storage.local.set({ [key]: value }, () => resolve()); 37 | }); 38 | } 39 | 40 | fetchStorageValueByKey(key) { 41 | return new Promise((resolve, reject) => { 42 | this.extensionAPI.storage.local.get([key], result => 43 | result[key] ? resolve(result[key]) : reject(Error('Not found the key in the storage of chrome browser')) 44 | ); 45 | }); 46 | } 47 | } 48 | 49 | export const extensionApiService = new ExtensionApiService(); 50 | -------------------------------------------------------------------------------- /src/shared/PageModification.js: -------------------------------------------------------------------------------- 1 | import { getBoardIdFromURL, getSearchParam, getReportNameFromURL } from '../routing'; 2 | import { waitForElement } from './utils'; 3 | import { 4 | deleteBoardProperty, 5 | getBoardEditData, 6 | getBoardEstimationData, 7 | getBoardProperty, 8 | getBoardConfiguration, 9 | updateBoardProperty, 10 | searchIssues, 11 | } from './jiraApi'; 12 | 13 | export class PageModification { 14 | sideEffects = []; 15 | 16 | // life-cycle methods 17 | 18 | shouldApply() { 19 | return true; 20 | } 21 | 22 | getModificationId() { 23 | return null; 24 | } 25 | 26 | appendStyles() {} 27 | 28 | preloadData() { 29 | return Promise.resolve(); 30 | } 31 | 32 | waitForLoading() { 33 | return Promise.resolve(); 34 | } 35 | 36 | loadData() { 37 | return Promise.resolve(); 38 | } 39 | 40 | apply() {} 41 | 42 | clear() { 43 | this.sideEffects.forEach(se => se()); 44 | } 45 | 46 | // methods with side-effects 47 | 48 | waitForElement(selector, container) { 49 | const { promise, cancel } = waitForElement(selector, container); 50 | this.sideEffects.push(cancel); 51 | return promise; 52 | } 53 | 54 | getBoardProperty(property) { 55 | const { cancelRequest, abortPromise } = this.createAbortPromise(); 56 | this.sideEffects.push(cancelRequest); 57 | return getBoardProperty(getBoardIdFromURL(), property, { abortPromise }); 58 | } 59 | 60 | getBoardConfiguration() { 61 | const { cancelRequest, abortPromise } = this.createAbortPromise(); 62 | this.sideEffects.push(cancelRequest); 63 | return getBoardConfiguration(getBoardIdFromURL(), { abortPromise }); 64 | } 65 | 66 | updateBoardProperty(property, value) { 67 | const { cancelRequest, abortPromise } = this.createAbortPromise(); 68 | this.sideEffects.push(cancelRequest); 69 | return updateBoardProperty(getBoardIdFromURL(), property, value, { abortPromise }); 70 | } 71 | 72 | deleteBoardProperty(property) { 73 | const { cancelRequest, abortPromise } = this.createAbortPromise(); 74 | this.sideEffects.push(cancelRequest); 75 | return deleteBoardProperty(getBoardIdFromURL(), property, { abortPromise }); 76 | } 77 | 78 | getBoardEditData() { 79 | const { cancelRequest, abortPromise } = this.createAbortPromise(); 80 | this.sideEffects.push(cancelRequest); 81 | 82 | return getBoardEditData(getBoardIdFromURL(), { abortPromise }); 83 | } 84 | 85 | getBoardEstimationData() { 86 | const { cancelRequest, abortPromise } = this.createAbortPromise(); 87 | this.sideEffects.push(cancelRequest); 88 | 89 | return getBoardEstimationData(getBoardIdFromURL(), { abortPromise }); 90 | } 91 | 92 | searchIssues(jql, params = {}) { 93 | const { cancelRequest, abortPromise } = this.createAbortPromise(); 94 | this.sideEffects.push(cancelRequest); 95 | 96 | return searchIssues(jql, { ...params, abortPromise }); 97 | } 98 | 99 | createAbortPromise() { 100 | let cancelRequest; 101 | const abortPromise = new Promise(resolve => { 102 | cancelRequest = resolve; 103 | }); 104 | 105 | return { cancelRequest, abortPromise }; 106 | } 107 | 108 | setTimeout(func, time) { 109 | const timeoutID = setTimeout(func, time); 110 | this.sideEffects.push(() => clearTimeout(timeoutID)); 111 | return timeoutID; 112 | } 113 | 114 | addEventListener = (target, event, cb) => { 115 | target.addEventListener(event, cb); 116 | this.sideEffects.push(() => target.removeEventListener(event, cb)); 117 | }; 118 | 119 | onDOMChange(selector, cb, params = { childList: true }) { 120 | const element = document.querySelector(selector); 121 | if (!element) return; 122 | 123 | const observer = new MutationObserver(cb); 124 | observer.observe(element, params); 125 | this.sideEffects.push(() => observer.disconnect()); 126 | } 127 | 128 | onDOMChangeOnce(selectorOrElement, cb, params = { childList: true }) { 129 | const element = 130 | selectorOrElement instanceof HTMLElement ? selectorOrElement : document.querySelector(selectorOrElement); 131 | if (!element) return; 132 | 133 | const observer = new MutationObserver(() => { 134 | observer.disconnect(); 135 | cb(); 136 | }); 137 | observer.observe(element, params); 138 | this.sideEffects.push(() => observer.disconnect()); 139 | } 140 | 141 | insertHTML(container, position, html) { 142 | container.insertAdjacentHTML(position, html.trim()); 143 | 144 | let insertedElement; 145 | switch (position) { 146 | case 'beforebegin': 147 | insertedElement = container.previousElementSibling; 148 | break; 149 | case 'afterbegin': 150 | insertedElement = container.firstElementChild; 151 | break; 152 | case 'beforeend': 153 | insertedElement = container.lastElementChild; 154 | break; 155 | case 'afterend': 156 | insertedElement = container.nextElementSibling; 157 | break; 158 | default: 159 | throw Error('Wrong position'); 160 | } 161 | 162 | this.sideEffects.push(() => insertedElement.remove()); 163 | return insertedElement; 164 | } 165 | 166 | setDataAttr(element, attr, value) { 167 | element.dataset[attr] = value; 168 | this.sideEffects.push(() => { 169 | delete element.dataset[attr]; 170 | }); 171 | } 172 | 173 | // helpers 174 | getCssSelectorNotIssueSubTask(editData) { 175 | const constraintType = editData.rapidListConfig.currentStatisticsField?.typeId ?? ''; 176 | return constraintType === 'issueCountExclSubs' ? ':not(.ghx-issue-subtask)' : ''; 177 | } 178 | 179 | getCssSelectorOfIssues(editData) { 180 | const cssNotIssueSubTask = this.getCssSelectorNotIssueSubTask(editData); 181 | return `.ghx-issue${cssNotIssueSubTask}`; 182 | } 183 | 184 | getSearchParam(param) { 185 | return getSearchParam(param); 186 | } 187 | 188 | getReportNameFromURL() { 189 | return getReportNameFromURL(); 190 | } 191 | 192 | getBoardId() { 193 | return getBoardIdFromURL(); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/shared/colorPickerTooltip.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import ColorPicker from 'simple-color-picker'; 3 | import noop from '@tinkoff/utils/function/noop'; 4 | import { colorPickerTooltipTemplate } from './htmlTemplates'; 5 | import styles from './styles.css'; 6 | 7 | /* 8 | * Usage: 9 | * 1. Run html() which returns html-string and append to required block 10 | * 2. Run init() 11 | * */ 12 | export class ColorPickerTooltip { 13 | static ids = { 14 | colorPickerTooltip: 'jh-wip-limits-color-picker-tooltip', 15 | colorPicker: 'jh-color-picker-inner-tooltip', 16 | colorPickerResult: 'jh-color-picker-inner-tooltip-result', 17 | okBtn: 'jh-color-picker-ok-btn', 18 | closeBtn: 'jh-color-picker-cancel-btn', 19 | }; 20 | 21 | constructor({ onClose = noop, onOk = (/* hexStr */) => {}, addEventListener }) { 22 | this.colorPicker = new ColorPicker({ 23 | color: '#FF0000', 24 | background: '#454545', 25 | width: 200, 26 | height: 200, 27 | }); 28 | this.onClose = onClose; 29 | this.onOk = onOk; 30 | this.addEventListener = addEventListener; 31 | this.dataId = null; 32 | this.attrNameOfDataId = null; 33 | } 34 | 35 | html() { 36 | return colorPickerTooltipTemplate({ 37 | tooltipClass: styles.tooltip, 38 | id: ColorPickerTooltip.ids.colorPickerTooltip, 39 | colorPickerId: ColorPickerTooltip.ids.colorPicker, 40 | colorPickerResultId: ColorPickerTooltip.ids.colorPickerResult, 41 | btnWrpClass: styles.tooltipButtonsWrp, 42 | colorPickerResultClass: styles.tooltipResult, 43 | okBtnId: ColorPickerTooltip.ids.okBtn, 44 | closeBtnId: ColorPickerTooltip.ids.closeBtn, 45 | }); 46 | } 47 | 48 | init(hostElement, attrDataId) { 49 | this.hostElement = hostElement; 50 | this.attrNameOfDataId = attrDataId; 51 | 52 | if (!(this.hostElement instanceof HTMLElement)) { 53 | throw new Error('host element for colorpicker is not DOM element'); 54 | } 55 | if (!this.attrNameOfDataId) { 56 | throw new Error('attribute name of data id for colorpicker is empty'); 57 | } 58 | 59 | this.hostElement.insertAdjacentHTML('beforeend', this.html()); 60 | 61 | this.addEventListener(hostElement, 'scroll', () => { 62 | this.hideTooltip(); 63 | }); 64 | 65 | this.tooltip = document.getElementById(ColorPickerTooltip.ids.colorPickerTooltip); 66 | this.pickerResultElem = document.getElementById(ColorPickerTooltip.ids.colorPickerResult); 67 | 68 | this.colorPicker.appendTo(`#${ColorPickerTooltip.ids.colorPicker}`); 69 | this.colorPicker.onChange(hexColorString => { 70 | this.pickerResultElem.style.background = hexColorString; 71 | }); 72 | 73 | this._initBtnHandlers(); 74 | } 75 | 76 | get isVisible() { 77 | return this.tooltip.style.visibility !== 'hidden'; 78 | } 79 | 80 | hideTooltip() { 81 | if (this.isVisible) { 82 | this.tooltip.style.visibility = 'hidden'; 83 | this.colorPickerGroupId = null; 84 | } 85 | this.onClose(); 86 | } 87 | 88 | showTooltip({ target }) { 89 | if (!target.hasAttribute(this.attrNameOfDataId)) return; 90 | if (!this.tooltip) return; 91 | 92 | this.dataId = target.getAttribute(this.attrNameOfDataId); 93 | const position = this.getTooltipPosition(target); 94 | 95 | this.tooltip.style.visibility = 'visible'; 96 | this.tooltip.style.top = `${position}px`; 97 | } 98 | 99 | getTooltipPosition(target) { 100 | const tPosition = target.getBoundingClientRect(); 101 | const hPosition = this.hostElement.getBoundingClientRect(); 102 | return tPosition.top - hPosition.top; 103 | } 104 | 105 | _save = () => { 106 | this.onOk(this.colorPicker.getColor(), this.dataId); 107 | this.hideTooltip(); 108 | }; 109 | 110 | _cancel = () => { 111 | this.hideTooltip(); 112 | }; 113 | 114 | _initBtnHandlers() { 115 | const okBtn = document.getElementById(ColorPickerTooltip.ids.okBtn); 116 | const closeBtn = document.getElementById(ColorPickerTooltip.ids.closeBtn); 117 | 118 | this.addEventListener(okBtn, 'click', this._save); 119 | this.addEventListener(closeBtn, 'click', this._cancel); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/shared/constants.js: -------------------------------------------------------------------------------- 1 | export const BOARD_PROPERTIES = { 2 | WIP_LIMITS_SETTINGS: 'subgroupsJH', 3 | SWIMLANE_SETTINGS: 'jiraHelperSwimlaneSettings', 4 | OLD_SWIMLANE_SETTINGS: 'jiraHelperWIPLimits', 5 | SLA_CONFIG: 'slaConfig3', 6 | TETRIS_PLANNING: 'settingTetrisPlaning', 7 | PERSON_LIMITS: 'personLimitsSettings', 8 | FIELD_LIMITS: 'fieldLimitsJH', 9 | WIP_LIMITS_CELLS: 'wipLimitCells', 10 | }; 11 | 12 | export const COLORS = { 13 | OVER_WIP_LIMITS: '#ff5630', 14 | ON_THE_LIMIT: '#ffd700', 15 | BELOW_THE_LIMIT: '#1b855c', 16 | }; 17 | 18 | // TODO: Группа кнопок на странице с колонками используется в нескольких местах 19 | // Желательно придумать более лучшее решение для использования общих UI-элементов 20 | export const btnGroupIdForColumnsSettingsPage = 'jh-group-of-btns-setting-page'; 21 | -------------------------------------------------------------------------------- /src/shared/defaultHeaders.js: -------------------------------------------------------------------------------- 1 | export const defaultHeaders = defaultHeadersVal => ({ 2 | init(context, next) { 3 | const { headers = {} } = context.state.request; 4 | context.state.request.headers = { 5 | ...defaultHeadersVal, 6 | ...headers, 7 | }; 8 | next(); 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/shared/getPopup.js: -------------------------------------------------------------------------------- 1 | const noopWithCallback = cb => cb(); 2 | 3 | export class Popup { 4 | constructor({ 5 | title = '', 6 | initialContentInnerHTML = '', 7 | onCancel = noopWithCallback, 8 | onConfirm = noopWithCallback, 9 | okButtonText = 'Ok', 10 | size = 'medium', // large, medium, small 11 | }) { 12 | this.isOpened = false; 13 | 14 | this.initialProps = { 15 | title, 16 | initialContentInnerHTML, 17 | onCancel, 18 | onConfirm, 19 | okButtonText, 20 | size, 21 | }; 22 | 23 | this.popupIdentifiers = { 24 | wrapperId: 'jh-popup-wrapper', 25 | contentWrapperId: 'jh-popup-content', 26 | confirmBtnId: 'jh-popup-confirm-btn', 27 | cancelBtnId: 'jh-popup-cancel-btn', 28 | }; 29 | 30 | this.htmlElement = null; 31 | this.contentBlock = null; 32 | this.confirmBtn = null; 33 | this.cancelBtn = null; 34 | } 35 | 36 | onClose = () => { 37 | this.initialProps.onCancel(this.unmount); 38 | }; 39 | 40 | onOk = () => { 41 | this.initialProps.onConfirm(this.unmount); 42 | }; 43 | 44 | html() { 45 | return ` 57 | `; 58 | } 59 | 60 | attachButtonHandlers() { 61 | if (!this.confirmBtn || !this.cancelBtn) return; 62 | 63 | this.confirmBtn.addEventListener('click', this.onOk); 64 | this.cancelBtn.addEventListener('click', this.onClose); 65 | } 66 | 67 | deattachButtonHandlers() { 68 | if (!this.confirmBtn || !this.cancelBtn) return; 69 | 70 | this.confirmBtn.removeEventListener('click', this.onOk); 71 | this.cancelBtn.removeEventListener('click', this.onClose); 72 | } 73 | 74 | renderDarkBackground() { 75 | if (document.querySelector('.aui-blanket')) { 76 | document.querySelector('.aui-blanket').setAttribute('aria-hidden', 'false'); 77 | 78 | // На Jira v8.12.3 используется аттрибут hidden на бэкграунде 79 | document.querySelector('.aui-blanket').removeAttribute('hidden'); 80 | } else { 81 | document.body.insertAdjacentHTML('beforeend', '
'); 82 | } 83 | } 84 | 85 | removeDarkBackground() { 86 | document.querySelector('.aui-blanket').setAttribute('aria-hidden', 'true'); 87 | document.querySelector('.aui-blanket').setAttribute('hidden', true); 88 | } 89 | 90 | // PUBLIC METHODS 91 | 92 | render() { 93 | this.isOpened = true; 94 | document.body.insertAdjacentHTML('beforeend', this.html()); 95 | 96 | this.htmlElement = document.getElementById(this.popupIdentifiers.wrapperId); 97 | this.contentBlock = document.getElementById(this.popupIdentifiers.contentWrapperId); 98 | this.confirmBtn = document.getElementById(this.popupIdentifiers.confirmBtnId); 99 | this.cancelBtn = document.getElementById(this.popupIdentifiers.cancelBtnId); 100 | 101 | this.renderDarkBackground(); 102 | this.attachButtonHandlers(); 103 | } 104 | 105 | unmount = () => { 106 | if (this.htmlElement) { 107 | this.isOpened = false; 108 | 109 | this.deattachButtonHandlers(); 110 | this.removeDarkBackground(); 111 | 112 | this.htmlElement.remove(); 113 | } 114 | }; 115 | 116 | appendToContent(str = '') { 117 | this.contentBlock.insertAdjacentHTML('beforeend', str); 118 | } 119 | 120 | clearContent() { 121 | while (this.contentBlock.lastElementChild) { 122 | this.contentBlock.removeChild(this.contentBlock.lastElementChild); 123 | } 124 | } 125 | 126 | toggleConfirmAvailability(isAvailable) { 127 | if (!this.confirmBtn) return; 128 | 129 | if (isAvailable) this.confirmBtn.removeAttribute('disabled'); 130 | else this.confirmBtn.disabled = 'true'; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/shared/htmlTemplates.js: -------------------------------------------------------------------------------- 1 | export const colorPickerTooltipTemplate = ({ 2 | tooltipClass, 3 | id, 4 | colorPickerId, 5 | colorPickerResultId, 6 | okBtnId, 7 | closeBtnId, 8 | btnWrpClass, 9 | colorPickerResultClass, 10 | }) => ` 11 |
12 |
13 |
14 |
15 | 16 | 17 |
18 |
19 |
20 |
`; 21 | -------------------------------------------------------------------------------- /src/shared/jiraApi.js: -------------------------------------------------------------------------------- 1 | import request from '@tinkoff/request-core'; 2 | import transformUrl from '@tinkoff/request-plugin-transform-url'; 3 | import deduplicateCache from '@tinkoff/request-plugin-cache-deduplicate'; 4 | import memoryCache from '@tinkoff/request-plugin-cache-memory'; 5 | import http from '@tinkoff/request-plugin-protocol-http'; 6 | import compose from '@tinkoff/utils/function/compose'; 7 | import map from '@tinkoff/utils/array/map'; 8 | import prop from '@tinkoff/utils/object/prop'; 9 | import filter from '@tinkoff/utils/array/filter'; 10 | import complement from '@tinkoff/utils/function/complement'; 11 | import isNil from '@tinkoff/utils/is/nil'; 12 | import path from '@tinkoff/utils/object/path'; 13 | import pathOr from '@tinkoff/utils/object/pathOr'; 14 | import { defaultHeaders } from './defaultHeaders'; 15 | import { getBoardIdFromURL } from '../routing'; 16 | 17 | export const configVersion = 'v1'; 18 | const getPropName = property => `${property}${configVersion}`; 19 | 20 | const boardPropertiesUrl = boardId => `agile/1.0/board/${boardId}/properties`; 21 | const boardConfigurationURL = boardId => `agile/1.0/board/${boardId}/configuration`; 22 | const boardEditDataURL = 'greenhopper/1.0/rapidviewconfig/editmodel.json?rapidViewId='; 23 | const boardEstimationDataURL = 'greenhopper/1.0/rapidviewconfig/estimation.json?rapidViewId='; 24 | 25 | const invalidatedProperties = {}; 26 | 27 | const requestJira = request([ 28 | defaultHeaders({ 29 | 'browser-plugin': `jira-helper/${process.env.PACKAGE_VERSION}`, 30 | }), 31 | transformUrl({ 32 | baseUrl: `${window.location.origin}/rest/`, 33 | }), 34 | deduplicateCache(), 35 | memoryCache({ allowStale: true }), 36 | http(), 37 | ]); 38 | 39 | const getBoardProperties = boardId => { 40 | const cacheKey = `${boardId}_propertiesList`; 41 | const memoryCacheForce = invalidatedProperties[cacheKey] != null; 42 | delete invalidatedProperties[cacheKey]; 43 | 44 | return requestJira({ 45 | url: boardPropertiesUrl(getBoardIdFromURL()), 46 | memoryCacheForce, 47 | type: 'json', 48 | }); 49 | }; 50 | 51 | export const getBoardProperty = async (boardId, property, params = {}) => { 52 | const boardProps = await getBoardProperties(boardId); 53 | if (!boardProps.keys.find(boardProp => boardProp.key === getPropName(property))) return undefined; 54 | 55 | const cacheKey = `${boardId}_${property}`; 56 | const memoryCacheForce = invalidatedProperties[cacheKey] != null; 57 | delete invalidatedProperties[cacheKey]; 58 | 59 | return requestJira({ 60 | url: `${boardPropertiesUrl(boardId)}/${getPropName(property)}`, 61 | memoryCacheForce, 62 | type: 'json', 63 | ...params, 64 | }).then(result => result.value); 65 | }; 66 | 67 | export const updateBoardProperty = (boardId, property, value, params = {}) => { 68 | const cacheKey = `${boardId}_${property}`; 69 | invalidatedProperties[cacheKey] = true; 70 | invalidatedProperties[`${boardId}_propertiesList`] = true; 71 | 72 | requestJira({ 73 | url: `${boardPropertiesUrl(boardId)}/${getPropName(property)}`, 74 | httpMethod: 'PUT', 75 | type: 'json', 76 | payload: value, 77 | ...params, 78 | }); 79 | }; 80 | 81 | export const deleteBoardProperty = (boardId, property, params = {}) => { 82 | const cacheKey = `${boardId}_${property}`; 83 | invalidatedProperties[cacheKey] = true; 84 | invalidatedProperties[`${boardId}_propertiesList`] = true; 85 | 86 | requestJira({ 87 | url: `${boardPropertiesUrl(boardId)}/${getPropName(property)}`, 88 | httpMethod: 'DELETE', 89 | type: 'json', 90 | ...params, 91 | }); 92 | }; 93 | 94 | export const getBoardEditData = (boardId, params = {}) => { 95 | return requestJira({ 96 | url: `${boardEditDataURL}${boardId}`, 97 | type: 'json', 98 | ...params, 99 | }); 100 | }; 101 | 102 | export const getBoardConfiguration = async (boardId, params = {}) => { 103 | return requestJira({ 104 | url: boardConfigurationURL(boardId), 105 | type: 'json', 106 | ...params, 107 | }); 108 | }; 109 | 110 | export const getBoardEstimationData = (boardId, params = {}) => { 111 | return requestJira({ 112 | url: `${boardEstimationDataURL}${boardId}`, 113 | type: 'json', 114 | ...params, 115 | }); 116 | }; 117 | 118 | export const searchIssues = (jql, params = {}) => 119 | requestJira({ 120 | url: `api/2/search?jql=${jql}`, 121 | type: 'json', 122 | ...params, 123 | }); 124 | 125 | export const loadNewIssueViewEnabled = (params = {}) => 126 | requestJira({ 127 | url: 'greenhopper/1.0/profile/labs-panel/issue-details-popup', 128 | type: 'json', 129 | ...params, 130 | }).then( 131 | res => res.isEnabled, 132 | () => false 133 | ); 134 | 135 | export const getAllFields = () => 136 | requestJira({ 137 | url: 'api/2/field', 138 | type: 'json', 139 | }); 140 | 141 | export const getFlaggedField = async () => 142 | getAllFields().then(fields => fields.find(field => field.name === 'Flagged').id); 143 | 144 | const getFlaggedIssues = flagField => 145 | compose(map(prop('key')), filter(compose(complement(isNil), path(['fields', flagField]))), pathOr(['issues'], [])); 146 | 147 | export const loadFlaggedIssues = keys => { 148 | return getFlaggedField().then(flagField => 149 | searchIssues(`key in (${keys.join(',')})&fields=${flagField}`).then(getFlaggedIssues(flagField)) 150 | ); 151 | }; 152 | 153 | export const getUser = query => 154 | Promise.allSettled([ 155 | requestJira({ 156 | url: 'api/2/user/search', 157 | query: { query }, 158 | type: 'json', 159 | }), 160 | requestJira({ 161 | url: 'api/2/user/search', 162 | query: { username: query }, 163 | type: 'json', 164 | }), 165 | ]) 166 | .then(([res1, res2]) => { 167 | if (res1.status === 'fulfilled') return res1.value; 168 | if (res2.status === 'fulfilled') return res2.value; 169 | }) 170 | .then(users => { 171 | if (!query) return users[0]; 172 | 173 | const exactMatch = users.find(user => user.name === query || user.displayName === query); 174 | if (exactMatch) return exactMatch; 175 | 176 | const substringMatch = users.find(user => user.name?.includes(query) || user.displayName?.includes(query)); 177 | if (substringMatch) return substringMatch; 178 | 179 | return users[0]; 180 | }); 181 | -------------------------------------------------------------------------------- /src/shared/runModifications.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-continue */ 2 | import { getCurrentRoute, onUrlChange, Routes } from '../routing'; 3 | 4 | const currentModifications = new Map(); 5 | let route; 6 | 7 | const applyModification = async (Modification, modificationInstance) => { 8 | const id = modificationInstance.getModificationId(); 9 | currentModifications.set(Modification, { id, instance: modificationInstance }); 10 | 11 | await modificationInstance.preloadData(); 12 | 13 | const loadingPromise = modificationInstance.waitForLoading(); 14 | const dataPromise = modificationInstance.loadData(); 15 | 16 | const [loadingElement, data] = await Promise.all([loadingPromise, dataPromise]); 17 | 18 | // if (loadingElement.dataset[Modification.name]) return; 19 | // loadingElement.dataset[Modification.name] = true; 20 | 21 | const styles = modificationInstance.appendStyles(); 22 | if (styles) document.body.insertAdjacentHTML('beforeend', styles); 23 | modificationInstance.apply(data, loadingElement); 24 | }; 25 | 26 | const applyModifications = modificationsMap => { 27 | const currentRoute = getCurrentRoute(); 28 | 29 | if (route !== currentRoute) { 30 | route = currentRoute; 31 | } 32 | 33 | const modificationsForRoute = new Set(modificationsMap[Routes.ALL].concat(modificationsMap[route] || [])); 34 | for (const Modification of currentModifications.keys()) { 35 | if (!modificationsForRoute.has(Modification)) { 36 | currentModifications.get(Modification).instance.clear(); 37 | currentModifications.delete(Modification); 38 | } 39 | } 40 | 41 | for (const Modification of modificationsForRoute) { 42 | const modificationInstance = new Modification(); 43 | 44 | Promise.resolve(modificationInstance.shouldApply()).then(shouldApply => { 45 | if (currentModifications.has(Modification)) { 46 | const { id: currentModificationId, instance: currentInstance } = currentModifications.get(Modification); 47 | 48 | if (!shouldApply) { 49 | currentInstance.clear(); 50 | currentModifications.delete(Modification); 51 | return; 52 | } 53 | 54 | if (currentModificationId !== modificationInstance.getModificationId()) { 55 | currentInstance.clear(); 56 | currentModifications.delete(Modification); 57 | } else { 58 | return; 59 | } 60 | } 61 | 62 | if (shouldApply) { 63 | applyModification(Modification, modificationInstance); 64 | } 65 | }); 66 | } 67 | }; 68 | 69 | export default modificationsMap => { 70 | applyModifications(modificationsMap); 71 | onUrlChange(() => applyModifications(modificationsMap)); 72 | }; 73 | -------------------------------------------------------------------------------- /src/shared/styles.css: -------------------------------------------------------------------------------- 1 | .tooltip { 2 | position: absolute; 3 | background: white; 4 | visibility: hidden; 5 | } 6 | 7 | .tooltipButtonsWrp { 8 | display: flex; 9 | justify-content: space-between; 10 | } 11 | 12 | .tooltipButtonsWrp button~button { 13 | margin-left: 0 !important; 14 | } 15 | 16 | .tooltipResult { 17 | flex: 0.7; 18 | background: red; 19 | } 20 | 21 | .ghx-flags { 22 | border-radius: 2px 2px !important; 23 | background: yellow !important; 24 | } -------------------------------------------------------------------------------- /src/shared/trackChanges.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Отслеживание изменений в указанном скрипте, см AutoRefreshPlugin 3 | * */ 4 | export default (eventType, cb) => { 5 | let ws; 6 | let reconnectTimeout = 1; 7 | let reconnectScheduled; 8 | 9 | const onMessage = message => { 10 | const [event, hasCompilationErrors] = message.data.split('|'); 11 | if (eventType === event) { 12 | if (hasCompilationErrors === 'true') { 13 | console.error('Скрипт не перезагружен из-за ошибки компиляции'); // eslint-disable-line 14 | } else { 15 | cb(); 16 | } 17 | } 18 | }; 19 | 20 | const onOpen = () => { 21 | reconnectTimeout = 1; 22 | 23 | if (reconnectScheduled) { 24 | clearTimeout(reconnectScheduled); 25 | reconnectScheduled = null; 26 | } 27 | }; 28 | 29 | const reconnect = () => { 30 | if (reconnectScheduled) { 31 | return; 32 | } 33 | 34 | if (reconnectTimeout < 15) { 35 | reconnectTimeout += 1; 36 | } 37 | 38 | reconnectScheduled = setTimeout(() => { 39 | reconnectScheduled = null; 40 | // eslint-disable-next-line no-use-before-define 41 | connectToWs(); 42 | }, reconnectTimeout * 1000); 43 | }; 44 | 45 | const connectToWs = () => { 46 | if (ws) { 47 | ws.removeEventListener('message', onMessage); 48 | ws.removeEventListener('open', onOpen); 49 | ws.removeEventListener('error', reconnect); 50 | ws.removeEventListener('close', reconnect); 51 | 52 | ws.close(); 53 | } 54 | 55 | ws = new WebSocket('ws://localhost:8899'); 56 | ws.addEventListener('message', onMessage); 57 | ws.addEventListener('open', onOpen); 58 | ws.addEventListener('error', reconnect); 59 | ws.addEventListener('close', reconnect); 60 | }; 61 | 62 | connectToWs(); 63 | }; 64 | -------------------------------------------------------------------------------- /src/shared/utils.js: -------------------------------------------------------------------------------- 1 | export function toPx(...args) { 2 | const sum = args.reduce((acc, i) => acc + i, 0); 3 | return `${sum}px`; 4 | } 5 | 6 | export const getRandomString = length => 7 | Math.random() 8 | .toString(36) 9 | .substring(length); 10 | 11 | export const isJira = document.body.id === 'jira'; 12 | 13 | export const waitForElement = (selector, container = document) => { 14 | let intervalId; 15 | const promise = new Promise(resolve => { 16 | intervalId = setInterval(() => { 17 | if (container.querySelector(selector)) { 18 | clearInterval(intervalId); 19 | resolve(container.querySelector(selector)); 20 | } 21 | }, 100); 22 | }); 23 | 24 | return { 25 | promise, 26 | cancel: () => clearInterval(intervalId), 27 | }; 28 | }; 29 | 30 | export const formatTemplateForInserting = str => { 31 | return `'${str.replace(/\r?\n?/g, '').trim()}'`; 32 | }; 33 | -------------------------------------------------------------------------------- /src/swimlane/SwimlaneLimits.js: -------------------------------------------------------------------------------- 1 | import each from '@tinkoff/utils/array/each'; 2 | import filter from '@tinkoff/utils/array/filter'; 3 | import { PageModification } from '../shared/PageModification'; 4 | import { settingsJiraDOM as DOM } from './constants'; 5 | import { BOARD_PROPERTIES } from '../shared/constants'; 6 | import style from './styles.css'; 7 | import { mergeSwimlaneSettings } from './utils'; 8 | 9 | export default class extends PageModification { 10 | shouldApply() { 11 | const view = this.getSearchParam('view'); 12 | return !view || view === 'detail'; 13 | } 14 | 15 | getModificationId() { 16 | return `add-swimlane-limits-${this.getBoardId()}`; 17 | } 18 | 19 | appendStyles() { 20 | return ` 21 | 26 | `; 27 | } 28 | 29 | waitForLoading() { 30 | return this.waitForElement(DOM.swimlane); 31 | } 32 | 33 | loadData() { 34 | return Promise.all([ 35 | this.getBoardProperty(BOARD_PROPERTIES.SWIMLANE_SETTINGS), 36 | this.getBoardProperty(BOARD_PROPERTIES.OLD_SWIMLANE_SETTINGS), 37 | ]).then(mergeSwimlaneSettings); 38 | } 39 | 40 | apply(settings) { 41 | if (!settings) return; 42 | 43 | this.renderLimits(settings); 44 | this.onDOMChange('#ghx-pool', () => this.renderLimits(settings)); 45 | } 46 | 47 | renderLimits(settings) { 48 | const swimlanesIssuesCount = {}; 49 | each(swimlane => { 50 | const swimlaneId = swimlane.getAttribute('swimlane-id'); 51 | if (!settings[swimlaneId] || !settings[swimlaneId].limit) return; 52 | 53 | const { limit } = settings[swimlaneId]; 54 | 55 | const swimlaneHeader = swimlane.querySelector(DOM.swimlaneHeader); 56 | const swimlaneColumns = Array.from(swimlane.getElementsByClassName('ghx-columns')[0].childNodes || []); 57 | 58 | const numberIssues = swimlaneColumns.reduce( 59 | (acc, column) => 60 | acc + 61 | filter( 62 | issue => !issue.classList.contains('ghx-done') && !issue.classList.contains('ghx-issue-subtask'), 63 | column.querySelectorAll('.ghx-issue') 64 | ).length, 65 | 0 66 | ); 67 | 68 | swimlanesIssuesCount[swimlaneId] = numberIssues; 69 | 70 | const swimlaneDescription = swimlane.querySelector('.ghx-description'); 71 | const innerSwimlaneHeader = swimlane.querySelector('.ghx-swimlane-header'); 72 | 73 | if (numberIssues > limit) { 74 | swimlane.style.backgroundColor = '#ff5630'; 75 | swimlaneDescription.style.color = '#ffd700'; 76 | 77 | // Some JIRA-versions has white backgroundColor on swimlane header, f.e. v8.8.1 78 | innerSwimlaneHeader.style.backgroundColor = '#ff5630'; 79 | } 80 | 81 | this.renderSwimlaneHeaderLimit(numberIssues, limit, swimlaneHeader); 82 | }, document.querySelectorAll(DOM.swimlane)); 83 | 84 | const stalker = document.querySelector('#ghx-swimlane-header-stalker'); 85 | if (stalker && stalker.firstElementChild) { 86 | const swimlaneId = stalker.firstElementChild.getAttribute('data-swimlane-id'); 87 | if (!swimlaneId || !swimlanesIssuesCount[swimlaneId]) return; 88 | 89 | const swimlaneHeader = stalker.querySelector(DOM.swimlaneHeader); 90 | this.renderSwimlaneHeaderLimit(swimlanesIssuesCount[swimlaneId], settings[swimlaneId].limit, swimlaneHeader); 91 | } 92 | } 93 | 94 | renderSwimlaneHeaderLimit(numberIssues, limit, swimlaneHeader) { 95 | // Здесь по порядку определяется title, потому что у него нет селектора 96 | const swimlaneTitle = swimlaneHeader.querySelector('*:nth-child(2)'); 97 | if (swimlaneTitle.classList.contains(style.limitBadge)) return; 98 | 99 | const badge = ` 100 | ${numberIssues}/${limit}Issues / Max. issues 101 | `; 102 | 103 | this.insertHTML(swimlaneTitle, 'beforebegin', badge); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/swimlane/SwimlaneSettingsPopup.js: -------------------------------------------------------------------------------- 1 | import { PageModification } from '../shared/PageModification'; 2 | import { getSettingsTab } from '../routing'; 3 | import { BOARD_PROPERTIES } from '../shared/constants'; 4 | import { mergeSwimlaneSettings } from './utils'; 5 | import { Popup } from '../shared/getPopup'; 6 | import { settingsEditBtnTemplate, settingsPopupTableRowTemplate, settingsPopupTableTemplate } from './constants'; 7 | 8 | export default class SwimlaneSettingsLimit extends PageModification { 9 | static ids = { 10 | editLimitsBtn: 'edit-limits-btn-jh', 11 | editTable: 'edit-table-jh', 12 | }; 13 | 14 | static classes = { 15 | editSwimlaneRow: 'edit-swimlane-row-jh', 16 | }; 17 | 18 | static jiraSelectors = { 19 | swimlanes: '#swimlanes', 20 | swimlaneConfig: '#ghx-swimlane-strategy-config', 21 | swimlaneSelect: '#ghx-swimlanestrategy-select', 22 | }; 23 | 24 | async shouldApply() { 25 | return (await getSettingsTab()) === 'swimlanes'; 26 | } 27 | 28 | getModificationId() { 29 | return `add-swimlane-settings-${this.getBoardId()}`; 30 | } 31 | 32 | waitForLoading() { 33 | return Promise.all([ 34 | this.waitForElement(SwimlaneSettingsLimit.jiraSelectors.swimlanes), 35 | this.waitForElement(SwimlaneSettingsLimit.jiraSelectors.swimlaneSelect), 36 | ]); 37 | } 38 | 39 | loadData() { 40 | return Promise.all([ 41 | this.getBoardEditData(), 42 | Promise.all([ 43 | this.getBoardProperty(BOARD_PROPERTIES.SWIMLANE_SETTINGS), 44 | this.getBoardProperty(BOARD_PROPERTIES.OLD_SWIMLANE_SETTINGS), 45 | ]).then(mergeSwimlaneSettings), 46 | ]); 47 | } 48 | 49 | apply([boardData, settings]) { 50 | this.settings = settings; 51 | this.boardData = boardData; 52 | 53 | if (!(boardData && boardData.canEdit)) return; 54 | 55 | this.swimlaneSelect = document.querySelector(SwimlaneSettingsLimit.jiraSelectors.swimlaneSelect); 56 | if (this.swimlaneSelect.value === 'custom') { 57 | this.renderEditButton(); 58 | } 59 | 60 | this.addEventListener(this.swimlaneSelect, 'change', event => { 61 | if (event.target.value === 'custom') this.renderEditButton(); 62 | else this.removeEditBtn(); 63 | }); 64 | } 65 | 66 | renderEditButton() { 67 | this.insertHTML( 68 | document.querySelector(SwimlaneSettingsLimit.jiraSelectors.swimlaneConfig), 69 | 'beforebegin', 70 | settingsEditBtnTemplate(SwimlaneSettingsLimit.ids.editLimitsBtn) 71 | ); 72 | 73 | this.popup = new Popup({ 74 | title: 'Edit swimlane limits', 75 | onConfirm: this.handleConfirmEditing, 76 | okButtonText: 'Save', 77 | }); 78 | 79 | this.editBtn = document.getElementById(SwimlaneSettingsLimit.ids.editLimitsBtn); 80 | this.addEventListener(this.editBtn, 'click', this.handleEditClick); 81 | } 82 | 83 | handleEditClick = () => { 84 | this.popup.render(); 85 | this.popup.appendToContent( 86 | settingsPopupTableTemplate( 87 | SwimlaneSettingsLimit.ids.editTable, 88 | this.boardData.swimlanesConfig.swimlanes 89 | .map(item => 90 | settingsPopupTableRowTemplate({ 91 | id: item.id, 92 | name: item.name, 93 | limit: this.settings[item.id] ? this.settings[item.id].limit : 0, 94 | isIgnored: this.settings[item.id] ? this.settings[item.id].ignoreWipInColumns : false, 95 | rowClass: SwimlaneSettingsLimit.classes.editSwimlaneRow, 96 | }) 97 | ) 98 | .join('') 99 | ) 100 | ); 101 | }; 102 | 103 | removeEditBtn() { 104 | this.editBtn.remove(); 105 | } 106 | 107 | handleConfirmEditing = unmountCallback => { 108 | const rows = document.querySelectorAll( 109 | `#${SwimlaneSettingsLimit.ids.editTable} .${SwimlaneSettingsLimit.classes.editSwimlaneRow}` 110 | ); 111 | const updatedSettings = {}; 112 | 113 | rows.forEach(row => { 114 | const { value: rawLimitValue } = row.querySelector('input[type="number"]'); 115 | const { checked: isExpediteValue } = row.querySelector('input[type="checkbox"]'); 116 | 117 | const swimlaneId = row.getAttribute('data-swimlane-id'); 118 | const limitValue = Number.parseInt(rawLimitValue, 10); 119 | 120 | updatedSettings[swimlaneId] = { 121 | limit: limitValue < 1 ? undefined : limitValue, 122 | ignoreWipInColumns: isExpediteValue, 123 | }; 124 | }); 125 | 126 | this.settings = updatedSettings; 127 | this.updateBoardProperty(BOARD_PROPERTIES.SWIMLANE_SETTINGS, updatedSettings); 128 | unmountCallback(); 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /src/swimlane/SwimlaneStats.js: -------------------------------------------------------------------------------- 1 | import map from '@tinkoff/utils/array/map'; 2 | import each from '@tinkoff/utils/array/each'; 3 | import { PageModification } from '../shared/PageModification'; 4 | import { toPx } from '../shared/utils'; 5 | import style from './styles.css'; 6 | 7 | export default class extends PageModification { 8 | shouldApply() { 9 | const view = this.getSearchParam('view'); 10 | return !view || view === 'detail'; 11 | } 12 | 13 | getModificationId() { 14 | return `add-swimlane-stats-${this.getBoardId()}`; 15 | } 16 | 17 | waitForLoading() { 18 | return this.waitForElement('.ghx-swimlane'); 19 | } 20 | 21 | loadData() { 22 | return this.getBoardEditData(); 23 | } 24 | 25 | apply(editData) { 26 | this.cssSelectorOfIssues = this.getCssSelectorOfIssues(editData); 27 | this.calcSwimlaneStatsAndRender(); 28 | this.onDOMChange('#ghx-pool', this.calcSwimlaneStatsAndRender); 29 | } 30 | 31 | calcSwimlaneStatsAndRender = () => { 32 | const headers = map( 33 | i => i.innerText, 34 | document.querySelectorAll('.ghx-column-title, #ghx-column-headers .ghx-column h2') 35 | ); 36 | 37 | const swimlanesStats = {}; 38 | 39 | each(sw => { 40 | const header = sw.getElementsByClassName('ghx-swimlane-header')[0]; 41 | 42 | if (!header) return; 43 | 44 | const list = sw.getElementsByClassName('ghx-columns')[0].childNodes; 45 | let numberIssues = 0; 46 | const arrNumberIssues = []; 47 | 48 | list.forEach(column => { 49 | const tasks = column.querySelectorAll(this.cssSelectorOfIssues); 50 | arrNumberIssues.push(tasks.length); 51 | numberIssues += tasks.length; 52 | }); 53 | 54 | swimlanesStats[sw.getAttribute('swimlane-id')] = { numberIssues, arrNumberIssues }; 55 | this.renderSwimlaneStats(header, headers, numberIssues, arrNumberIssues); 56 | }, document.querySelectorAll('.ghx-swimlane')); 57 | 58 | const stalker = document.querySelector('#ghx-swimlane-header-stalker'); 59 | if (stalker && stalker.firstElementChild) { 60 | const swimlaneId = stalker.firstElementChild.getAttribute('data-swimlane-id'); 61 | if (!swimlaneId || !swimlanesStats[swimlaneId]) return; 62 | 63 | const header = stalker.querySelector('.ghx-swimlane-header'); 64 | const { numberIssues, arrNumberIssues } = swimlanesStats[swimlaneId]; 65 | 66 | this.renderSwimlaneStats(header, headers, numberIssues, arrNumberIssues); 67 | } 68 | }; 69 | 70 | renderSwimlaneStats(header, headers, numberIssues, arrNumberIssues) { 71 | const stats = ` 72 |
73 | ${arrNumberIssues 74 | .map((currentNumberIssues, index) => { 75 | const title = `${headers[index]}: ${currentNumberIssues}`; 76 | 77 | return ` 78 |
79 |
82 |
83 | `; 84 | }) 85 | .join('')} 86 |
87 | `; 88 | 89 | header.classList.add(style.header); 90 | this.insertHTML(header, 'afterbegin', stats); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/swimlane/constants.js: -------------------------------------------------------------------------------- 1 | // settings 2 | export const settingsJiraDOM = { 3 | table: '#ghx-swimlane-table', 4 | sortableTbody: 'tbody.ui-sortable', 5 | createTbody: 'tbody.aui-restfultable-create', 6 | tableHead: 'thead', 7 | row: 'tr', 8 | cell: 'td', 9 | headCell: 'th', 10 | everythingElseRow: 'ghx-default-swimlane', 11 | swimlane: '.ghx-swimlane', 12 | swimlaneHeaderContainer: '.ghx-swimlane-header', 13 | swimlaneHeader: '.ghx-heading', 14 | }; 15 | 16 | export const settingsEditBtnTemplate = btnId => `
17 | 18 |
`; 19 | 20 | export const settingsPopupTableTemplate = (tableId, tableBody) => ` 21 |
22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | ${tableBody} 35 | 36 |
SwimlaneWIP limits 28 | Is expedite 29 | 30 |
37 |
38 | `; 39 | 40 | export const settingsPopupTableRowTemplate = ({ id, name, limit, isIgnored, rowClass }) => 41 | ` 42 | ${name} 43 | 44 | 45 | 46 | 47 | 48 | 49 | `; 50 | -------------------------------------------------------------------------------- /src/swimlane/styles.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | margin: 10px 2px; 3 | display: flex; 4 | background: #ccc; 5 | } 6 | 7 | .header { 8 | display: flex; 9 | } 10 | 11 | .column { 12 | position: relative; 13 | display: inline-block; 14 | width: 12px; 15 | border: none; 16 | height: 20px; 17 | margin: auto 1px; 18 | } 19 | 20 | .bar { 21 | background: #66CCFF; 22 | position: absolute; 23 | bottom: 0px; 24 | width: 12px; 25 | border: none; 26 | } 27 | 28 | .limitBadge { 29 | padding: 2px 4px; 30 | border-radius: 5px; 31 | font-size: 0.8rem; 32 | margin: 0 10px; 33 | color: white !important; 34 | background-color: #1b855c; 35 | } 36 | 37 | 38 | .loader { 39 | display: block !important; 40 | margin: 0; 41 | font-size: 5px; 42 | position: relative; 43 | text-indent: -9999em; 44 | /*border-top: 2px solid mix(white,black,80%);*/ 45 | /*border-right: 2px solid mix(white,black,80%);*/ 46 | /*border-bottom: 2px solid mix(white,black,80%);*/ 47 | border-left: 2px solid #344563; 48 | -webkit-animation: load8 .5s infinite linear; 49 | animation: load8 .5s infinite linear; 50 | } 51 | .loader, 52 | .loader:after { 53 | border-radius: 50%; 54 | width:17px; 55 | height: 17px; 56 | } 57 | @-webkit-keyframes load8 { 58 | 0% { 59 | -webkit-transform: rotate(0deg); 60 | transform: rotate(0deg); 61 | } 62 | 100% { 63 | -webkit-transform: rotate(360deg); 64 | transform: rotate(360deg); 65 | } 66 | } 67 | @keyframes load8 { 68 | 0% { 69 | -webkit-transform: rotate(0deg); 70 | transform: rotate(0deg); 71 | } 72 | 100% { 73 | -webkit-transform: rotate(360deg); 74 | transform: rotate(360deg); 75 | } 76 | } 77 | 78 | .limitBadge__hint { 79 | visibility: hidden; 80 | position: absolute; 81 | padding: 2px 3px; 82 | margin-left: -171px; 83 | margin-top: -10px; 84 | width: 130px; 85 | transition: 0s visibility; 86 | cursor: pointer; 87 | } 88 | 89 | .limitBadge:hover .limitBadge__hint { 90 | visibility: visible; 91 | display: inline; 92 | color: black; 93 | padding: 10px; 94 | background-color: white; 95 | border-radius: 5px; 96 | box-shadow: 0 0 5px rgba(0,0,0,0.5); /* Параметры тени */ 97 | z-index: 100000; 98 | transition-delay: 200ms; 99 | } 100 | -------------------------------------------------------------------------------- /src/swimlane/utils.js: -------------------------------------------------------------------------------- 1 | export const mergeSwimlaneSettings = ([settings, oldLimits]) => { 2 | if (settings) return settings; 3 | 4 | const convertedSettings = {}; 5 | 6 | if (oldLimits) { 7 | Object.keys(oldLimits).forEach(swimlaneId => { 8 | convertedSettings[swimlaneId] = { 9 | limit: oldLimits[swimlaneId], 10 | }; 11 | }); 12 | } 13 | 14 | return convertedSettings; 15 | }; 16 | -------------------------------------------------------------------------------- /src/tetris-planning/TetrisPlanning.js: -------------------------------------------------------------------------------- 1 | import { PageModification } from '../shared/PageModification'; 2 | import { BOARD_PROPERTIES } from '../shared/constants'; 3 | 4 | export default class extends PageModification { 5 | shouldApply() { 6 | const view = this.getSearchParam('view') || ''; 7 | return view.startsWith('planning'); 8 | } 9 | 10 | getModificationId() { 11 | return `tetris-planning-${this.getBoardId()}`; 12 | } 13 | 14 | waitForLoading() { 15 | return this.waitForElement('.ghx-backlog-container.js-sprint-container'); 16 | } 17 | 18 | loadData() { 19 | return this.getBoardProperty(BOARD_PROPERTIES.TETRIS_PLANNING); 20 | } 21 | 22 | apply(fields = []) { 23 | const sprintContainers = document.querySelectorAll('.ghx-backlog-container.js-sprint-container'); 24 | sprintContainers.forEach(spContainer => this.addStatsToSprint(spContainer, fields)); 25 | } 26 | 27 | addStatsToSprint = (spContainer, fields) => { 28 | const { sprintId } = spContainer.dataset; 29 | 30 | if (!sprintId || sprintId === '-1') return; 31 | 32 | const issueCountContainer = spContainer.querySelectorAll('.ghx-backlog-header .ghx-issue-count')[0]; 33 | const jql = `sprint = ${sprintId} AND issuetype not in ( "Sub-Task", Epic)`; 34 | 35 | this.searchIssues(decodeURI(jql), { memoryCache: false }) 36 | .then(data => (data.issues && data.issues.length > 0 ? data.issues : [])) 37 | .then(issues => 38 | issues.reduce((acc, i) => { 39 | return acc.map(f => { 40 | return { 41 | ...f, 42 | value: (f.value || 0) + (Number(i.fields[f.id]) || 0), 43 | }; 44 | }); 45 | }, fields) 46 | ) 47 | .then(fieldsData => { 48 | spContainer.querySelectorAll('.sp-value-limitation').forEach(e => e.parentNode.removeChild(e)); 49 | 50 | fieldsData.forEach(f => { 51 | const prefix = 'sp-value-limitation active-sprint-lozenge aui-lozenge aui-lozenge-'; 52 | const el = document.createElement('span'); 53 | const val = f.value / (!f.divider || f.divider === 0 ? 1 : f.divider); 54 | 55 | el.className = prefix + (val <= f.max ? 'success' : 'error'); 56 | el.style.marginRight = '5px'; 57 | el.innerText = `${f.name}: ${val}/${f.max}`; 58 | this.setTimeout(() => issueCountContainer.appendChild(el), 10); 59 | }); 60 | }); 61 | 62 | this.onDOMChangeOnce(spContainer, () => { 63 | this.setTimeout(() => this.addStatsToSprint(spContainer, fields)); // need timeout for api to get ready 64 | }); 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/tetris-planning/TetrisPlanningButton.js: -------------------------------------------------------------------------------- 1 | import map from '@tinkoff/utils/array/map'; 2 | import template from './template.html'; 3 | import { PageModification } from '../shared/PageModification'; 4 | import { getSettingsTab } from '../routing'; 5 | import { formatTemplateForInserting } from '../shared/utils'; 6 | import { extensionApiService } from '../shared/ExtensionApiService'; 7 | import { BOARD_PROPERTIES } from '../shared/constants'; 8 | 9 | export default class extends PageModification { 10 | async shouldApply() { 11 | return (await getSettingsTab()) === 'estimation'; 12 | } 13 | 14 | getModificationId() { 15 | return `add-tetris-button-${this.getBoardId()}`; 16 | } 17 | 18 | waitForLoading() { 19 | return this.waitForElement('#estimation'); 20 | } 21 | 22 | loadData() { 23 | return this.getBoardEditData(); 24 | } 25 | 26 | apply(boardData, element) { 27 | if (!boardData.canEdit) return; 28 | 29 | this.insertHTML( 30 | element, 31 | 'beforeend', 32 | ` 33 |
34 | 35 |
` 36 | ); 37 | 38 | this.addEventListener(document.querySelector('#tetris-planning-button'), 'click', this.openTetrisPlanningModal); 39 | } 40 | 41 | openTetrisPlanningModal = async () => { 42 | const [{ availableEstimationStatistics: fields }, tetrisPlanning = []] = await Promise.all([ 43 | this.getBoardEstimationData(), 44 | this.getBoardProperty(BOARD_PROPERTIES.TETRIS_PLANNING), 45 | ]); 46 | 47 | this.insertHTML( 48 | document.body, 49 | 'beforeend', 50 | formatTemplateForInserting(template).replace(/\$BOARD/g, this.getBoardId()) 51 | ); 52 | 53 | this.insertHTML( 54 | document.querySelector('#select2-example'), 55 | 'beforeend', 56 | fields.map(({ fieldId, name }) => ``).join('') 57 | ); 58 | 59 | tetrisPlanning.forEach(({ id, name, max, divider }) => this.appendRow(id, name, max, divider)); 60 | 61 | this.addEventListener(document.querySelector('#dialog_planning_btn_add'), 'click', () => { 62 | const name = document.querySelector('#select2-example').value; 63 | const { id } = document.querySelector(`[value="${name}"]`).dataset; 64 | 65 | this.appendRow( 66 | id, 67 | document.querySelector('#select2-example').value, 68 | document.querySelector('#dialog_planning_max_add').value, 69 | document.querySelector('#dialog_planning_value_divider').value || 1 70 | ); 71 | }); 72 | 73 | this.addEventListener(document.querySelector('#dialog-confirm'), 'click', () => { 74 | const value = map(row => { 75 | return { 76 | id: row.dataset.id, 77 | name: row.querySelector('.name').textContent, 78 | max: row.querySelector('.max').textContent, 79 | divider: Number(row.querySelector('.divider').textContent, 10), 80 | }; 81 | }, document.querySelectorAll('#dialog_planning_tbody .aui-restfultable-row')); 82 | 83 | this.updateBoardProperty(BOARD_PROPERTIES.TETRIS_PLANNING, value); 84 | document.querySelector('#dialog-cancel').click(); 85 | }); 86 | 87 | // window.AJS is not available here 88 | const script = document.createElement('script'); 89 | script.setAttribute('src', extensionApiService.getUrl('openModal.js')); 90 | document.body.appendChild(script); 91 | }; 92 | 93 | appendRow(id, name, max, divider) { 94 | const row = this.insertHTML( 95 | document.querySelector('#dialog_planning_tbody'), 96 | 'beforeend', 97 | ` 98 | 99 | ${name} 100 | ${max} 101 | ${divider} 102 | Delete 103 | 104 | ` 105 | ); 106 | 107 | this.addEventListener(row.querySelector('#dialog_planning_btn_delete'), 'click', () => row.remove()); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/tetris-planning/openModal.js: -------------------------------------------------------------------------------- 1 | function jiraHelperTetrisPlanningModal() { 2 | const dialog = window.AJS.dialog2('#static-dialog'); 3 | dialog.show(); 4 | 5 | document.querySelector('#dialog-cancel').addEventListener('click', () => { 6 | dialog.hide(); 7 | document.querySelector('#static-dialog').remove(); 8 | }); 9 | } 10 | 11 | jiraHelperTetrisPlanningModal(); 12 | -------------------------------------------------------------------------------- /src/tetris-planning/styles.css: -------------------------------------------------------------------------------- 1 | #static-dialog-example .aui-live-demo { 2 | position: relative; 3 | background-color: #999; 4 | } 5 | #static-dialog { 6 | margin-top: 100px; /* to push it inside the example box. */ 7 | } 8 | -------------------------------------------------------------------------------- /src/tetris-planning/template.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tetris-planning/todo.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## Сохранить настройки в JIRA хранилище 4 | 5 | Необходимо используя механизм jiraBoardStorage.js 6 | Сохранять настройки выбранные пользователем для Тетрис планирования 7 | Таким образом, все кто использует плагин смогут видеть эти настройки. -------------------------------------------------------------------------------- /src/wiplimit-on-cells/WiplimitOnCellsSettingsPopup.js: -------------------------------------------------------------------------------- 1 | import { PageModification } from '../shared/PageModification'; 2 | import { BOARD_PROPERTIES, btnGroupIdForColumnsSettingsPage } from '../shared/constants'; 3 | import { Popup } from '../shared/getPopup'; 4 | import { cellsAdd, ClearDataButton, RangeName, settingsEditWipLimitOnCells, settingsJiraDOM } from './constants'; 5 | import { TableRangeWipLimit } from './table'; 6 | 7 | export default class WipLimitOnCells extends PageModification { 8 | static ids = { 9 | editLimitsBtn: 'edit-WipLimitOnCells-btn-jh', 10 | }; 11 | 12 | static jiraSelectors = { 13 | panelConfig: '#jh-group-of-btns-setting-page', 14 | }; 15 | 16 | getModificationId() { 17 | return `WipLimitByCells-settings-${this.getBoardId()}`; 18 | } 19 | 20 | waitForLoading() { 21 | return Promise.all([this.waitForElement(WipLimitOnCells.jiraSelectors.panelConfig)]); 22 | } 23 | 24 | loadData() { 25 | return Promise.all([ 26 | this.getBoardEditData(), 27 | Promise.all([this.getBoardProperty(BOARD_PROPERTIES.WIP_LIMITS_CELLS)]), 28 | ]); 29 | } 30 | 31 | apply([boardData, settings]) { 32 | if (!(boardData && boardData.canEdit)) return; 33 | 34 | this.boardData = boardData; 35 | this.swimline = this.boardData?.swimlanesConfig?.swimlanes; 36 | this.column = this.boardData?.rapidListConfig?.mappedColumns; 37 | this.renderEditButton(); 38 | this.onDOMChange('#columns', () => { 39 | this.renderEditButton(); 40 | }); 41 | 42 | [this.data] = settings; 43 | const handleGetNameLabel = (swimlaneId, columnid) => { 44 | const swimline = this.swimline.find(element => element.id.toString() === swimlaneId.toString()); 45 | const column = this.column.find(element => element.id.toString() === columnid.toString()); 46 | 47 | return `${swimline?.name} / ${column?.name}`; 48 | }; 49 | this.table = new TableRangeWipLimit({ data: this.data, handleGetNameLabel }); 50 | } 51 | 52 | appendStyles() { 53 | return ` 54 | `; 63 | } 64 | 65 | renderEditButton() { 66 | const editBtn = this.insertHTML( 67 | document.getElementById(btnGroupIdForColumnsSettingsPage), 68 | 'beforeend', 69 | settingsEditWipLimitOnCells(WipLimitOnCells.ids.editLimitsBtn) 70 | ); 71 | 72 | this.popup = new Popup({ 73 | title: 'Edit WipLimit on cells', 74 | onConfirm: this.handleConfirmEditing, 75 | size: 'large', 76 | okButtonText: 'Save', 77 | }); 78 | 79 | this.addEventListener(editBtn, 'click', this.handleEditClick); 80 | } 81 | 82 | handleEditClick = async () => { 83 | await this.popup.render(); 84 | 85 | await this.popup.appendToContent(RangeName()); 86 | await this.popup.appendToContent(cellsAdd(this.swimline, this.column)); 87 | await this.popup.appendToContent(`
`); 88 | 89 | await this.popup.appendToContent(ClearDataButton(settingsJiraDOM.ClearData)); 90 | 91 | this.editBtn = document.getElementById(settingsJiraDOM.buttonRange); 92 | this.addEventListener(this.editBtn, 'click', this.handleOnClickAddRange); 93 | 94 | const clearBtn = document.getElementById(settingsJiraDOM.ClearData); 95 | this.addEventListener(clearBtn, 'click', this.handleClearSettings); 96 | 97 | this.input = document.getElementById(settingsJiraDOM.inputRange); 98 | this.addEventListener(this.input, 'input', this.handleOnChangeRange); 99 | 100 | await this.table.setDiv(document.getElementById(settingsJiraDOM.table)); 101 | await this.table.render(); 102 | }; 103 | 104 | handleOnChangeRange = () => { 105 | const { value: name } = document.getElementById(settingsJiraDOM.inputRange); 106 | const haveRange = this.table.findRange(name); 107 | if (haveRange) { 108 | this.editBtn.innerText = 'Add cell'; 109 | this.input.dataset.range = name; 110 | } else { 111 | this.editBtn.innerText = 'Add range'; 112 | delete this.input.dataset.range; 113 | } 114 | }; 115 | 116 | handleOnClickAddRange = () => { 117 | const { value: name, dataset } = document.getElementById(settingsJiraDOM.inputRange); 118 | const { value: swimline } = document.getElementById(`${settingsJiraDOM.swimlineSelect}`).selectedOptions[0]; 119 | const { value: column } = document.getElementById(`${settingsJiraDOM.columnSelect}`).selectedOptions[0]; 120 | const { checked: showBadge } = document.getElementById(`${settingsJiraDOM.showBadge}`); 121 | 122 | if (swimline === '-' || column === '-') { 123 | alert('need choose swimline and column and try again.'); 124 | return; 125 | } 126 | 127 | if (dataset.range && this.table.findRange(dataset.range)) { 128 | const cells = { 129 | swimline, 130 | column, 131 | showBadge, 132 | }; 133 | this.table.addCells(name, cells); 134 | } else { 135 | const addRangeResult = this.table.addRange(name); 136 | if (addRangeResult) { 137 | const cells = { 138 | swimline, 139 | column, 140 | showBadge, 141 | }; 142 | this.table.addCells(name, cells); 143 | } 144 | this.handleOnChangeRange(); 145 | } 146 | }; 147 | 148 | handleClearSettings = () => { 149 | this.table.setData([]); 150 | this.popup.unmount(); 151 | this.handleEditClick(); 152 | this.deleteBoardProperty(BOARD_PROPERTIES.WIP_LIMITS_CELLS); 153 | }; 154 | 155 | removeEditBtn() { 156 | this.editBtn.remove(); 157 | } 158 | 159 | handleConfirmEditing = unmountCallback => { 160 | const data = this.table.getData(); 161 | this.updateBoardProperty(BOARD_PROPERTIES.WIP_LIMITS_CELLS, data); 162 | unmountCallback(); 163 | }; 164 | } 165 | -------------------------------------------------------------------------------- /src/wiplimit-on-cells/constants.js: -------------------------------------------------------------------------------- 1 | // settings 2 | export const settingsJiraDOM = { 3 | swimlineSelect: 'WIPLC_SwimLine', 4 | columnSelect: 'WIPLC_Column', 5 | showBadge: 'WIPLC_showBadge', 6 | table: 'WIP_tableDiv', 7 | ClearData: 'SLAClearData', 8 | inputRange: 'WIP_inputRange', 9 | disableRange: 'WIP_disableRange', 10 | buttonRange: 'WIP_buttonRange', 11 | chooseCheckbox: 'WIP_chooseCheckbox', 12 | }; 13 | 14 | export const settingsEditWipLimitOnCells = btnId => ` 15 | 16 | `; 17 | 18 | export const ClearDataButton = btnId => `
19 | 20 |
`; 21 | 22 | export const RangeName = () => ` 23 |
24 |
25 | 26 | 27 | 28 |
29 |
`; 30 | // 31 | 32 | export const cellsAdd = (swimlines, collums) => { 33 | if (!Array.isArray(collums) || !Array.isArray(swimlines)) { 34 | return ''; 35 | } 36 | const swimlinesHTML = []; 37 | swimlines.forEach(element => { 38 | swimlinesHTML.push(``); 39 | }); 40 | const collumsHTML = []; 41 | collums.forEach(element => { 42 | collumsHTML.push(``); 43 | }); 44 | return ` 45 |
46 |
47 |
48 | 49 | 53 |
54 | 55 |
56 | 57 | 61 |
62 |
63 | 64 | 65 |
66 |
67 |
68 |
`; 69 | }; 70 | 71 | export const settingsPopupTableTemplate = tableBody => ` 72 |
73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | ${tableBody} 86 | 87 |
Range nameWIP limitDisable rangeCells
88 |
89 | `; 90 | -------------------------------------------------------------------------------- /test/routing/test-getCurrentRoute.js: -------------------------------------------------------------------------------- 1 | import each from 'jest-each'; 2 | import { getCurrentRoute } from '../../src/routing'; 3 | 4 | describe('Routing should', () => { 5 | each([ 6 | ['https://www.example.com/RapidView.jspa', 'SETTINGS'], 7 | ['https://www.example.com/RapidBoard.jspa', 'BOARD'], 8 | ['https://www.example.com/RapidBoard.jspa?config=1', 'SETTINGS'], 9 | ['https://www.example.com/RapidBoard.jspa?view=reporting', 'REPORTS'], 10 | ['https://www.example.com/browse', 'ISSUE'], 11 | ['https://www.example.com/browse?jql=1', 'SEARCH'], 12 | ['https://www.example.com/issues/', 'SEARCH'], 13 | ['https://www.example.com/', null], 14 | ]).it('when "%s" is given then return "%s"', (url, route) => { 15 | delete window.location; 16 | window.location = new URL(url); 17 | expect(getCurrentRoute()).toEqual(route); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/routing/test-getSettingsTab.js: -------------------------------------------------------------------------------- 1 | import each from 'jest-each'; 2 | import { getSettingsTab } from '../../src/routing'; 3 | 4 | describe('Routing should', () => { 5 | delete window.location; 6 | 7 | each([ 8 | ['tab=settings-tab', 'settings-tab'], 9 | ['config=config-tab', 'config-tab'], 10 | ]).it('when "%s" is given then return "%s"', (search, tab) => { 11 | window.location = { search }; 12 | expect.assertions(1); 13 | return expect(getSettingsTab()).resolves.toEqual(tab); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /test/shared/jiraApi/test-getUser.js: -------------------------------------------------------------------------------- 1 | import { getUser } from '../../../src/shared/jiraApi'; 2 | 3 | jest.mock('@tinkoff/request-core', () => 4 | jest.fn().mockImplementation(() => request => { 5 | expect(request.url).toEqual('api/2/user/search'); 6 | expect(request.query.username || request.query.query).toEqual('John'); 7 | expect(request.type).toEqual('json'); 8 | return Promise.resolve([{ name: 'John' }, { name: 'Mike' }]); 9 | }) 10 | ); 11 | 12 | describe('JiraApi should', () => { 13 | test('return User by name', () => { 14 | expect.assertions(2 * 3 + 1); 15 | return expect(getUser('John')).resolves.toEqual({ name: 'John' }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/shared/test-PageModification.js: -------------------------------------------------------------------------------- 1 | import { PageModification } from '../../src/shared/PageModification'; 2 | 3 | describe('MarkFlaggedIssues', () => { 4 | const pageModification = new PageModification(); 5 | 6 | test('.shouldApply should return true', () => { 7 | expect(pageModification.shouldApply()).toBeTruthy(); 8 | }); 9 | 10 | test('.getModificationId should return null', () => { 11 | expect(pageModification.getModificationId()).toBeNull(); 12 | }); 13 | 14 | test('.appendStyles should return undefined', () => { 15 | expect(pageModification.appendStyles()).toBeUndefined(); 16 | }); 17 | 18 | test('.apply should return undefined', () => { 19 | expect(pageModification.apply()).toBeUndefined(); 20 | }); 21 | 22 | describe('.insertHTML should', () => { 23 | function Container() {} 24 | Container.prototype.insertAdjacentHTML = () => {}; 25 | Container.prototype.previousElementSibling = { e: 1 }; 26 | Container.prototype.firstElementChild = { e: 2 }; 27 | Container.prototype.lastElementChild = { e: 3 }; 28 | Container.prototype.nextElementSibling = { e: 4 }; 29 | const container = new Container(); 30 | test('return previous sibling element when position is "beforebegin"', () => { 31 | expect(pageModification.insertHTML(container, 'beforebegin', '')).toEqual(container.previousElementSibling); 32 | }); 33 | 34 | test('return first child of element element when position is "afterbegin"', () => { 35 | expect(pageModification.insertHTML(container, 'afterbegin', '')).toEqual(container.firstElementChild); 36 | }); 37 | 38 | test('return last child of element element when position is "beforeend"', () => { 39 | expect(pageModification.insertHTML(container, 'beforeend', '')).toEqual(container.lastElementChild); 40 | }); 41 | 42 | test('return next child of element when position is "afterend"', () => { 43 | expect(pageModification.insertHTML(container, 'afterend', '')).toEqual(container.nextElementSibling); 44 | }); 45 | 46 | test('throw error when position is not in ["beforebegin","afterbegin","beforeend","afterend"]', () => { 47 | expect(() => pageModification.insertHTML(container, 'dummy', '')).toThrowError('Wrong position'); 48 | }); 49 | }); 50 | 51 | test('.getCssSelectorNotIssueSubTask should return "" when "rapidListConfig.currentStatisticsField.typeId" is not "issueCountExclSubs"', () => { 52 | const editData = { rapidListConfig: {} }; 53 | expect(pageModification.getCssSelectorNotIssueSubTask(editData)).toEqual(''); 54 | }); 55 | 56 | test('.getCssSelectorNotIssueSubTask should return ":not(.ghx-issue-subtask)" when "rapidListConfig.currentStatisticsField.typeId" is "issueCountExclSubs"', () => { 57 | const editData = { rapidListConfig: { currentStatisticsField: { typeId: 'issueCountExclSubs' } } }; 58 | expect(pageModification.getCssSelectorNotIssueSubTask(editData)).toEqual(':not(.ghx-issue-subtask)'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/shared/test-runModifications.js: -------------------------------------------------------------------------------- 1 | import runModifications from '../../src/shared/runModifications'; 2 | 3 | describe('RunModifications should', () => { 4 | test('applyModifications', () => { 5 | /** 6 | * Need to mock window object 7 | * @see ../../src/shared/ExtensionApiService.js:3 8 | */ 9 | expect(() => runModifications({ ALL: '' })).toThrowError(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/shared/utils/test-utils.js: -------------------------------------------------------------------------------- 1 | import { getRandomString } from '../../../src/shared/utils'; 2 | 3 | describe('Utils should', () => { 4 | test('return random number by given length', () => { 5 | const randomString1 = getRandomString(10); 6 | const randomString2 = getRandomString(10); 7 | 8 | expect(randomString1.length).toBeLessThanOrEqual(10); 9 | expect(randomString2.length).toBeLessThanOrEqual(10); 10 | expect(randomString1).not.toEqual(randomString2); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /tools/prepare-commit-message.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -- $HUSKY_GIT_PARAMS 4 | 5 | BRANCH_NAME=$(git symbolic-ref --short HEAD) 6 | # sample "19_blure_sensitive_text" -> "19" 7 | BRANCH_NAME_ID_ISSUE=$(git symbolic-ref --short HEAD | sed -e 's/\([0-9]*\)[^0-9]*/\1/') 8 | 9 | BRANCH_IN_COMMIT=0 10 | if [ -f $1 ]; then 11 | BRANCH_IN_COMMIT=$(grep -c "\[$BRANCH_NAME\]" $1) 12 | fi 13 | 14 | if [ -n "$BRANCH_NAME_ID_ISSUE" ] && ! [[ $BRANCH_IN_COMMIT -ge 1 ]]; then 15 | if [ -f $1 ]; then 16 | BRANCH_NAME="${BRANCH_NAME/\//\/}" 17 | sed -i.bak -e "1s@^@[#$BRANCH_NAME_ID_ISSUE] @" $1 18 | else 19 | echo "[$BRANCH_NAME] " > "$1" 20 | fi 21 | fi 22 | 23 | exit 0 24 | -------------------------------------------------------------------------------- /webpack/AutoRefreshPlugin.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | 3 | class AutoRefreshPlugin { 4 | constructor(trackedEntries) { 5 | this.trackedEntries = trackedEntries; 6 | 7 | this.sockets = []; 8 | 9 | const wss = new WebSocket.Server({ port: 8899 }); 10 | 11 | wss.on('connection', ws => { 12 | this.sockets.push(ws); 13 | const removeSocket = () => { 14 | this.sockets = this.sockets.filter(socket => socket !== ws); 15 | }; 16 | 17 | ws.on('error', removeSocket); 18 | ws.on('close', removeSocket); 19 | }); 20 | } 21 | 22 | apply(compiler) { 23 | compiler.hooks.watchRun.tap('AutoRefreshPlugin', comp => { 24 | this.changedFiles = Object.keys(comp.watchFileSystem.watcher.mtimes); 25 | }); 26 | 27 | compiler.hooks.done.tap('AutoRefreshPlugin', stats => { 28 | const hasCompilationErrors = stats.compilation.errors.some(error => error.name === 'ModuleBuildError'); 29 | 30 | const dependencies = new Set(); 31 | 32 | this.trackedEntries.forEach(entry => { 33 | this.collectAllDependencies( 34 | dependencies, 35 | stats.compilation.entries.find(e => e.name === entry.name).dependencies 36 | ); 37 | 38 | const sentEvents = {}; 39 | const sendEvent = event => { 40 | if (sentEvents[event]) return; 41 | 42 | this.sockets.forEach(ws => ws.send(`${event}|${hasCompilationErrors}`)); 43 | sentEvents[event] = true; 44 | }; 45 | 46 | if (this.changedFiles.some(file => dependencies.has(file))) { 47 | entry.events.forEach(event => { 48 | if (typeof event === 'string') { 49 | sendEvent(event); 50 | } else { 51 | setTimeout(() => sendEvent(event.name), event.timeout); 52 | } 53 | }); 54 | } 55 | 56 | dependencies.clear(); 57 | }); 58 | }); 59 | } 60 | 61 | collectAllDependencies(result, dependencies) { 62 | if (!dependencies || !dependencies.length) { 63 | return; 64 | } 65 | 66 | dependencies.forEach(dep => { 67 | if (!dep.module) { 68 | return; 69 | } 70 | 71 | if (dep.module.resource && !dep.module.resource.includes('node_modules')) { 72 | result.add(dep.module.resource); 73 | this.collectAllDependencies(result, dep.module.dependencies); 74 | } 75 | }); 76 | } 77 | } 78 | 79 | module.exports = AutoRefreshPlugin; 80 | -------------------------------------------------------------------------------- /webpack/webpack.common.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const pcg = require('../package.json'); 5 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 8 | 9 | const version = process.env.PACKAGE_VERSION || pcg.version; 10 | 11 | module.exports = { 12 | stats: 'errors-only', 13 | entry: { 14 | content: ['./src/content.js'], // TODO fix AutoRefreshPlugin to work without [] 15 | index: './src/popup/chromePlugin.js', 16 | options: './src/options/options.js', 17 | background: './src/background/background.js', 18 | printcards: './src/printcards/cardsRender/printcards.js', 19 | // blureforsensitive: './src/blure-for-sensitive/blurSensitive.js' 20 | }, 21 | output: { 22 | filename: '[name].js', 23 | path: path.resolve(__dirname, '../dist'), 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.js$/, 29 | exclude: /node_modules/, 30 | use: ['babel-loader', 'eslint-loader'], 31 | }, 32 | { 33 | test: /\.css$/, 34 | use: [ 35 | { 36 | loader: 'style-loader', 37 | }, 38 | { 39 | loader: 'css-loader', 40 | options: { 41 | modules: true, 42 | }, 43 | }, 44 | ], 45 | }, 46 | { 47 | test: /\.(html)$/, 48 | use: { 49 | loader: 'html-loader', 50 | options: { 51 | minimaze: true, 52 | attrs: [':data-src'], 53 | }, 54 | }, 55 | }, 56 | ], 57 | }, 58 | plugins: [ 59 | new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }), 60 | new CopyWebpackPlugin([ 61 | { from: './src/printcards/img/**/*', to: './img', flatten: true }, 62 | { from: './src/issue/img/**/*', to: './img', flatten: true }, 63 | { from: './src/assets/**/*', to: './src', flatten: true }, 64 | { from: './src/options/static/**/*', to: './options_static', flatten: true }, 65 | { from: './src/printcards/cardsRender/fonts/**/*', to: './fonts', flatten: true }, 66 | { from: './src/manifest.json', to: './' }, 67 | { from: './src/tetris-planning/openModal.js', to: './' }, 68 | { from: './src/person-limits/nativeModalScript.js', to: './' }, 69 | { from: './src/blur-for-sensitive/blurSensitive.css', to: './src', flatten: true }, 70 | { from: './src/contextMenu.js', to: './', flatten: true }, 71 | ]), 72 | new HtmlWebpackPlugin({ 73 | filename: 'index.html', 74 | title: 'jira-helper', 75 | template: path.resolve(__dirname, '../src/popup/chromePlugin.html'), 76 | inject: 'head', 77 | files: { 78 | js: ['chromePlugin.js'], 79 | css: ['chromePlugin.css'], 80 | }, 81 | }), 82 | new HtmlWebpackPlugin({ 83 | filename: 'options.html', 84 | title: 'options', 85 | template: path.resolve(__dirname, '../src/options/options.html'), 86 | inject: 'head', 87 | chunks: ['options'], 88 | }), 89 | new HtmlWebpackPlugin({ 90 | filename: 'background.html', 91 | title: 'background', 92 | template: path.resolve(__dirname, '../src/background/background.html'), 93 | inject: 'head', 94 | chunks: ['background'], 95 | }), 96 | new HtmlWebpackPlugin({ 97 | filename: 'printcards.html', 98 | title: 'printcards', 99 | template: path.resolve(__dirname, '../src/printcards/cardsRender/printcards.html'), 100 | inject: 'head', 101 | chunks: ['printcards'], 102 | }), 103 | new webpack.DefinePlugin({ 104 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 105 | 'process.env.PACKAGE_VERSION': JSON.stringify(version), 106 | }), 107 | { 108 | apply(compiler) { 109 | compiler.hooks.afterEmit.tap('SetVersionPlugin', () => { 110 | const manifest = require('../dist/manifest.json'); 111 | 112 | manifest.version = version; 113 | 114 | fs.promises.writeFile(path.resolve(__dirname, '../dist/manifest.json'), JSON.stringify(manifest, null, 2)); 115 | }); 116 | }, 117 | }, 118 | ], 119 | }; 120 | -------------------------------------------------------------------------------- /webpack/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const WebpackBar = require('webpackbar'); 3 | const AutoRefreshPlugin = require('./AutoRefreshPlugin'); 4 | 5 | const common = require('./webpack.common.config'); 6 | 7 | const plugins = [ 8 | new WebpackBar({ name: 'Jira Helper' }), 9 | new AutoRefreshPlugin([ 10 | { 11 | name: 'content', 12 | events: [{ name: 'refresh_background', timeout: 50 }], 13 | }, 14 | ]), 15 | ]; 16 | 17 | module.exports = merge(common, { 18 | mode: 'development', 19 | devtool: 'cheap-module-eval-source-map', 20 | plugins, 21 | watch: true, 22 | }); 23 | -------------------------------------------------------------------------------- /webpack/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | 4 | const common = require('./webpack.common.config'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'production', 8 | optimization: { 9 | minimize: true, 10 | minimizer: [new TerserPlugin()], 11 | }, 12 | }); 13 | --------------------------------------------------------------------------------