├── .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 | 
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 | [](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 | `${text} `
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 | `Group Settings
`;
6 |
7 | export const formTemplate = ({ leftBlock = '', rightBlock = '', id = 'jh-wip-limits-id' }) =>
8 | ``;
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 | : `
`
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 | Edit WIP limits by field
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 |
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 | Edit
133 | Delete
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 |
62 |
63 |
64 |
Печать карточек
65 |
66 | Для печати кароточек, необходимо сначала распечатать
67 | шаблон .
68 |
69 |
70 | Распечатать шаблон
71 |
72 |
73 | Настройки печати для шаблона:
74 |
75 | Двустороняя печать - ☑
76 |
77 | Дополнительные настройки:
78 |
79 | Поля - нет
80 | Фон - ☑
81 |
82 |
83 |
84 |
85 |
86 | После того, как были распечатаны карточки, наклейте стикеры согласно инструкции на листе.
87 |
88 |
89 | Теперь, когда вы наклеили стикеры на лист, используйте форму для ввода JQL-запроса на сайте Jira.
90 | И, когда получите необходимый результат,
91 | кликайте по кнопке печати справа от строки запроса.
92 |
93 |
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 | 'Manage per-person WIP-limits '
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 |
2 |
5 |
6 |
7 |
50 |
51 |
52 |
53 |
54 |
55 | Person
56 | Limit
57 | Columns
58 | Swimlanes
59 | Delete
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
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 | Delete
Edit
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 | Settings tetris-planing
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 |
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 |
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 |
46 | `;
47 |
48 | export const getTaskLimitTemplate = ({ startBtnId, maxTasksInputId, maxIssues }) => `
49 |
69 | `;
70 |
71 | export const getRoleSettingsTemplate = ({ requiredRolesBlockId, fields }) => `
72 |
73 |
Set card roles, maximum for 5 roles:
74 |
75 |
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 |
29 | ${htmlPrintIcon}
30 |
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 | `;
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 |
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 | Edit swimlane limits
18 |
`;
19 |
20 | export const settingsPopupTableTemplate = (tableId, tableBody) => `
21 |
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 | Settings Tetris-Planning
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 }) => `${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 | Edit Wip limits by cells
16 | `;
17 |
18 | export const ClearDataButton = btnId => `
19 | Clear and save all data
20 |
`;
21 |
22 | export const RangeName = () => `
23 | `;
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(`${element.name} `);
39 | });
40 | const collumsHTML = [];
41 | collums.forEach(element => {
42 | collumsHTML.push(`${element.name} `);
43 | });
44 | return `
45 |
68 | `;
69 | };
70 |
71 | export const settingsPopupTableTemplate = tableBody => `
72 |
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 |
--------------------------------------------------------------------------------