Repository: rgehan/octolenses Branch: master Commit: a0727568b17d Files: 159 Total size: 210.7 KB Directory structure: gitextract_cgx_ogxj/ ├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── main.yml ├── .gitignore ├── .nvmrc ├── .parcelrc ├── .prettierrc ├── LICENSE ├── README.md ├── changelog.md ├── cypress/ │ ├── e2e/ │ │ ├── discover.spec.js │ │ ├── filters.spec.js │ │ └── settings.spec.js │ └── support/ │ ├── commands.js │ └── e2e.js ├── cypress.config.ts ├── jest.config.js ├── manifest.json ├── package.json ├── postcss.config.js ├── scripts/ │ ├── release │ └── screenshots ├── src/ │ ├── @types/ │ │ ├── contrast/ │ │ │ └── index.d.ts │ │ └── human-format/ │ │ └── index.d.ts │ ├── App.tsx │ ├── components/ │ │ ├── Button/ │ │ │ ├── Button.tsx │ │ │ └── index.ts │ │ ├── Dropdown/ │ │ │ ├── Dropdown.tsx │ │ │ └── index.ts │ │ ├── FilterLink/ │ │ │ ├── FilterLink.tsx │ │ │ └── index.ts │ │ ├── FilterPredicate/ │ │ │ ├── FilterPredicate.tsx │ │ │ ├── OperatorSelector.tsx │ │ │ ├── ValueSelector.tsx │ │ │ └── index.ts │ │ ├── Header/ │ │ │ ├── Header.tsx │ │ │ ├── TabLink.tsx │ │ │ └── index.ts │ │ ├── Loader/ │ │ │ ├── Loader.tsx │ │ │ └── index.ts │ │ ├── Modal/ │ │ │ ├── Modal.tsx │ │ │ └── index.ts │ │ ├── RadioCard/ │ │ │ ├── RadioCard.tsx │ │ │ └── index.ts │ │ ├── ToastManager/ │ │ │ ├── Toast.tsx │ │ │ ├── ToastManager.tsx │ │ │ ├── index.ts │ │ │ └── types.ts │ │ └── index.ts │ ├── constants/ │ │ ├── darkMode.ts │ │ ├── dates.ts │ │ └── languages.ts │ ├── containers/ │ │ ├── FilterEditModal/ │ │ │ ├── FilterEditModal.tsx │ │ │ ├── PredicatesStep.tsx │ │ │ ├── ProviderStep.tsx │ │ │ └── index.ts │ │ ├── RepoCard/ │ │ │ ├── RepoCard.tsx │ │ │ └── index.ts │ │ ├── SettingsModal/ │ │ │ ├── Panel.tsx │ │ │ ├── SettingsModal.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── tabs/ │ │ │ │ ├── Cache.tsx │ │ │ │ ├── NightMode.tsx │ │ │ │ └── index.ts │ │ │ └── types.ts │ │ └── index.ts │ ├── errors/ │ │ ├── InvalidCredentials.ts │ │ ├── NeedTokenError.ts │ │ ├── RateLimitError.ts │ │ └── index.ts │ ├── index.html │ ├── index.scss │ ├── index.tsx │ ├── lib/ │ │ ├── assertUnreachable.ts │ │ ├── cache.ts │ │ └── github/ │ │ ├── index.ts │ │ └── trending/ │ │ └── index.ts │ ├── migrations/ │ │ ├── index.ts │ │ ├── mocks/ │ │ │ ├── index.ts │ │ │ ├── v0.ts │ │ │ ├── v1-without-token.ts │ │ │ ├── v1.ts │ │ │ ├── v2-with-negated-predicates.ts │ │ │ ├── v2.ts │ │ │ └── v3-with-defaulted-operators.ts │ │ ├── testing-utils.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ ├── v0-to-v1.test.ts │ │ ├── v0-to-v1.ts │ │ ├── v1-to-v2.test.ts │ │ ├── v1-to-v2.ts │ │ ├── v2-to-v3.test.ts │ │ └── v2-to-v3.ts │ ├── pages/ │ │ ├── Dashboard/ │ │ │ ├── Dashboard.tsx │ │ │ ├── FilterLinkContainer.tsx │ │ │ └── index.ts │ │ ├── Discover/ │ │ │ ├── Discover.scss │ │ │ ├── Discover.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── providers/ │ │ ├── AbstractProvider.ts │ │ ├── github/ │ │ │ ├── components/ │ │ │ │ ├── IssueCard/ │ │ │ │ │ ├── CheckStatusIndicator.tsx │ │ │ │ │ ├── ConflictIndicator.tsx │ │ │ │ │ ├── ContextualDropdown.tsx │ │ │ │ │ ├── IssueCard.tsx │ │ │ │ │ ├── IssueStatusIndicator.tsx │ │ │ │ │ ├── LabelBadge.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── ProfileCard.tsx │ │ │ │ └── Settings.tsx │ │ │ ├── fetchers/ │ │ │ │ ├── client.ts │ │ │ │ ├── graphql/ │ │ │ │ │ ├── query.ts │ │ │ │ │ ├── search.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── index.ts │ │ │ │ └── rest/ │ │ │ │ ├── profile.ts │ │ │ │ └── search.ts │ │ │ ├── index.tsx │ │ │ └── predicates/ │ │ │ ├── createdOrUpdatedAt.ts │ │ │ ├── draft.ts │ │ │ ├── index.ts │ │ │ ├── mergeStatus.ts │ │ │ ├── review.ts │ │ │ ├── status.ts │ │ │ └── type.ts │ │ ├── index.ts │ │ ├── jira/ │ │ │ ├── components/ │ │ │ │ ├── AvailableResources.tsx │ │ │ │ ├── IssueCard/ │ │ │ │ │ ├── IssueCard.tsx │ │ │ │ │ ├── StatusBadge.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── LoginButton.tsx │ │ │ │ ├── LogoutButton.tsx │ │ │ │ └── Settings.tsx │ │ │ ├── fetchers/ │ │ │ │ ├── index.ts │ │ │ │ ├── refreshToken.ts │ │ │ │ ├── resources.ts │ │ │ │ └── swapToken.ts │ │ │ ├── index.tsx │ │ │ └── predicates/ │ │ │ └── index.ts │ │ └── types.ts │ ├── service_worker/ │ │ ├── cache.js │ │ └── index.js │ ├── setupTests.ts │ └── store/ │ ├── filters.ts │ ├── index.ts │ ├── models/ │ │ └── filter.ts │ ├── navigation.ts │ ├── settings.ts │ └── trends.ts ├── tailwind.config.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "plugins": [ ["@babel/plugin-proposal-decorators", { "legacy": true }] ] } ================================================ FILE: .editorconfig ================================================ root=true [*] end_of_line = lf tab_width = 2 indent_style = space insert_final_newline = true charset = utf-8 trim_trailing_whitespace = true ================================================ FILE: .eslintignore ================================================ .cache/ dist/ node_modules/ ================================================ FILE: .eslintrc ================================================ { "extends": [ // Enable eslint recommended, and the few overrides necessary to work with TS "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", // Enable recommended rules specific to TS "plugin:@typescript-eslint/recommended", // Enable rules that are types aware "plugin:@typescript-eslint/recommended-requiring-type-checking", // React rules "plugin:react/recommended" ], "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], "parserOptions": { "ecmaFeatures": { "jsx": true }, "project": "./tsconfig.json" }, "settings": { "react": { "version": "detect" } }, "rules": { // Enforce `I` prefix for interfaces, as I like it that way "@typescript-eslint/interface-name-prefix": [ "error", { "prefixWithI": "always", "allowUnderscorePrefix": true } ], // Allow using `any`, as it's sometimes easier with external API "@typescript-eslint/no-explicit-any": "off", // Do not require return types on all functions, as the inference engine is good enough to figure it out "@typescript-eslint/explicit-function-return-type": "off", // It is convenient being able to declare functions after they're used "@typescript-eslint/no-use-before-define": "off", // It is erroring when declaring global functions on the window object, which is annoying "@typescript-eslint/unbound-method": ["off"], "@typescript-eslint/camelcase": ["error", { "ignoreDestructuring": true, "properties": "never" }] } } ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - Browser [e.g. chrome, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/main.yml ================================================ name: Lint & Tests on: [push] jobs: lint: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v1 with: fetch-depth: 1 - name: Restore node_modules folder uses: actions/cache@v1 with: path: node_modules key: ${{ runner.os }}-cache-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-cache- - name: Install dependencies run: yarn install - name: Run ESLint run: yarn lint - name: Run tsc run: tsc --noEmit - name: Run Jest run: yarn test integration-tests: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v1 with: fetch-depth: 1 - name: Run Cypress uses: cypress-io/github-action@v5 with: record: true start: yarn start env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} CYPRESS_GITHUB_TOKEN: ${{ secrets.CYPRESS_GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ node_modules/ .idea/ yarn-error.log .cache/ .parcel-cache/ dist/ dist.crx dist.pem octolenses-* cypress/videos ================================================ FILE: .nvmrc ================================================ 20 ================================================ FILE: .parcelrc ================================================ { "extends": "@parcel/config-default", "transformers": { "*.{ts,tsx}": ["@parcel/transformer-typescript-tsc"] } } ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "trailingComma": "es5" } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Renan GEHAN Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [![](.github/icons/chrome.png)](https://chrome.google.com/webstore/detail/octolenses/ghlblfakaklgkdmfejdlffbmpcaidoci) [![](.github/icons/firefox.png)](https://addons.mozilla.org/firefox/addon/github-octolenses/) ![](https://github.com/rgehan/octolenses/workflows/Lint%20%26%20Tests/badge.svg) # OctoLenses Browser Extension > Watch your repos and discover awesome things directly from your New Tab page. As a developer, you shouldn't have to worry about that and instead focus on what is fundamental: your code This extension allows you to create very precise filters that will nicely lay out all the information you need in order to be as productive as possible. ![](.github/screenshots/light/dashboard.png) ![](.github/screenshots/light/filter-add.png) ![](.github/screenshots/light/filter-edit.png) ## Installation [![](.github/icons/chrome.png)](https://chrome.google.com/webstore/detail/octolenses/ghlblfakaklgkdmfejdlffbmpcaidoci) [![](.github/icons/firefox.png)](https://addons.mozilla.org/firefox/addon/github-octolenses/) Simply download it on your favorite browser's extensions store. It is available on both [Google Chrome](https://chrome.google.com/webstore/detail/octolenses/ghlblfakaklgkdmfejdlffbmpcaidoci) and [Firefox](https://addons.mozilla.org/firefox/addon/github-octolenses/). By default, it overrides your browser's default "New Tab" page, but this can be disabled so that it only opens when you click on the extension's icon. You can access this setting in the settings modal, which can be opened by simply clicking on the little cog icon on the top right hand corner. ## Usage example At my current job, we have quite a lovely system where each Pull Request is assigned a specific label depending on whether it's a WIP, under review or if it has been successfully (or not) reviewed. I built this tool for the very purpose of keeping track of this, but this is not all it can do. It can do much more, such as: - Helping you contribute to Open Source by presenting you with issues that are labelled `Good first issue` or `Help wanted` - Helping you stay up to date with your favorite framework changes - Allowing you to discover trendy repositories in your favorite language (similar to what [GitHunt](https://github.com/kamranahmedse/githunt) does) ## Dark theme Because being flashed bright lights in the eyes at night is the worst thing ever, I even included a pretty cool dark mode. ![](.github/screenshots/dark/dashboard.png) ![](.github/screenshots/dark/filter-edit.png) ![](.github/screenshots/dark/discover.png) ![](.github/screenshots/dark/settings-night-mode.png) ## Extensively configurable There are a lot of settings you can tweak, to adapt the experience of the extension to your needs. ![](.github/screenshots/light/settings-cache.png) ![](.github/screenshots/light/settings-git-hub.png) ![](.github/screenshots/light/settings-jira.png) ## Permissions asked OctoLenses only asks for the `tabs` permission, as it needs to be able to: - Detect when a tab is opened so it can eventually override it - Create a new tab when the extension's icon is clicked. ## Development setup You need a few tools before being able to build the extension: - `yarn`, a [JS package manager](https://yarnpkg.com/docs/install) (on Mac: `brew install yarn`) - `jq`, a [JSON CLI utility](https://stedolan.github.io/jq/) (on Mac: `brew install jq`). - `sed`, should be available on any Unix system. - `zip`, should be available on any Unix system. Then you can follow this process to develop/build the extension: ```sh # Clone the repository git clone git@github.com:rgehan/octolenses.git && \ cd octolenses # Install the dependencies yarn # Run the development environment... yarn start # ...or build the extension yarn build ``` The built extension (located in the `dist/` folder), can then be loaded inside your browser as an _unpacked extension_, provided you're in developer mode. [https://github.com/rgehan/octolenses](rgehan/octolenses) ## Testing The extension is covered by unit tests, and integration tests. Here is how you can run them: ```bash # Run the unit tests (w/ Jest) yarn test # Open the integration tests runner (w/ Cypress) CYPRESS_GITHUB_TOKEN= yarn e2e ``` ## Releasing ```sh # Update changelog, increment version number & create release commit and tag yarn release patch|minor|major # Build the release .zip archive yarn build ``` The archive can then be uploaded on the Chrome Store dashboard. ## Contributing 1. Fork it () 2. Create your feature branch (`git checkout -b feature/fooBar`) 3. Commit your changes (`git commit -am 'Add some fooBar'`) 4. Push to the branch (`git push origin feature/fooBar`) 5. Create a new Pull Request ## License MIT © Renan GEHAN ================================================ FILE: changelog.md ================================================ ### v2.3.0 - Allow filtering PRs/issues by relative update/creation date [Renan GEHAN] ### v2.2.2 - Refresh all button, when pressing Meta key [Renan GEHAN] - Handle draft PRs [Renan GEHAN] - Fix build script to avoid weird parcel issues [Renan GEHAN] ### v2.2.1 - Add support for draft PRs [Renan GEHAN] ### v2.2.0 - Fix wrap issues with GH label badges [Renan GEHAN] - Add .idea to .gitignore [Renan GEHAN] - Update main deps (parcel, tailwind) + node LTS (#219) [GitHub] ### v2.1.2 - Remove unused `storage` permission [Renan GEHAN] ### v2.1.1 - Remove unnecessary `tabs` permission [Renan GEHAN] ### v2.1.0 - use-page-overrides-api (#218) [GitHub] ### v2.0.0 - Fallback to localStorage if chrome.storage not available [Renan GEHAN] - Upgrade cypress [Renan GEHAN] - Upgrade cypress github action [Renan GEHAN] - Fix tsc issues [Renan GEHAN] - Prettify [Renan GEHAN] - Fix .gitignore [Renan GEHAN] - Remove some usage of `localStorage` [Renan GEHAN] - Update @types/chrome [Renan GEHAN] - Migrate to manifest v3 [Renan GEHAN] - Upgrade Cypress [Renan GEHAN] ### v1.2.7 - Upgrade all dependencies [Renan GEHAN] - Fix predicates deletion [Renan GEHAN] - [Security] Bump acorn from 5.7.3 to 5.7.4 (#161) [GitHub] - Bump @babel/core from 7.7.4 to 7.8.7 (#160) [GitHub] - Bump ts-jest from 24.2.0 to 24.3.0 (#158) [GitHub] - Bump @babel/polyfill from 7.7.0 to 7.8.3 (#154) [GitHub] - Bump typescript from 3.7.4 to 3.8.3 (#156) [GitHub] - Fix lint [Renan Gehan] ### v1.2.6 - Display last activity date in IssueCard [Renan Gehan] - Bump rimraf from 3.0.0 to 3.0.2 (#150) [GitHub] - Bump @typescript-eslint/parser from 2.8.0 to 2.19.2 (#152) [GitHub] ### v1.2.5 - Fix Jira resource style in night mode [Renan GEHAN] - Fix link style [Renan GEHAN] - Add support project predicate (#151) [GitHub] - Bump eslint-plugin-react from 7.16.0 to 7.18.3 (#147) [GitHub] - Bump @babel/plugin-proposal-object-rest-spread from 7.7.4 to 7.8… (#145) [GitHub] - Bump cypress from 3.6.1 to 3.8.3 (#146) [GitHub] - Bump mobx from 5.15.0 to 5.15.1 (#137) [Renan GEHAN] - Bump sass from 1.23.7 to 1.24.2 (#140) [Renan GEHAN] - Bump typescript from 3.7.2 to 3.7.4 (#139) [Renan GEHAN] - Bump timeago.js from 3.0.2 to 4.0.1 (#131) [Renan GEHAN] - Bump object-hash from 2.0.0 to 2.0.1 (#133) [Renan GEHAN] - [Security] Bump serialize-to-js from 3.0.0 to 3.0.2 (#136) [Renan GEHAN] - Bump eslint from 6.7.0 to 6.7.2 (#134) [Renan GEHAN] ### v1.2.4 - Fix "Open in New Tab" in recent versions of Chrome [Renan GEHAN] - add-tsc-check (#130) [GitHub] - migrate-to-eslint (#129) [GitHub] - improve-tests (#128) [GitHub] - Remove forgotten it.only() [Renan GEHAN] - Throw if the CYPRESS_GITHUB_TOKEN env var isn't defined [Renan GEHAN] - Cypress tests 2 (#127) [GitHub] - Bump immer from 1.12.1 to 5.0.0 (#123) [Renan GEHAN] - Bump @babel/polyfill from 7.6.0 to 7.7.0 (#124) [Renan GEHAN] - Document cypress tests [Renan GEHAN] - Bump react from 16.11.0 to 16.12.0 (#125) [Renan GEHAN] - Cypress tests (#126) [GitHub] - Bump @babel/core from 7.6.4 to 7.7.2 (#113) [Renan GEHAN] - Bump @types/jest from 24.0.21 to 24.0.22 (#118) [Renan GEHAN] - Bump @babel/plugin-proposal-class-properties from 7.5.5 to 7.7.0 (#115) [Renan GEHAN] - Bump tslint from 5.20.0 to 5.20.1 (#117) [Renan GEHAN] - Bump rimraf from 2.7.1 to 3.0.0 (#114) [Renan GEHAN] - Bump mobx from 5.14.2 to 5.15.0 (#116) [Renan GEHAN] - Bump @types/lodash from 4.14.144 to 4.14.146 (#119) [Renan GEHAN] - Bump object-hash from 1.3.1 to 2.0.0 (#120) [Renan GEHAN] - Bump @types/react-sortable-hoc from 0.6.6 to 0.7.1 (#121) [Renan GEHAN] - Bump prettier from 1.18.2 to 1.19.1 (#122) [Renan GEHAN] ### v1.2.3 - Fix URL of the jira issue cards [Renan GEHAN] - Upgrade all dependencies [Renan GEHAN] - Bump tslint-react from 4.0.0 to 4.1.0 (#102) [Renan GEHAN] - Bump human-format from 0.10.0 to 0.10.1 [Renan GEHAN] - Bump tslint from 5.19.0 to 5.20.0 [Renan GEHAN] - Bump @babel/plugin-proposal-class-properties from 7.4.0 to 7.5.5 [Renan GEHAN] - Bump typescript from 3.6.3 to 3.6.4 [Renan GEHAN] - Bump prop-types from 15.6.2 to 15.7.2 [Renan GEHAN] - Bump react-sortable-hoc from 1.5.3 to 1.10.1 [Renan GEHAN] - Bump puppeteer from 1.18.1 to 2.0.0 [Renan GEHAN] - Bump tailwindcss from 1.0.4 to 1.1.3 [Renan GEHAN] - Bump @types/styled-components from 4.1.12 to 4.1.20 [Renan GEHAN] - [Security] Bump safer-eval from 1.3.3 to 1.3.5 [Renan GEHAN] - Bump @babel/plugin-proposal-decorators from 7.4.0 to 7.6.0 [Renan GEHAN] - Bump mobx-react from 6.1.1 to 6.1.4 [Renan GEHAN] - Bump @types/chrome from 0.0.81 to 0.0.91 [Renan GEHAN] - Bump sass from 1.17.3 to 1.22.12 [Renan GEHAN] - Bump ts-jest from 24.0.0 to 24.1.0 [Renan GEHAN] - Bump @types/classnames from 2.2.7 to 2.2.9 [Renan GEHAN] - Bump typescript from 3.5.3 to 3.6.3 [Renan GEHAN] - Bump mixin-deep from 1.3.1 to 1.3.2 [Renan GEHAN] - Bump lodash from 4.17.13 to 4.17.15 [Renan GEHAN] - Bump @babel/core from 7.4.0 to 7.6.0 [Renan GEHAN] - Show priority in Jira issue card [Renan GEHAN] - Revert "Add renovate.json" [Renan GEHAN] - Add renovate.json [Renan GEHAN] - Bump @types/object-hash from 1.2.0 to 1.3.0 [dependabot-preview[bot]] - Bump @babel/plugin-proposal-object-rest-spread from 7.4.0 to 7.5.5 [dependabot-preview[bot]] - Bump tslint from 5.14.0 to 5.19.0 [dependabot-preview[bot]] - Bump @types/lodash from 4.14.123 to 4.14.137 [dependabot-preview[bot]] - Bump @types/jest from 24.0.11 to 24.0.18 [Renan GEHAN] - Bump typescript from 3.3.4000 to 3.5.3 [Renan GEHAN] - Bump react from 16.8.1 to 16.9.0 [dependabot-preview[bot]] - Bump uuid from 3.3.2 to 3.3.3 [dependabot-preview[bot]] - Add bottom margin to FilterEditModal to add spacing between CTAs and bottom of screen [Renan GEHAN] - Add overflow auto to Modal content [Renan GEHAN] - Bump lodash from 4.17.10 to 4.17.13 [Renan GEHAN] - Do not show all items as new after editing a filter [Renan GEHAN] - Move update logic to the filter model [Renan GEHAN] - Clear filter's notifications when navigating to another filter [Renan GEHAN] - Generate screenshots with notifications too [Renan GEHAN] - Automatically select a filter on creation [Renan GEHAN] - Minor ui fixes on Github profile card [Renan GEHAN] - Let filters automatically refetch themselves in reaction to their hash changing [Renan GEHAN] - Move filter fetching logic inside of filter model (better encapsulation) [Renan GEHAN] - Stop resetting whole filter object when simply editing [Renan GEHAN] - Fix filter.clone so that it clones all relevant attributes of the model [Renan GEHAN] ### v1.2.2 - Update README with new screenshots [Renan GEHAN] - Fix text color on dark RepoCard [Renan GEHAN] - Add a few data-* attributes to make navigation easier with Puppeteer [Renan GEHAN] - Add a script for automatically taking Chrome Store formatted screenshots [Renan GEHAN] - Fix editorconfig again [Renan GEHAN] ### v1.2.1 - Fix color on active FilterLink in light mode [Renan GEHAN] - Remove IsDarkContext, directly import settingsStore instead [Renan GEHAN] - Stop relying on @inject, directly import stores instead [Renan GEHAN] - Convert FilterLink to TS [Renan GEHAN] - Add utils for exporting/import the configuration [Renan GEHAN] - Remove unused migrations file [Renan GEHAN] - Split filters store file in two: FilterStore and Filter model [Renan GEHAN] - Convert Dashboard to Typescript [Renan GEHAN] - Add small border to indicate which are the new items of a filter [Renan GEHAN] - In Filter, store all new items IDs, instead of just the count [Renan GEHAN] - Fix type [Renan GEHAN] - Fix default filter type predicate [Renan GEHAN] - Make filter sidebar sticky [Renan GEHAN] - Also fade out toasts when manually discarded [Renan GEHAN] - Fix toast z-index so that they are visible above modals [Renan GEHAN] - Add feedback toast to some actions [Renan GEHAN] - Add active colors to buttons [Renan GEHAN] - Minor manual adjustement after Tailwind update [Renan GEHAN] - Update all css classes to new tailwind format [Renan GEHAN] - Remove normalize.css [Renan GEHAN] - Update tailwindcss module to 1.0.4 [Renan GEHAN] - Fix .editorconfig *facepalm* [Renan GEHAN] ### v1.2.0 - Ensure cache invalidation is persistent across hard refreshes [Renan GEHAN] - Alter Filter model to compute count of new items since last refresh [Renan GEHAN] - Add method on provider to resolve UID for filter items [Renan GEHAN] - Add notification badge to FilterLink [Renan GEHAN] - Make initialize method on AbstractProvider, abstract [Renan GEHAN] - Slightly decouple FiltersStore and Filter model [Renan GEHAN] - Add .editorconfig [Renan GEHAN] - Allow console.log in tslint.json [Renan GEHAN] - Persist the selectedFilterId [Renan Gehan] ### v1.1.1 - Fix useNewTabPage setting not being taken into account [Renan Gehan] ### v1.1.0 - Add title to the check status [Renan Gehan] - Show whether a PR is conflicting with an icon [Renan Gehan] - Retrieve MergeableStatus from PRs (graphql) [Renan Gehan] ### v1.0.0 - Move IssueStatus enum to its own file [Renan GEHAN] - Default IssueStatus to Unknown when using Github REST fetcher [Renan Gehan] - Adapt CheckStatusIndicator to new status format [Renan Gehan] - Adapt github graphql fetcher to properly format status [Renan Gehan] - Fetch checkSuites associated to the last commit of a PR [Renan Gehan] - Add header on github fetcher allowing access to the Previews API [Renan Gehan] - Use enum for Issue status type [Renan Gehan] - Use international URL for Firefox [Renan GEHAN] - Bump handlebars from 4.1.1 to 4.1.2 [Renan GEHAN] - Bump safer-eval from 1.2.3 to 1.3.3 [Renan GEHAN] - Create migration for operators [Renan GEHAN] - Add more jira predicates [Renan GEHAN] - Allow submitting FilterEditModal by pressing Enter [Renan GEHAN] - Fix style when in dark mode [Renan GEHAN] - Adapt filtering UI to the operators [Renan GEHAN] - Replace `negated` flag on predicates by an `operator` key, allowing richer filters [Renan GEHAN] - Add testing utils for testing migrations in the browser [Renan GEHAN] - Add logging in migrations [Renan GEHAN] - Run migrations on app start [Renan GEHAN] - Create migration from v1 to v2, with tests [Renan GEHAN] - Disable ts diagnostics in tests [Renan GEHAN] - Make ts-jest work with js files too [Renan GEHAN] - Create migration from v0 to v1, with tests [Renan GEHAN] - Install jest-localstorage-mock [Renan GEHAN] - setup jest [Renan GEHAN] - Run tslint autofix on the whole project [Renan GEHAN] - Fix tslint setup [Renan GEHAN] - Upgrade build system dependencies [Renan GEHAN] - Improve header styling (+ ts rewrite) [Renan GEHAN] - Add link to the repo [Renan GEHAN] - Rename octolenses-browser-extension to octolenses (to match new repo url) [Renan GEHAN] - Cache jira filters too [Renan GEHAN] - Change jira service url, change jira app client id [Renan GEHAN] - Cache trending repos response [Renan GEHAN] - Flush expired cache entries on start [Renan GEHAN] - Create settings tab for the cache (with a clear button) [Renan GEHAN] - Cache jira available resources for 5m [Renan GEHAN] - Create filters in loading state by default [Renan GEHAN] - Cache results from the GitHub GraphQL endpoint [Renan GEHAN] - Cache results from the GitHub REST endpoint [Renan GEHAN] - Add a way to bypass the cache for filter refresh [Renan GEHAN] - Compute a hash for each filter, taking into account all data that's relevant to fetching [Renan GEHAN] - Implement a cache class (very similar to laravel Cache facade) [Renan GEHAN] - Very simple implementation of a caching service worker [Renan GEHAN] - Very simple implementation of a caching service worker [Renan GEHAN] - Fetch jira resources on init [Renan GEHAN] - Properly serialize predicates that can contain whitespaces [Renan GEHAN] - Use first jira resource by default [Renan GEHAN] - Only refresh jira token if it's expired, or going to in less than 5m [Renan GEHAN] - Add key to manifest for a stable dev url [Renan GEHAN] - Refresh token on init (+ add types) [Renan GEHAN] - Improve Modal animation [Renan GEHAN] - Fetch resources on jira connection [Renan GEHAN] - Extract token swap logic outside of the component [Renan GEHAN] - Use proper token swap service URL [Renan GEHAN] - Show jira logout button if the user's connected + it's list of available resources [Renan GEHAN] - Make github ProfileCard react on token change [Renan GEHAN] - Display user profile in the Github settings panel if the user's logged in [Renan GEHAN] - Minor UI fix in settings modal sidebar [Renan GEHAN] - Fix settings pane that are not from provides [Renan GEHAN] - On init, fetch Github profile [Renan GEHAN] - Add a way for providers to run an initialization method on start [Renan GEHAN] - Pass whole provider to the settings views [Renan GEHAN] - Create method for fetching github profile [Renan GEHAN] - Reorganize github fetchers [Renan GEHAN] - Ensure providers are persisted/hydrated just like regular stores [Renan GEHAN] - Adapt jira provider to the settings changes [Renan GEHAN] - Adapt github provider to the settings changes [Renan GEHAN] - Stop passing settings from the generic settings store to the provider settings views [Renan GEHAN] - Remove providerSettings from the settings store [Renan GEHAN] - Add an observable/persistable settings object on each provider, so that they manage their own settings [Renan GEHAN] - Transform Provider interface to an AbstractProvider abstract class [Renan GEHAN] - Simplify providers by extracting behavior to other files [Renan GEHAN] - Temporarily use a working site id [Renan GEHAN] - Get token at the proper setting key in jira provider [Renan GEHAN] - Update FilterEditModal to accomodate providers system + new UI [Renan GEHAN] - Rewrite SettingsModal to dynamically render registered providers settings pane [Renan GEHAN] - Create Jira provider [Renan GEHAN] - Move GitHub specific rendering logic to the provider, connect it to the settings so it can fetch with credentials [Renan GEHAN] - Transform Button component to TS [Renan GEHAN] - Create generic animated modal to be used for all modals [Renan GEHAN] - Add additional fonts/sizes to tailwind config [Renan GEHAN] - Install mobx-react-lite for use with hooks, and a few types [Renan GEHAN] - Rewrite Root App component in TS and provide isDark context from it [Renan GEHAN] - Create React context for dark mode [Renan GEHAN] - Add permission to use 'identity' API [Renan GEHAN] - Add some missing types [Renan GEHAN] - Move migrations to a file, make them run earlier. [Renan GEHAN] - Remove now useless filters lib [Renan GEHAN] - Fix filter addition/cloning [Renan GEHAN] - Decouple app from GitHub by introducing a system of providers (+ typescript rewrite) [Renan GEHAN] - Proper TypeScript/TSlint setup (also install lib types) [Renan GEHAN] - Fix comments count total computation [Renan GEHAN] - Show sum of reviews/comments count on the IssueCard [Renan GEHAN] - Add comments count (and fake reviews count) to the result of the REST query [Renan GEHAN] - Fetch comments/reviews totalCount in graphql query [Renan GEHAN] - Fix icon used in IssueCard when using REST endpoint [Renan GEHAN] - Disable HMR as it makes us hit rate limit really fast in dev [Renan GEHAN] - Fix issue status icon color when using REST endpoint [Renan Gehan] - Properly serialize REST filter payload [Renan Gehan] - Renaming CIStatusIndicator component to CheckStatusIndicator. [Renan GEHAN] - Adding a new merged status predicate with merged and unmerged values. [Renan GEHAN] - Renaming STATE_COLORS hash map to ISSUE_STATUS_COLORS. Delete unsued import package. [Renan GEHAN] - Extraction of issue status indicator logic in a specific IssueStatusIndicator component. [Renan GEHAN] - Rename StatusIndicator component to CIStatusIndicator. [Renan GEHAN] - Add merged and unmerged values on status filter. [Renan GEHAN] - Fix small layout issue [Renan GEHAN] - Fetch issues using the REST api when no token is set [Renan GEHAN] - Add initial migration for setting schemaVersion to 1 [Renan GEHAN] - Add schemaVersion attribute to settings store [Renan GEHAN] - Fix loader dark color [Renan GEHAN] - Add cursor:pointer on ContextDropdown trigger [Renan GEHAN] - 49/use-graphql-api (#57) [GitHub] ### v0.4.3 - Adapt ContextualDropdown to theme [Renan Gehan] - Use ContextualDropdown in IssueCard [Renan Gehan] - Implement ContextualDropdown [Renan Gehan] - Install clipboard dependency [Renan Gehan] - Upgrade to React 16.8 [Renan Gehan] ### v0.4.2 - Fix FilterLink observer [Renan GEHAN] - Prevent dragging from too far [Renan GEHAN] - Fix filter selection [Renan GEHAN] - Select the moved filter [Renan GEHAN] - Make filters reorderable [Renan GEHAN] - Update font-awesome to 5.6 [Renan GEHAN] - Install react-sortable-hoc [Renan GEHAN] - Allow removing toast by clicking on it [Renan GEHAN] - Fix toast colors [Renan GEHAN] - Show proper error when getting rate-limited [Renan GEHAN] - To rebase toast [Renan GEHAN] - Add global ToastManager to the tree [Renan GEHAN] - Create ToastManager for handling notifications [Renan GEHAN] - Ignore bundled zips [Renan GEHAN] ### v0.4.1 - Fix FilterLink count badge size [Renan GEHAN] - Add browser icons in the README.md to guide the user to the link [Renan GEHAN] - Update screenshots & README.md [Renan GEHAN] - Change default filter [Renan GEHAN] ### v0.4.0 - Fix image placehold bg color to avoid weird border in dark mode [Renan GEHAN] - Fix dark mode so that it automatically toggles at night [Renan GEHAN] - Fix font size [Renan GEHAN] - Reskin FilterLink & Button components [Renan GEHAN] - Fix alignment of different pages [Renan GEHAN] - Darken modal backdrop in dark mode [Renan GEHAN] - Minor UI tweaks in the Settings modal [Renan GEHAN] - Adapt FilterPredicate to dark mode [Renan GEHAN] - Reskin FilterEditModal [Renan GEHAN] - Reskin settings modal [Renan GEHAN] - Reskin dropdown [Renan GEHAN] - Add dark mode support in reskined components [Renan GEHAN] - Reskin app completely [Renan GEHAN] - Setup tailwindcss [Renan GEHAN] - Update compress script to add version to filename [Renan GEHAN] ### v0.3.0 - Showcase dark mode in the README [Renan GEHAN] - Improve Loader style in dark mode [Renan GEHAN] - Update yarn.lock with integrity entries [Renan GEHAN] - Improve layout of the settings modal [Renan GEHAN] - Add night time detection to toggle dark mode [Renan GEHAN] - Add a dark theme to all relevant components [Renan GEHAN] - Add a dark mode option [Renan GEHAN] ### v0.2.3 - Add ellipsis and overflow to repo cards titles (#46) [Renan GEHAN] - Add margins on Loader & error messages (#43) [GitHub] - Add refresh filter button (#41) [GitHub] - Add 'Last 2 Weeks' option to discover date ranges [Renan GEHAN] - Fix Dashboard container height when there are few cards [Renan GEHAN] ### v0.2.2 - Add icons on the browser_action so it works on FF [Renan GEHAN] ### v0.2.1 - Create util method for checking if url is a new tab url [Renan GEHAN] - Change new tab url check for firefox (#38) [Renan GEHAN] ### v0.2.0 - Update README to explain why the 'tabs' permission is required [Renan GEHAN] - Update README to explain how to disable the New Tab override [Renan GEHAN] - Allow setting the new tab behavior in the SettingsModal [Renan GEHAN] - Add a browser_action for opening OctoLenses [Renan GEHAN] - Reorganize background scripts to expose a single entry point [Renan GEHAN] - Update browser_action title [Renan GEHAN] - Let the decision of overriding the new tab page to a smart background script [Renan GEHAN] - Add icons [Renan GEHAN] - Change github personal access token creation link to prefill needed scope (#34) [Renan GEHAN] - Add link to the Firefox version [Renan GEHAN] - Improve README.md build instructions [Renan GEHAN] ### v0.1.5 - Refresh filters/trends when a token is set (fix #31) [Renan GEHAN] - Fix FilterEditModal title input width [Renan GEHAN] - Remove unnecessary permissions [Renan GEHAN] - Fix new filter default id not being applied [Renan GEHAN] - Ensure a failed filter error is removed once refetched successfully [Renan GEHAN] - Fix typos in README (#24) [Renan GEHAN] ### v0.1.4 - Add clone filter button (fix #16) [Renan GEHAN] - Improve filter actions UI in the Dashboard [Renan GEHAN] - Add default 'is:open' predicate to new filters [Renan GEHAN] - Ensure there is a filter selected after hydration [Renan GEHAN] - Use repo id as a key to prevent duplicate key warning when using repo name [Renan GEHAN] - Remove unused import [Renan GEHAN] - Fix FilterLink not reacting to loading/error state changes [Renan GEHAN] - Persist filters definitions, not their data [Renan GEHAN] - Persist settings and navigation store [Renan GEHAN] - Install mobx-persist & reorganize store bootstrap logic [Renan GEHAN] - Replace all rematch models by mobx stores [Renan GEHAN] - Fix order of babel plugins so that mobx decorators work as expected [Renan GEHAN] - Replace rematch with mobx [Renan GEHAN] ### v0.1.3 - Update README to reflect changes in release process [Renan GEHAN] - Create release script (fix #22) [Renan GEHAN] - Add placeholders to all predicates [Renan GEHAN] - Increase input predicate min-width to 250px [Renan GEHAN] - Add all review related filter predicates [Renan GEHAN] ================================================ FILE: cypress/e2e/discover.spec.js ================================================ context('Discover', () => { beforeEach(() => { cy.injectGithubToken(); cy.visit(Cypress.env('BASE_URL') + '/'); cy.get('[data-header-link=discover]').click(); }); it('loads a default set of repos', () => { cy.get('[data-id=loader]'); cy.get('[data-id=repo-card]'); }); it('can change the language', () => { // Let it load cy.get('[data-id=loader]'); cy.get('[data-id=repo-card]'); // Change the language to JavaScript cy.get('[data-id=dropdown-language').select('JavaScript'); // Let it load again, and check it loaded JavaScript repos cy.get('[data-id=loader]'); cy.get('[data-id=repo-card]').contains('JavaScript'); }); it('can change the period', () => { // Let it load cy.get('[data-id=loader]'); cy.get('[data-id=repo-card]'); // Change the language to JavaScript cy.get('[data-id=dropdown-dateRange').select('Last month'); // Let it load again, and check it loaded JavaScript repos cy.get('[data-id=loader]'); cy.get('[data-id=repo-card]'); }); it('redirects to the repo on click', () => { // Let it load, and assert it would have opened in a new tab on click cy.get('[data-id=loader]'); cy.get('[data-id=repo-card]:first-child [data-id=repo-link]').should( 'have.attr', 'target', '_blank' ); }); }); ================================================ FILE: cypress/e2e/filters.spec.js ================================================ const DEFAULT_FILTER_NAME = 'OctoLenses Issues'; context('Filters', () => { beforeEach(() => { cy.injectGithubToken(); cy.visit(Cypress.env('BASE_URL') + '/'); }); it('sees a default filter', () => { cy.contains(DEFAULT_FILTER_NAME); }); it('loads a list of issues from the default filter', () => { cy.contains('rgehan/octolenses'); }); it('can add a GitHub filter', () => { createFilter({ name: 'React PRs', predicates: [ { name: 'Repository', type: 'repo', value: 'facebook/react' }, { name: 'Type', type: 'type', value: 'PRs', isDropdown: true }, { name: 'Status', type: 'status', value: 'Open', isDropdown: true }, ], }); cy.get('[data-id=loader]'); cy.contains('React PRs'); }); it('can delete a filter', () => { createFilter({ name: 'Laravel stuff', predicates: [ { name: 'Repository', type: 'repo', value: 'laravel/framework' }, ], }); cy.contains(DEFAULT_FILTER_NAME).click(); cy.contains('Delete').click(); cy.contains(DEFAULT_FILTER_NAME).should('not.exist'); }); it('can edit a filter', () => { cy.contains(DEFAULT_FILTER_NAME).click(); cy.contains('Edit').click(); // Rename the filter cy.get('[data-id=filter-label-input]') .type('{selectAll}{del}') .type('Laravel PRs'); // Change the target repository cy.get(`[data-id=predicate-repo] [data-id=predicate-value-selector]`) .type('{selectAll}{del}') .type('laravel/framework'); // Save cy.contains('Continue').click(); // Check the edited filter is in the sidebar cy.get('[data-id=filter-links]').contains('Laravel PRs'); // Check we have cards corresponding to the new filter cy.get('[data-id=filter-results]').contains('laravel/framework'); }); it('can clone a filter', () => { cy.contains(DEFAULT_FILTER_NAME).click(); cy.contains('Clone').click(); cy.get('[data-id=filter-links]').contains(DEFAULT_FILTER_NAME + ' (Copy)'); }); it('can refresh a filter', () => { cy.contains(DEFAULT_FILTER_NAME).click(); // Waits for results to be displayed cy.get('[data-id=filter-results]').contains('rgehan/octolenses'); // Refresh cy.contains('Refresh').click(); // Wait for a loader to appear cy.get('[data-id=loader]'); // Check we have results again cy.get('[data-id=filter-results]').contains('rgehan/octolenses'); }); }); function createFilter({ name, predicates }) { // Open the filter modal cy.contains('Add').click(); // Select a GitHub filter cy.contains('GitHub').click(); cy.contains('Continue').click(); // Rename the filter cy.get('[data-id=filter-label-input]') .type('{selectAll}{del}') .type(name); predicates.forEach(({ name, type, value, isDropdown = false }) => { cy.get('[data-id=add-predicate-dropdown]').select(name); if (isDropdown) { cy.get( `[data-id=predicate-${type}] [data-id=predicate-value-selector]` ).select(value); } else { cy.get( `[data-id=predicate-${type}] [data-id=predicate-value-selector]` ).type(value); } }); // Save the filter cy.contains('Continue').click(); } ================================================ FILE: cypress/e2e/settings.spec.js ================================================ context('Discover', () => { beforeEach(() => { cy.visit(Cypress.env('BASE_URL') + '/'); cy.get('[data-header-link=settings]').click(); }); it('opens, then closes the settings modal', () => { cy.contains('Settings'); cy.contains('Close').click(); cy.contains('Settings').should('not.exist'); }); it('changes to night mode', () => { // Check it's not using dark mode cy.get('body.dark').should('not.exist'); // Switch to dark mode cy.contains('Night mode').click(); cy.contains('Always').click(); // Check dark mode is applied cy.get('body.dark'); }); it('clears the cache', () => { cy.contains('Cache').click(); cy.contains('Clear cache').click(); cy.contains('Cache was successfully cleared'); }); it('sets the GitHub token', () => { cy.contains('GitHub').click(); cy.get('input').type('my-token'); cy.contains('Save').click(); cy.contains('Token was saved'); }); }); ================================================ FILE: cypress/support/commands.js ================================================ Cypress.Commands.add('injectGithubToken', () => { cy.window().then(window => { const token = Cypress.env('GITHUB_TOKEN'); if (!token) { throw new Error( 'No CYPRESS_GITHUB_TOKEN environement variable was provided!' ); } window.localStorage.setItem( 'githubProvider', JSON.stringify({ settings: { token, }, }) ); }); }); ================================================ FILE: cypress/support/e2e.js ================================================ import './commands'; ================================================ FILE: cypress.config.ts ================================================ import { defineConfig } from 'cypress' export default defineConfig({ projectId: 'r2fbf6', watchForFileChanges: false, env: { BASE_URL: 'http://localhost:1234', }, fixturesFolder: false, e2e: { setupNodeEvents(on, config) {}, specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', }, }) ================================================ FILE: jest.config.js ================================================ module.exports = { preset: 'ts-jest/presets/js-with-ts', testMatch: ['/src/**/*.test.ts'], testEnvironment: 'jsdom', setupFiles: ['./src/setupTests.ts'], globals: { 'ts-jest': { diagnostics: false, }, }, }; ================================================ FILE: manifest.json ================================================ { "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq/S9dl/VwNTcp+T5ZLnOH8gOZoMkFTiKNsR6EDv3zUiEImaheCDMzFUhRiyxg1NY55RjB7oMcqFl2RQnMr8YIR4zmCN0VKljgzN4G81mH+tN+lSccKYOPcUed26nzzzSdxryUSg2QfadChtnCfFMaZxkwrQTn4So5pTyr9upPNDvL4BCXG1sxeZHpp5TDIJTNYlKngQzmWGd/yi0QBRwukeobk7nqiXzAoXkG2qHP3A+Y3YrGFsXfB3X1rM/1g8qnUHwYlv/MmLboOmRQdEBsKdstUmmGTQb6FuTAYVkTcaw/wtRHyd+Cdf9jTo2SVdU160zZ3Ngqr7B+cYvEx+htQIDAQAB", "manifest_version": 3, "name": "OctoLenses", "version": "", "short_name": "OctoLenses Browser Extension", "description": "Watch your repos and discover awesome things directly from your New Tab page.", "permissions": ["identity"], "host_permissions": [ "*://*.github.com/*", "https://use.fontawesome.com/*", "https://fonts.googleapis.com/*" ], "chrome_url_overrides": { "newtab": "index.html" }, "background": { "service_worker": "service_worker/index.js" }, "action": { "default_title": "OctoLenses", "default_icon": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" } }, "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" } } ================================================ FILE: package.json ================================================ { "name": "github-trending-chrome-extension", "targets": { "app": { "context": "browser", "distDir": "dist/", "source": "src/index.html" }, "service_worker": { "context": "service-worker", "distDir": "dist/service_worker/", "source": "src/service_worker/index.js" } }, "browserslist": "> 0.5%, last 2 versions, not dead", "version": "2.3.0", "author": "Renan GEHAN ", "license": "MIT", "dependencies": { "@babel/polyfill": "^7.8.3", "@types/object-hash": "^1.3.0", "babel-polyfill": "^6.26.0", "classnames": "^2.2.6", "clipboard": "^2.0.4", "contrast": "^1.0.1", "es6-error": "^4.1.1", "human-format": "^0.10.1", "immer": "^5.0.0", "lodash": "^4.17.15", "mobx": "^5.15.1", "mobx-persist": "^0.4.1", "mobx-react": "^6.1.4", "moment": "^2.22.2", "object-hash": "^2.0.1", "prop-types": "^15.7.2", "react": "^16.12.0", "react-dom": "^16.8.0", "react-sortable-hoc": "^1.10.1", "recompose": "^0.30.0", "styled-components": "^4.1.3", "timeago.js": "^4.0.1", "uuid": "^3.3.3" }, "devDependencies": { "@babel/core": "^7.8.7", "@babel/plugin-proposal-class-properties": "^7.7.0", "@babel/plugin-proposal-decorators": "^7.6.0", "@babel/plugin-proposal-object-rest-spread": "^7.8.3", "@parcel/config-default": "^2.10.0", "@parcel/transformer-sass": "2.10.0", "@parcel/transformer-typescript-tsc": "^2.10.0", "@types/chrome": "^0.0.239", "@types/classnames": "^2.2.9", "@types/clipboard": "^2.0.1", "@types/jest": "^24.0.22", "@types/lodash": "^4.14.146", "@types/node": "^20.3.3", "@types/react-dom": "^16.9.4", "@types/react-sortable-hoc": "^0.7.1", "@types/recompose": "^0.30.7", "@types/styled-components": "^4.1.20", "@types/uuid": "^3.4.4", "@typescript-eslint/eslint-plugin": "^2.8.0", "@typescript-eslint/parser": "^2.19.2", "autoprefixer": "^10.4.16", "cypress": "12.16.0", "eslint": "^6.7.2", "eslint-plugin-react": "^7.18.3", "jest": "^24.5.0", "jest-localstorage-mock": "^2.4.0", "parcel": "^2.0.0", "prettier": "^1.19.1", "process": "^0.11.10", "puppeteer": "^2.0.0", "rimraf": "^3.0.2", "sass": "^1.24.2", "tailwindcss": "2", "ts-jest": "^24.3.0", "typescript": "^3.8.3" }, "scripts": { "clean": "rimraf dist/ .parcel-cache", "start": "parcel serve --no-hmr --no-autoinstall src/index.html", "copyManifest": "cp manifest.json dist/manifest.json", "copyIcons": "cp -r icons/ dist/icons/", "syncManifestVersion": "version=$(jq -r .version package.json) && sed -i '' \"s//$version/\" dist/manifest.json && echo \"Updated manifest with version: $version\"", "build:unpacked": "yarn clean && parcel build && yarn copyManifest && yarn copyIcons && yarn syncManifestVersion", "compress": "cd dist/ && zip -rq ../octolenses-$(jq -r .version ../package.json).zip *", "build": "yarn build:unpacked && yarn compress", "release": "./scripts/release", "screenshots": "./scripts/screenshots", "test": "jest", "e2e": "cypress open", "lint": "eslint --ext ts,tsx src/" } } ================================================ FILE: postcss.config.js ================================================ const tailwindcss = require('tailwindcss'); // prettier-ignore module.exports = { plugins: [ tailwindcss(), require('autoprefixer'), ], }; ================================================ FILE: scripts/release ================================================ #!/usr/bin/env node const path = require('path'); const { execSync } = require('child_process'); const { readFileSync, writeFileSync } = require('fs'); const { includes, isBuffer } = require('lodash'); const semver = require('semver'); const RELEASE_TYPES = ['patch', 'minor', 'major']; const exec = command => { const stdout = execSync(command); return isBuffer(stdout) ? stdout.toString() : stdout; }; // Ensure the release type is valid const releaseType = process.argv[2]; if (!includes(RELEASE_TYPES, releaseType)) { console.log(`Usage: yarn release ${RELEASE_TYPES.join('|')}`); process.exit(1); } // Compute the new version number const packageJsonPath = path.resolve(__dirname, '../package.json'); const packageJson = require(packageJsonPath); const currentVersion = packageJson.version; const newVersion = semver.inc(currentVersion, releaseType); console.log(`1. Incrementing version from ${currentVersion} to ${newVersion}`); // Generate and write the changelog console.log('2. Writing changelog...'); const changelogPath = path.resolve(__dirname, '../changelog.md'); const changelogAdditions = exec(`git log v${currentVersion}..HEAD --pretty="- %s [%cn]"`); const existingChangelog = readFileSync(changelogPath); const newChangelog = `### v${newVersion}\n${changelogAdditions}\n${existingChangelog}`; writeFileSync(changelogPath, newChangelog); // Update package.json version & create the release commit console.log('3. Updating package.json version number...'); packageJson.version = newVersion; writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); console.log('4. Creating release commit...') exec(`git add changelog.md package.json`); exec(`git commit -m "v${newVersion}"`); console.log('5. Creating release tag...'); exec(`git tag v${newVersion}`); console.log('[INFO] To publish the changes, run:'); console.log('$ git push origin HEAD && git push --tags'); ================================================ FILE: scripts/screenshots ================================================ #!/usr/bin/env node const path = require('path'); const Bundler = require('parcel-bundler'); const puppeteer = require('puppeteer'); const { kebabCase } = require('lodash'); (async () => { const port = await startDevelopmentServer(); const { browser, page } = await setupPuppeteer(port); await takeScreenshots(page); console.log('All done.'); browser.close(); process.exit(0); })(); async function startDevelopmentServer() { console.log('Starting development server...'); const bundler = new Bundler(path.resolve(__dirname, '../src/index.html'), { hmr: false, }); const server = await bundler.serve(); console.log('Started.'); return server.address().port; } async function setupPuppeteer(port) { const browser = await puppeteer.launch({ // headless: false, // devtools: false, }); const page = await browser.newPage(); // Setup the viewport await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 }); await page.goto(`http://localhost:${port}`); return { browser, page }; } async function takeScreenshots(page) { // Move to the app, wait for it to load const DARK_MODES = ['DISABLED', 'ENABLED']; const SCENARIOS = [ screenshotDashboard, screenshotDiscover, screenshotFilterEditModal, screenshotFilterAddModal, screenshotSettings, ]; for (darkMode of DARK_MODES) { console.log(`[darkMode: ${darkMode}]`); await page.evaluate(darkMode => { window.stores.settingsStore.darkMode = darkMode; }, darkMode); const screenshotFolder = path.resolve( __dirname, '../.github/screenshots/', darkMode === 'ENABLED' ? 'dark' : 'light' ); for (scenario of SCENARIOS) { await scenario(page, screenshotFolder); } } } async function screenshotDashboard(page, screenshotFolder) { console.log('Capturing screenshot of the Dashboard page...'); await page.waitForFunction(() => !document.querySelector('[data-id=loader]')); await page.waitFor(300); await page.evaluate(() => { const filter = window.stores.filtersStore.data[0]; const identifiers = filter.data.slice(0, 2).map(filter => filter.number); filter.newItemsIdentifiers = identifiers; }); await page.screenshot({ path: path.resolve(screenshotFolder, './dashboard.png'), }); } async function screenshotDiscover(page, screenshotFolder) { console.log('Capturing screenshot of the Discover page...'); await page.click('[data-header-link=discover]'); await page.waitForFunction(() => !document.querySelector('[data-id=loader]')); await page.screenshot({ path: path.resolve(screenshotFolder, './discover.png'), }); await page.click('[data-header-link=dashboard]'); } async function screenshotFilterEditModal(page, screenshotFolder) { console.log('Capturing screenshot of the filter edition modal...'); await page.click('.fa-edit'); await page.waitFor(500); await page.screenshot({ path: path.resolve(screenshotFolder, './filter-edit.png'), }); await page.keyboard.press('Escape'); } async function screenshotFilterAddModal(page, screenshotFolder) { console.log('Capturing screenshot of the filter addition modal...'); await page.click('.fa-plus-square'); await page.waitFor(500); await page.screenshot({ path: path.resolve(screenshotFolder, './filter-add.png'), }); await page.keyboard.press('Escape'); } async function screenshotSettings(page, screenshotFolder) { console.log('Capturing screenshot of the night mode settings modal...'); // Open the settings modal await page.click('.fa-cog'); await page.waitFor(300); // Screenshot all tabs const SETTING_TABS = ['Night mode', 'Cache', 'GitHub', 'Jira']; for (tab of SETTING_TABS) { await page.click(`[data-setting-tab="${tab}"]`); await page.screenshot({ path: path.resolve(screenshotFolder, `./settings-${kebabCase(tab)}.png`), }); } await page.keyboard.press('Escape'); } ================================================ FILE: src/@types/contrast/index.d.ts ================================================ declare module 'contrast'; ================================================ FILE: src/@types/human-format/index.d.ts ================================================ declare module 'human-format'; ================================================ FILE: src/App.tsx ================================================ import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { Header, ToastManager } from './components'; import { Dashboard, Discover } from './pages'; import { NavigationStore } from './store/navigation'; const PAGES = { discover: Discover, dashboard: Dashboard, }; type PageName = keyof typeof PAGES; interface IInnerProps { navigationStore: NavigationStore; } export const App = compose( inject('navigationStore'), observer )(({ navigationStore }) => { const Page = PAGES[navigationStore.page as PageName]; return (
); }); ================================================ FILE: src/components/Button/Button.tsx ================================================ import cx from 'classnames'; import React, { ReactNode } from 'react'; export enum ButtonType { PRIMARY = 'primary', DEFAULT = 'default', } const TYPE_TO_CLASSES: Record = { primary: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white', default: 'bg-gray-200 hover:bg-gray-400 active:bg-gray-500 text-black', }; interface IProps { type?: ButtonType; onClick: () => void; children: ReactNode; className?: string; } export const Button = ({ type = ButtonType.DEFAULT, onClick, children, className = '', }: IProps) => ( ); ================================================ FILE: src/components/Button/index.ts ================================================ export { Button, ButtonType } from './Button'; ================================================ FILE: src/components/Dropdown/Dropdown.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React, { ChangeEvent } from 'react'; import { compose } from 'recompose'; import { SettingsStore } from '../../store/settings'; interface IProps { value: string; name: string; items: IOption[]; onChange: (option: IOption) => void; className?: string; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } interface IOption { value: string; name: string; } export const Dropdown = compose( inject('settingsStore'), observer )(({ name, items, value, className, settingsStore, onChange }) => { function handleChange(event: ChangeEvent) { onChange({ name, value: event.target.value }); } return (
); }); ================================================ FILE: src/components/Dropdown/index.ts ================================================ export { Dropdown } from './Dropdown'; ================================================ FILE: src/components/FilterLink/FilterLink.tsx ================================================ import cx from 'classnames'; import { size } from 'lodash'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { SortableElement, SortableHandle, SortableElementProps, } from 'react-sortable-hoc'; import { compose } from 'recompose'; import { Filter } from '../../store/models/filter'; import { SettingsStore } from '../../store/settings'; import { Loader } from '../Loader'; const DragHandle = SortableHandle( observer(({ isDark }: { isDark: boolean }) => ( )) ); interface IProps extends SortableElementProps { filter: Filter; isSelected: boolean; onClick: () => void; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const FilterLink = compose( SortableElement, inject('settingsStore'), observer )(({ filter, isSelected, onClick, settingsStore }) => { const { loading, error } = filter; const activeColor = settingsStore.isDark ? 'text-gray-500' : 'text-gray-900'; return (
{loading && } {!loading && error && } {!loading && !error && size(filter.data)} {filter.newItemsCount > 0 && !filter.loading && (
{filter.newItemsCount <= 99 ? ( {filter.newItemsCount} ) : ( )}
)}
{filter.label}
); }); ================================================ FILE: src/components/FilterLink/index.ts ================================================ export { FilterLink } from './FilterLink'; ================================================ FILE: src/components/FilterPredicate/FilterPredicate.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import styled from 'styled-components'; import { AbstractProvider } from '../../providers'; import { SettingsStore } from '../../store/settings'; import { OperatorSelector } from './OperatorSelector'; import { ValueSelector } from './ValueSelector'; const Wrapper = styled.div` .action-icon { display: none; } :hover { .action-icon { display: initial; } } `; interface IProps { type: string; operator: string; value: string; provider: AbstractProvider; onChange: (payload: object) => void; onDelete: () => void; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const FilterPredicate = compose( inject('settingsStore'), observer )(({ type, operator, value, provider, onDelete, onChange, settingsStore }) => { const predicate = provider.findPredicate(type); const handleChange = (key: string) => (newValue: string) => onChange({ type, operator, value, [key]: newValue, }); return (
{predicate.label}
); }); ================================================ FILE: src/components/FilterPredicate/OperatorSelector.tsx ================================================ import React from 'react'; import { Predicate } from '../../providers'; interface IProps { predicate: Predicate; value: string; onChange: (value: string) => void; } export const OperatorSelector = ({ predicate, value, onChange }: IProps) => { if (predicate.operators.length === 0) { return null; } return ( ); }; ================================================ FILE: src/components/FilterPredicate/ValueSelector.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { Predicate, PredicateType } from '../../providers'; import { SettingsStore } from '../../store/settings'; interface IProps { predicate: Predicate; value: string; onChange: (value: string) => void; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const ValueSelector = compose( inject('settingsStore'), observer )(({ predicate, value, onChange, settingsStore }) => { const baseStyle = cx( 'h-full flex-1 bg-transparent outline-none', settingsStore.isDark ? 'text-white' : 'text-gray-800' ); if (predicate.type === PredicateType.TEXT) { return ( onChange(event.target.value)} placeholder={predicate.placeholder} className={cx(baseStyle, 'pl-3')} data-id="predicate-value-selector" /> ); } if (predicate.type === PredicateType.DROPDOWN) { return ( ); } return null; }); ================================================ FILE: src/components/FilterPredicate/index.ts ================================================ export { FilterPredicate } from './FilterPredicate'; ================================================ FILE: src/components/Header/Header.tsx ================================================ import cx from 'classnames'; import { capitalize } from 'lodash'; import { inject, observer } from 'mobx-react'; import React, { useState } from 'react'; import { compose } from 'recompose'; import { SettingsModal } from '../../containers'; import { NavigationStore } from '../../store/navigation'; import { SettingsStore } from '../../store/settings'; import { TabLink } from './TabLink'; interface IInnerProps { settingsStore: SettingsStore; navigationStore: NavigationStore; } export const Header = compose( inject('settingsStore', 'navigationStore'), observer )(({ settingsStore, navigationStore }) => { const [modalOpen, setModalOpen] = useState(false); function renderLink(name: string) { const { page, navigateTo } = navigationStore; return ( navigateTo(name)} active={page === name} name={name} > {capitalize(name)} ); } return (
OctoLenses
{renderLink('dashboard')} {renderLink('discover')} setModalOpen(true)} name="settings">
{modalOpen && setModalOpen(false)} />}
); }); ================================================ FILE: src/components/Header/TabLink.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { SettingsStore } from '../../store/settings'; const COLORS = { dark: { active: 'text-white', inactive: 'text-gray-400 hover:text-white', }, light: { active: 'text-gray-800', inactive: 'text-gray-600 hover:text-gray-800', }, }; interface IProps { onClick: () => void; name: string; active?: boolean; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const TabLink = compose( inject('settingsStore'), observer )(({ children, name, onClick, active = false, settingsStore }) => ( {children} )); ================================================ FILE: src/components/Header/index.ts ================================================ export { Header } from './Header'; ================================================ FILE: src/components/Loader/Loader.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { SettingsStore } from '../../store/settings'; interface IProps { size?: number; strokeWidth?: number; className?: string; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const Loader = compose( inject('settingsStore'), observer )(({ size = 50, strokeWidth = 10, className, settingsStore }) => (
)); ================================================ FILE: src/components/Loader/index.ts ================================================ export { Loader } from './Loader'; ================================================ FILE: src/components/Modal/Modal.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React, { ReactNode, useEffect } from 'react'; import { compose } from 'recompose'; import styled, { keyframes } from 'styled-components'; import { SettingsStore } from '../../store/settings'; const fadeIn = keyframes` from { opacity: 0; } to { opacity: 1; } `; const Backdrop = styled.div` animation: ${fadeIn} 0.25s ease; `; const slideBottom = keyframes` from { transform: translateY(100%); } to { transform: translateY(0); } `; const Wrapper = styled.div` animation: ${slideBottom} 0.25s ease; `; interface IProps { children: ReactNode; onClose: () => void; className?: string; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const Modal = compose( inject('settingsStore'), observer )(({ children, onClose, settingsStore }) => { // Close the modal on ESC useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') { onClose(); } } window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); return (
Close
{children}
); }); ================================================ FILE: src/components/Modal/index.ts ================================================ export { Modal } from './Modal'; ================================================ FILE: src/components/RadioCard/RadioCard.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { SettingsStore } from '../../store/settings'; interface IProps { title: string; text: string; selected: boolean; icon?: string; dark?: boolean; onClick: () => void; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } const COLORS = { dark: { active: 'bg-gray-800 border-blue-500', inactive: 'bg-gray-800 border-gray-800', }, light: { active: 'border-blue-500 bg-blue-100 text-blue-800', inactive: 'border-gray bg-gray-100 text-gray-800', }, }; export const RadioCard = compose( inject('settingsStore'), observer )(({ title, text, selected, icon, onClick, settingsStore }) => (
{title}
{text}
{icon && (
)}
)); ================================================ FILE: src/components/RadioCard/index.ts ================================================ export { RadioCard } from './RadioCard'; ================================================ FILE: src/components/ToastManager/Toast.tsx ================================================ import cx from 'classnames'; import React from 'react'; import styled from 'styled-components'; import { INotification } from './types'; const TYPES_TO_ICON = { info: 'fa-info-circle', error: 'fa-exclamation-circle', }; const TYPES_TO_THEME = { info: 'bg-blue-500 text-white', error: 'bg-red-400 text-white', }; const TOAST_DURATION = 3000; const TOAST_FADE_DURATION = 200; export const ToastTypes = Object.keys(TYPES_TO_ICON); const Wrapper = styled.div` transition: opacity 0.2s; `; interface IProps extends INotification { onRemove: (id: string) => void; } export class Toast extends React.Component { public state = { visible: true, }; public componentDidMount() { setTimeout(this.discardToast, TOAST_DURATION); } public discardToast = () => { this.setState({ visible: false }); setTimeout(this.removeToast, TOAST_FADE_DURATION); }; public removeToast = () => { const { id, onRemove } = this.props; onRemove(id); }; public render() { const { message, type } = this.props; const { visible } = this.state; return ( {message} ); } } ================================================ FILE: src/components/ToastManager/ToastManager.tsx ================================================ import { reject } from 'lodash'; import React from 'react'; import { Toast } from './Toast'; import { INotification, NotificationType } from './types'; // This will contain a ToastManager instance reference so that toasts // can be created from anywhere without having to store toasts state // inside MobX or having to do weird findDOMNode stuff. let manager: ToastManager = null; const generateRandomId = () => Math.floor(Math.random() * 0x10000).toString(16); interface IState { notifications: INotification[]; } export class ToastManager extends React.Component<{}, IState> { public state: IState = { notifications: [], }; public componentDidMount() { manager = this; } public addNotification(message: string, type: NotificationType) { const { notifications } = this.state; const notification = { id: generateRandomId(), message, type, }; this.setState({ notifications: [notification, ...notifications], }); } public onRemoveNotification = (id: string) => { this.setState({ notifications: reject(this.state.notifications, { id }), }); }; public render() { const { notifications } = this.state; return (
{notifications.map(notification => ( ))}
); } } /** * Syntax sugar allowing to safely inject a toast inside the ToastManager singleton * @param {*} message * @param {*} type */ export function toast(message: string, type: NotificationType) { if (!manager) { return; } manager.addNotification(message, type); } ================================================ FILE: src/components/ToastManager/index.ts ================================================ export { ToastManager, toast } from './ToastManager'; ================================================ FILE: src/components/ToastManager/types.ts ================================================ export interface INotification { id: string; message: string; type: NotificationType; } export type NotificationType = 'info' | 'error'; ================================================ FILE: src/components/index.ts ================================================ export { Dropdown } from './Dropdown'; export { Loader } from './Loader'; export { Header } from './Header'; export { FilterLink } from './FilterLink'; export { ToastManager } from './ToastManager'; ================================================ FILE: src/constants/darkMode.ts ================================================ export const DARK_MODE = { DISABLED: 'DISABLED', ENABLED: 'ENABLED', AT_NIGHT: 'AT_NIGHT', }; ================================================ FILE: src/constants/dates.ts ================================================ import { find } from 'lodash'; import moment from 'moment'; export type DateType = | 'last_week' | 'last_two_weeks' | 'last_month' | 'last_six_months' | 'last_year' | 'anytime'; interface IDatePreset { name: string; value: DateType; data: { amount: any; unit: any; }; } export const DATES: IDatePreset[] = [ { name: 'Last week', value: 'last_week', data: { amount: 1, unit: 'week' } }, { name: 'Last 2 weeks', value: 'last_two_weeks', data: { amount: 2, unit: 'weeks' }, }, { name: 'Last month', value: 'last_month', data: { amount: 1, unit: 'month' }, }, { name: 'Last 6 months', value: 'last_six_months', data: { amount: 6, unit: 'months' }, }, { name: 'Last year', value: 'last_year', data: { amount: 1, unit: 'year' } }, { name: 'Anytime', value: 'anytime', data: { amount: 10, unit: 'years' } }, ]; /** * Returns a formatted date as used in a Github filter from * the value of the date object */ export const getDateFromValue = (value: DateType) => { const { data: { amount, unit }, } = find(DATES, { value }); return moment() .subtract(amount, unit) .format('YYYY-MM-DD'); }; ================================================ FILE: src/constants/languages.ts ================================================ export const LANGUAGES = [ { value: null, name: 'All Languages' }, { value: '1c-enterprise', name: '1C Enterprise' }, { value: 'abap', name: 'ABAP' }, { value: 'abnf', name: 'ABNF' }, { value: 'actionscript', name: 'ActionScript' }, { value: 'ada', name: 'Ada' }, { value: 'adobe-font-metrics', name: 'Adobe Font Metrics' }, { value: 'agda', name: 'Agda' }, { value: 'ags-script', name: 'AGS Script' }, { value: 'alloy', name: 'Alloy' }, { value: 'alpine-abuild', name: 'Alpine Abuild' }, { value: 'ampl', name: 'AMPL' }, { value: 'angelscript', name: 'AngelScript' }, { value: 'ant-build-system', name: 'Ant Build System' }, { value: 'antlr', name: 'ANTLR' }, { value: 'apacheconf', name: 'ApacheConf' }, { value: 'apex', name: 'Apex' }, { value: 'api-blueprint', name: 'API Blueprint' }, { value: 'apl', name: 'APL' }, { value: 'apollo-guidance-computer', name: 'Apollo Guidance Computer' }, { value: 'applescript', name: 'AppleScript' }, { value: 'arc', name: 'Arc' }, { value: 'asciidoc', name: 'AsciiDoc' }, { value: 'asn.1', name: 'ASN.1' }, { value: 'asp', name: 'ASP' }, { value: 'aspectj', name: 'AspectJ' }, { value: 'assembly', name: 'Assembly' }, { value: 'ats', name: 'ATS' }, { value: 'augeas', name: 'Augeas' }, { value: 'autohotkey', name: 'AutoHotkey' }, { value: 'autoit', name: 'AutoIt' }, { value: 'awk', name: 'Awk' }, { value: 'ballerina', name: 'Ballerina' }, { value: 'batchfile', name: 'Batchfile' }, { value: 'befunge', name: 'Befunge' }, { value: 'bison', name: 'Bison' }, { value: 'bitbake', name: 'BitBake' }, { value: 'blade', name: 'Blade' }, { value: 'blitzbasic', name: 'BlitzBasic' }, { value: 'blitzmax', name: 'BlitzMax' }, { value: 'bluespec', name: 'Bluespec' }, { value: 'boo', name: 'Boo' }, { value: 'brainfuck', name: 'Brainfuck' }, { value: 'brightscript', name: 'Brightscript' }, { value: 'bro', name: 'Bro' }, { value: 'c', name: 'C' }, { value: 'c%23', name: 'C#' }, { value: 'c++', name: 'C++' }, { value: 'c-objdump', name: 'C-ObjDump' }, { value: 'c2hs-haskell', name: 'C2hs Haskell' }, { value: "cap'n-proto", name: "Cap'n Proto" }, { value: 'cartocss', name: 'CartoCSS' }, { value: 'ceylon', name: 'Ceylon' }, { value: 'chapel', name: 'Chapel' }, { value: 'charity', name: 'Charity' }, { value: 'chuck', name: 'ChucK' }, { value: 'cirru', name: 'Cirru' }, { value: 'clarion', name: 'Clarion' }, { value: 'clean', name: 'Clean' }, { value: 'click', name: 'Click' }, { value: 'clips', name: 'CLIPS' }, { value: 'clojure', name: 'Clojure' }, { value: 'closure-templates', name: 'Closure Templates' }, { value: 'cloud-firestore-security-rules', name: 'Cloud Firestore Security Rules', }, { value: 'cmake', name: 'CMake' }, { value: 'cobol', name: 'COBOL' }, { value: 'coffeescript', name: 'CoffeeScript' }, { value: 'coldfusion', name: 'ColdFusion' }, { value: 'coldfusion-cfc', name: 'ColdFusion CFC' }, { value: 'collada', name: 'COLLADA' }, { value: 'common-lisp', name: 'Common Lisp' }, { value: 'common-workflow-language', name: 'Common Workflow Language' }, { value: 'component-pascal', name: 'Component Pascal' }, { value: 'conll-u', name: 'CoNLL-U' }, { value: 'cool', name: 'Cool' }, { value: 'coq', name: 'Coq' }, { value: 'cpp-objdump', name: 'Cpp-ObjDump' }, { value: 'creole', name: 'Creole' }, { value: 'crystal', name: 'Crystal' }, { value: 'cson', name: 'CSON' }, { value: 'csound', name: 'Csound' }, { value: 'csound-document', name: 'Csound Document' }, { value: 'csound-score', name: 'Csound Score' }, { value: 'css', name: 'CSS' }, { value: 'csv', name: 'CSV' }, { value: 'cuda', name: 'Cuda' }, { value: 'cweb', name: 'CWeb' }, { value: 'cycript', name: 'Cycript' }, { value: 'cython', name: 'Cython' }, { value: 'd', name: 'D' }, { value: 'd-objdump', name: 'D-ObjDump' }, { value: 'darcs-patch', name: 'Darcs Patch' }, { value: 'dart', name: 'Dart' }, { value: 'dataweave', name: 'DataWeave' }, { value: 'desktop', name: 'desktop' }, { value: 'diff', name: 'Diff' }, { value: 'digital-command-language', name: 'DIGITAL Command Language' }, { value: 'dm', name: 'DM' }, { value: 'dns-zone', name: 'DNS Zone' }, { value: 'dockerfile', name: 'Dockerfile' }, { value: 'dogescript', name: 'Dogescript' }, { value: 'dtrace', name: 'DTrace' }, { value: 'dylan', name: 'Dylan' }, { value: 'e', name: 'E' }, { value: 'eagle', name: 'Eagle' }, { value: 'easybuild', name: 'Easybuild' }, { value: 'ebnf', name: 'EBNF' }, { value: 'ec', name: 'eC' }, { value: 'ecere-projects', name: 'Ecere Projects' }, { value: 'ecl', name: 'ECL' }, { value: 'eclipse', name: 'ECLiPSe' }, { value: 'edje-data-collection', name: 'Edje Data Collection' }, { value: 'edn', name: 'edn' }, { value: 'eiffel', name: 'Eiffel' }, { value: 'ejs', name: 'EJS' }, { value: 'elixir', name: 'Elixir' }, { value: 'elm', name: 'Elm' }, { value: 'emacs-lisp', name: 'Emacs Lisp' }, { value: 'emberscript', name: 'EmberScript' }, { value: 'eq', name: 'EQ' }, { value: 'erlang', name: 'Erlang' }, { value: 'f%23', name: 'F#' }, { value: 'factor', name: 'Factor' }, { value: 'fancy', name: 'Fancy' }, { value: 'fantom', name: 'Fantom' }, { value: 'filebench-wml', name: 'Filebench WML' }, { value: 'filterscript', name: 'Filterscript' }, { value: 'fish', name: 'fish' }, { value: 'flux', name: 'FLUX' }, { value: 'formatted', name: 'Formatted' }, { value: 'forth', name: 'Forth' }, { value: 'fortran', name: 'Fortran' }, { value: 'freemarker', name: 'FreeMarker' }, { value: 'frege', name: 'Frege' }, { value: 'g-code', name: 'G-code' }, { value: 'game-maker-language', name: 'Game Maker Language' }, { value: 'gams', name: 'GAMS' }, { value: 'gap', name: 'GAP' }, { value: 'gcc-machine-description', name: 'GCC Machine Description' }, { value: 'gdb', name: 'GDB' }, { value: 'gdscript', name: 'GDScript' }, { value: 'genie', name: 'Genie' }, { value: 'genshi', name: 'Genshi' }, { value: 'gentoo-ebuild', name: 'Gentoo Ebuild' }, { value: 'gentoo-eclass', name: 'Gentoo Eclass' }, { value: 'gerber-image', name: 'Gerber Image' }, { value: 'gettext-catalog', name: 'Gettext Catalog' }, { value: 'gherkin', name: 'Gherkin' }, { value: 'glsl', name: 'GLSL' }, { value: 'glyph', name: 'Glyph' }, { value: 'gn', name: 'GN' }, { value: 'gnuplot', name: 'Gnuplot' }, { value: 'go', name: 'Go' }, { value: 'golo', name: 'Golo' }, { value: 'gosu', name: 'Gosu' }, { value: 'grace', name: 'Grace' }, { value: 'gradle', name: 'Gradle' }, { value: 'grammatical-framework', name: 'Grammatical Framework' }, { value: 'graph-modeling-language', name: 'Graph Modeling Language' }, { value: 'graphql', name: 'GraphQL' }, { value: 'graphviz-(dot)', name: 'Graphviz (DOT)' }, { value: 'groovy', name: 'Groovy' }, { value: 'groovy-server-pages', name: 'Groovy Server Pages' }, { value: 'hack', name: 'Hack' }, { value: 'haml', name: 'Haml' }, { value: 'handlebars', name: 'Handlebars' }, { value: 'harbour', name: 'Harbour' }, { value: 'haskell', name: 'Haskell' }, { value: 'haxe', name: 'Haxe' }, { value: 'hcl', name: 'HCL' }, { value: 'hiveql', name: 'HiveQL' }, { value: 'hlsl', name: 'HLSL' }, { value: 'html', name: 'HTML' }, { value: 'html+django', name: 'HTML+Django' }, { value: 'html+ecr', name: 'HTML+ECR' }, { value: 'html+eex', name: 'HTML+EEX' }, { value: 'html+erb', name: 'HTML+ERB' }, { value: 'html+php', name: 'HTML+PHP' }, { value: 'http', name: 'HTTP' }, { value: 'hxml', name: 'HXML' }, { value: 'hy', name: 'Hy' }, { value: 'hyphy', name: 'HyPhy' }, { value: 'idl', name: 'IDL' }, { value: 'idris', name: 'Idris' }, { value: 'igor-pro', name: 'IGOR Pro' }, { value: 'inform-7', name: 'Inform 7' }, { value: 'ini', name: 'INI' }, { value: 'inno-setup', name: 'Inno Setup' }, { value: 'io', name: 'Io' }, { value: 'ioke', name: 'Ioke' }, { value: 'irc-log', name: 'IRC log' }, { value: 'isabelle', name: 'Isabelle' }, { value: 'isabelle-root', name: 'Isabelle ROOT' }, { value: 'j', name: 'J' }, { value: 'jasmin', name: 'Jasmin' }, { value: 'java', name: 'Java' }, { value: 'java-server-pages', name: 'Java Server Pages' }, { value: 'javascript', name: 'JavaScript' }, { value: 'jflex', name: 'JFlex' }, { value: 'jison', name: 'Jison' }, { value: 'jison-lex', name: 'Jison Lex' }, { value: 'jolie', name: 'Jolie' }, { value: 'json', name: 'JSON' }, { value: 'json-with-comments', name: 'JSON with Comments' }, { value: 'json5', name: 'JSON5' }, { value: 'jsoniq', name: 'JSONiq' }, { value: 'jsonld', name: 'JSONLD' }, { value: 'jsx', name: 'JSX' }, { value: 'julia', name: 'Julia' }, { value: 'jupyter-notebook', name: 'Jupyter Notebook' }, { value: 'kicad-layout', name: 'KiCad Layout' }, { value: 'kicad-legacy-layout', name: 'KiCad Legacy Layout' }, { value: 'kicad-schematic', name: 'KiCad Schematic' }, { value: 'kit', name: 'Kit' }, { value: 'kotlin', name: 'Kotlin' }, { value: 'krl', name: 'KRL' }, { value: 'labview', name: 'LabVIEW' }, { value: 'lasso', name: 'Lasso' }, { value: 'latte', name: 'Latte' }, { value: 'lean', name: 'Lean' }, { value: 'less', name: 'Less' }, { value: 'lex', name: 'Lex' }, { value: 'lfe', name: 'LFE' }, { value: 'lilypond', name: 'LilyPond' }, { value: 'limbo', name: 'Limbo' }, { value: 'linker-script', name: 'Linker Script' }, { value: 'linux-kernel-module', name: 'Linux Kernel Module' }, { value: 'liquid', name: 'Liquid' }, { value: 'literate-agda', name: 'Literate Agda' }, { value: 'literate-coffeescript', name: 'Literate CoffeeScript' }, { value: 'literate-haskell', name: 'Literate Haskell' }, { value: 'livescript', name: 'LiveScript' }, { value: 'llvm', name: 'LLVM' }, { value: 'logos', name: 'Logos' }, { value: 'logtalk', name: 'Logtalk' }, { value: 'lolcode', name: 'LOLCODE' }, { value: 'lookml', name: 'LookML' }, { value: 'loomscript', name: 'LoomScript' }, { value: 'lsl', name: 'LSL' }, { value: 'lua', name: 'Lua' }, { value: 'm', name: 'M' }, { value: 'm4', name: 'M4' }, { value: 'm4sugar', name: 'M4Sugar' }, { value: 'makefile', name: 'Makefile' }, { value: 'mako', name: 'Mako' }, { value: 'markdown', name: 'Markdown' }, { value: 'marko', name: 'Marko' }, { value: 'mask', name: 'Mask' }, { value: 'mathematica', name: 'Mathematica' }, { value: 'matlab', name: 'Matlab' }, { value: 'maven-pom', name: 'Maven POM' }, { value: 'max', name: 'Max' }, { value: 'maxscript', name: 'MAXScript' }, { value: 'mediawiki', name: 'MediaWiki' }, { value: 'mercury', name: 'Mercury' }, { value: 'meson', name: 'Meson' }, { value: 'metal', name: 'Metal' }, { value: 'minid', name: 'MiniD' }, { value: 'mirah', name: 'Mirah' }, { value: 'modelica', name: 'Modelica' }, { value: 'modula-2', name: 'Modula-2' }, { value: 'modula-3', name: 'Modula-3' }, { value: 'module-management-system', name: 'Module Management System' }, { value: 'monkey', name: 'Monkey' }, { value: 'moocode', name: 'Moocode' }, { value: 'moonscript', name: 'MoonScript' }, { value: 'mql4', name: 'MQL4' }, { value: 'mql5', name: 'MQL5' }, { value: 'mtml', name: 'MTML' }, { value: 'muf', name: 'MUF' }, { value: 'mupad', name: 'mupad' }, { value: 'myghty', name: 'Myghty' }, { value: 'ncl', name: 'NCL' }, { value: 'nearley', name: 'Nearley' }, { value: 'nemerle', name: 'Nemerle' }, { value: 'nesc', name: 'nesC' }, { value: 'netlinx', name: 'NetLinx' }, { value: 'netlinx+erb', name: 'NetLinx+ERB' }, { value: 'netlogo', name: 'NetLogo' }, { value: 'newlisp', name: 'NewLisp' }, { value: 'nextflow', name: 'Nextflow' }, { value: 'nginx', name: 'Nginx' }, { value: 'nim', name: 'Nim' }, { value: 'ninja', name: 'Ninja' }, { value: 'nit', name: 'Nit' }, { value: 'nix', name: 'Nix' }, { value: 'nl', name: 'NL' }, { value: 'nsis', name: 'NSIS' }, { value: 'nu', name: 'Nu' }, { value: 'numpy', name: 'NumPy' }, { value: 'objdump', name: 'ObjDump' }, { value: 'objective-c', name: 'Objective-C' }, { value: 'objective-c++', name: 'Objective-C++' }, { value: 'objective-j', name: 'Objective-J' }, { value: 'ocaml', name: 'OCaml' }, { value: 'omgrofl', name: 'Omgrofl' }, { value: 'ooc', name: 'ooc' }, { value: 'opa', name: 'Opa' }, { value: 'opal', name: 'Opal' }, { value: 'opencl', name: 'OpenCL' }, { value: 'openedge-abl', name: 'OpenEdge ABL' }, { value: 'openrc-runscript', name: 'OpenRC runscript' }, { value: 'openscad', name: 'OpenSCAD' }, { value: 'opentype-feature-file', name: 'OpenType Feature File' }, { value: 'org', name: 'Org' }, { value: 'ox', name: 'Ox' }, { value: 'oxygene', name: 'Oxygene' }, { value: 'oz', name: 'Oz' }, { value: 'p4', name: 'P4' }, { value: 'pan', name: 'Pan' }, { value: 'papyrus', name: 'Papyrus' }, { value: 'parrot', name: 'Parrot' }, { value: 'parrot-assembly', name: 'Parrot Assembly' }, { value: 'parrot-internal-representation', name: 'Parrot Internal Representation', }, { value: 'pascal', name: 'Pascal' }, { value: 'pawn', name: 'PAWN' }, { value: 'pep8', name: 'Pep8' }, { value: 'perl', name: 'Perl' }, { value: 'perl-6', name: 'Perl 6' }, { value: 'php', name: 'PHP' }, { value: 'pic', name: 'Pic' }, { value: 'pickle', name: 'Pickle' }, { value: 'picolisp', name: 'PicoLisp' }, { value: 'piglatin', name: 'PigLatin' }, { value: 'pike', name: 'Pike' }, { value: 'plpgsql', name: 'PLpgSQL' }, { value: 'plsql', name: 'PLSQL' }, { value: 'pod', name: 'Pod' }, { value: 'pogoscript', name: 'PogoScript' }, { value: 'pony', name: 'Pony' }, { value: 'postcss', name: 'PostCSS' }, { value: 'postscript', name: 'PostScript' }, { value: 'pov-ray-sdl', name: 'POV-Ray SDL' }, { value: 'powerbuilder', name: 'PowerBuilder' }, { value: 'powershell', name: 'PowerShell' }, { value: 'processing', name: 'Processing' }, { value: 'prolog', name: 'Prolog' }, { value: 'propeller-spin', name: 'Propeller Spin' }, { value: 'protocol-buffer', name: 'Protocol Buffer' }, { value: 'public-key', name: 'Public Key' }, { value: 'pug', name: 'Pug' }, { value: 'puppet', name: 'Puppet' }, { value: 'pure-data', name: 'Pure Data' }, { value: 'purebasic', name: 'PureBasic' }, { value: 'purescript', name: 'PureScript' }, { value: 'python', name: 'Python' }, { value: 'python-console', name: 'Python console' }, { value: 'python-traceback', name: 'Python traceback' }, { value: 'q', name: 'q' }, { value: 'qmake', name: 'QMake' }, { value: 'qml', name: 'QML' }, { value: 'r', name: 'R' }, { value: 'racket', name: 'Racket' }, { value: 'ragel', name: 'Ragel' }, { value: 'raml', name: 'RAML' }, { value: 'rascal', name: 'Rascal' }, { value: 'raw-token-data', name: 'Raw token data' }, { value: 'rdoc', name: 'RDoc' }, { value: 'realbasic', name: 'REALbasic' }, { value: 'reason', name: 'Reason' }, { value: 'rebol', name: 'Rebol' }, { value: 'red', name: 'Red' }, { value: 'redcode', name: 'Redcode' }, { value: 'regular-expression', name: 'Regular Expression' }, { value: "ren'py", name: "Ren'Py" }, { value: 'renderscript', name: 'RenderScript' }, { value: 'restructuredtext', name: 'reStructuredText' }, { value: 'rexx', name: 'REXX' }, { value: 'rhtml', name: 'RHTML' }, { value: 'ring', name: 'Ring' }, { value: 'rmarkdown', name: 'RMarkdown' }, { value: 'robotframework', name: 'RobotFramework' }, { value: 'roff', name: 'Roff' }, { value: 'rouge', name: 'Rouge' }, { value: 'rpc', name: 'RPC' }, { value: 'rpm-spec', name: 'RPM Spec' }, { value: 'ruby', name: 'Ruby' }, { value: 'runoff', name: 'RUNOFF' }, { value: 'rust', name: 'Rust' }, { value: 'sage', name: 'Sage' }, { value: 'saltstack', name: 'SaltStack' }, { value: 'sas', name: 'SAS' }, { value: 'sass', name: 'Sass' }, { value: 'scala', name: 'Scala' }, { value: 'scaml', name: 'Scaml' }, { value: 'scheme', name: 'Scheme' }, { value: 'scilab', name: 'Scilab' }, { value: 'scss', name: 'SCSS' }, { value: 'sed', name: 'sed' }, { value: 'self', name: 'Self' }, { value: 'shaderlab', name: 'ShaderLab' }, { value: 'shell', name: 'Shell' }, { value: 'shellsession', name: 'ShellSession' }, { value: 'shen', name: 'Shen' }, { value: 'slash', name: 'Slash' }, { value: 'slim', name: 'Slim' }, { value: 'smali', name: 'Smali' }, { value: 'smalltalk', name: 'Smalltalk' }, { value: 'smarty', name: 'Smarty' }, { value: 'smt', name: 'SMT' }, { value: 'solidity', name: 'Solidity' }, { value: 'sourcepawn', name: 'SourcePawn' }, { value: 'sparql', name: 'SPARQL' }, { value: 'spline-font-database', name: 'Spline Font Database' }, { value: 'sqf', name: 'SQF' }, { value: 'sql', name: 'SQL' }, { value: 'sqlpl', name: 'SQLPL' }, { value: 'squirrel', name: 'Squirrel' }, { value: 'srecode-template', name: 'SRecode Template' }, { value: 'stan', name: 'Stan' }, { value: 'standard-ml', name: 'Standard ML' }, { value: 'stata', name: 'Stata' }, { value: 'ston', name: 'STON' }, { value: 'stylus', name: 'Stylus' }, { value: 'subrip-text', name: 'SubRip Text' }, { value: 'sugarss', name: 'SugarSS' }, { value: 'supercollider', name: 'SuperCollider' }, { value: 'svg', name: 'SVG' }, { value: 'swift', name: 'Swift' }, { value: 'systemverilog', name: 'SystemVerilog' }, { value: 'tcl', name: 'Tcl' }, { value: 'tcsh', name: 'Tcsh' }, { value: 'tea', name: 'Tea' }, { value: 'terra', name: 'Terra' }, { value: 'tex', name: 'TeX' }, { value: 'text', name: 'Text' }, { value: 'textile', name: 'Textile' }, { value: 'thrift', name: 'Thrift' }, { value: 'ti-program', name: 'TI Program' }, { value: 'tla', name: 'TLA' }, { value: 'toml', name: 'TOML' }, { value: 'turing', name: 'Turing' }, { value: 'turtle', name: 'Turtle' }, { value: 'twig', name: 'Twig' }, { value: 'txl', name: 'TXL' }, { value: 'type-language', name: 'Type Language' }, { value: 'typescript', name: 'TypeScript' }, { value: 'unified-parallel-c', name: 'Unified Parallel C' }, { value: 'unity3d-asset', name: 'Unity3D Asset' }, { value: 'unix-assembly', name: 'Unix Assembly' }, { value: 'uno', name: 'Uno' }, { value: 'unrealscript', name: 'UnrealScript' }, { value: 'urweb', name: 'UrWeb' }, { value: 'vala', name: 'Vala' }, { value: 'vcl', name: 'VCL' }, { value: 'verilog', name: 'Verilog' }, { value: 'vhdl', name: 'VHDL' }, { value: 'vim-script', name: 'Vim script' }, { value: 'visual-basic', name: 'Visual Basic' }, { value: 'volt', name: 'Volt' }, { value: 'vue', name: 'Vue' }, { value: 'wavefront-material', name: 'Wavefront Material' }, { value: 'wavefront-object', name: 'Wavefront Object' }, { value: 'wdl', name: 'wdl' }, { value: 'web-ontology-language', name: 'Web Ontology Language' }, { value: 'webassembly', name: 'WebAssembly' }, { value: 'webidl', name: 'WebIDL' }, { value: 'wisp', name: 'wisp' }, { value: 'world-of-warcraft-addon-data', name: 'World of Warcraft Addon Data', }, { value: 'x-bitmap', name: 'X BitMap' }, { value: 'x-pixmap', name: 'X PixMap' }, { value: 'x10', name: 'X10' }, { value: 'xbase', name: 'xBase' }, { value: 'xc', name: 'XC' }, { value: 'xcompose', name: 'XCompose' }, { value: 'xml', name: 'XML' }, { value: 'xojo', name: 'Xojo' }, { value: 'xpages', name: 'XPages' }, { value: 'xproc', name: 'XProc' }, { value: 'xquery', name: 'XQuery' }, { value: 'xs', name: 'XS' }, { value: 'xslt', name: 'XSLT' }, { value: 'xtend', name: 'Xtend' }, { value: 'yacc', name: 'Yacc' }, { value: 'yaml', name: 'YAML' }, { value: 'yang', name: 'YANG' }, { value: 'yara', name: 'YARA' }, { value: 'zephir', name: 'Zephir' }, { value: 'zimpl', name: 'Zimpl' }, ]; ================================================ FILE: src/containers/FilterEditModal/FilterEditModal.tsx ================================================ import { pick } from 'lodash'; import { toJS } from 'mobx'; import { inject, observer } from 'mobx-react'; import React, { useState } from 'react'; import { compose } from 'recompose'; import styled from 'styled-components'; import { Modal } from '../../components/Modal'; import { providers, ProviderType } from '../../providers'; import { Filter, FiltersStore } from '../../store/filters'; import { PredicatesStep } from './PredicatesStep'; import { ProviderStep } from './ProviderStep'; const Container = styled.div` width: 650px; `; enum STEPS { PROVIDERS, PREDICATES, } interface IProps { initialFilter?: Filter; onClose: () => void; } interface IInnerProps extends IProps { filtersStore: FiltersStore; } export const FilterEditModal = compose( inject('filtersStore'), observer )(({ initialFilter, onClose, filtersStore }) => { const [step, setStep] = useState( initialFilter ? STEPS.PREDICATES : STEPS.PROVIDERS ); const defaultedFilter = defaultFilter(initialFilter); const [provider, setProvider] = useState(defaultedFilter.provider); const [label, setLabel] = useState(defaultedFilter.label); const [predicates, setPredicates] = useState(defaultedFilter.predicates); function handleSave() { filtersStore.saveFilter({ id: defaultedFilter.id, provider, label, predicates, }); onClose(); } return ( {step === STEPS.PROVIDERS && ( setStep(STEPS.PREDICATES)} /> )} {step === STEPS.PREDICATES && ( )} ); }); function defaultFilter(filter?: Filter) { if (!filter) { return { id: undefined, provider: ProviderType.GITHUB, label: 'Unnamed filter', predicates: [], }; } return pick(toJS(filter), ['id', 'provider', 'label', 'predicates']); } ================================================ FILE: src/containers/FilterEditModal/PredicatesStep.tsx ================================================ import cx from 'classnames'; import { chain, get } from 'lodash'; import { inject, observer } from 'mobx-react'; import React, { ChangeEvent, useEffect } from 'react'; import { compose } from 'recompose'; import { Button, ButtonType } from '../../components/Button'; import { FilterPredicate } from '../../components/FilterPredicate'; import { AbstractProvider, IStoredPredicate } from '../../providers'; import { SettingsStore } from '../../store/settings'; interface IProps { label: string; predicates: any[]; // TODO provider: AbstractProvider; setLabel: (name: string) => void; setPredicates: (predicates: any[]) => void; // TODO previous: () => void; next: () => void; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const PredicatesStep = compose( inject('settingsStore'), observer )( ({ label, predicates, provider, setLabel, setPredicates, previous, next, settingsStore, }) => { // Save on Enter useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Enter') { next(); } } window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [next]); /** * Add a new predicate to the list of predicates */ function handleAddPredicate(event: ChangeEvent) { const predicate = provider.findPredicate(event.target.value); if (!predicate) { return; } setPredicates([ ...predicates, { type: predicate.name, operator: get(predicate, 'operators.0.value'), value: '', }, ]); } /** * Update a predicate in place * @param index Index at which the predicate is located */ const handlePredicateChange = (index: number) => ({ value, operator, }: IStoredPredicate) => { setPredicates([ ...predicates.slice(0, index), { ...predicates[index], value, operator }, ...predicates.slice(index + 1), ]); }; /** * Remove a predicate * @param index Index at which the predicate is located */ const handlePredicateDeletion = (index: number) => () => { setPredicates([ ...predicates.slice(0, index), ...predicates.slice(index + 1), ]); }; return (
Filter name
setLabel(event.target.value)} data-id="filter-label-input" />
Predicates
{predicates.map((predicate, index) => ( ))}
); } ); ================================================ FILE: src/containers/FilterEditModal/ProviderStep.tsx ================================================ import React from 'react'; import { Button, ButtonType } from '../../components/Button'; import { RadioCard } from '../../components/RadioCard'; import { ProviderType } from '../../providers'; interface IProps { provider: ProviderType; onChange: (provider: ProviderType) => void; previous: () => void; next: () => void; } export const ProviderStep = ({ provider, onChange, previous, next, }: IProps) => { return (
Where do you want to get data from?
onChange(ProviderType.GITHUB)} /> onChange(ProviderType.JIRA)} />
); }; ================================================ FILE: src/containers/FilterEditModal/index.ts ================================================ export { FilterEditModal } from './FilterEditModal'; ================================================ FILE: src/containers/RepoCard/RepoCard.tsx ================================================ /* eslint-disable @typescript-eslint/camelcase */ import cx from 'classnames'; import humanFormat from 'human-format'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { SettingsStore } from '../../store/settings'; interface IProps { repo: any; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const RepoCard = compose( inject('settingsStore'), observer )(({ repo, settingsStore }) => { const { name, description, stargazers_count, forks_count, open_issues_count, html_url, language, owner: { avatar_url, html_url: profile_url }, } = repo; return (
{description}
{language &&
{language}
}
{' '} {humanFormat(stargazers_count, { decimals: 1, separator: '' })}
{' '} {humanFormat(forks_count, { decimals: 1, separator: '' })}
{' '} {humanFormat(open_issues_count, { decimals: 1, separator: '' })}
); }); ================================================ FILE: src/containers/RepoCard/index.ts ================================================ export { RepoCard } from './RepoCard'; ================================================ FILE: src/containers/SettingsModal/Panel.tsx ================================================ import { find } from 'lodash'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { SettingsStore } from '../../store/settings'; import { SETTINGS_VIEWS } from './constants'; interface IProps { selectedTab: string; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const Panel = compose( inject('settingsStore'), observer )(({ selectedTab, settingsStore }) => { const view = find(SETTINGS_VIEWS, { id: selectedTab }); if (!view) { return null; } const Component = view.component; return ; }); ================================================ FILE: src/containers/SettingsModal/SettingsModal.tsx ================================================ import React, { useState } from 'react'; import styled from 'styled-components'; import { Modal } from '../../components/Modal'; import { SETTINGS_VIEWS } from './constants'; import { Panel } from './Panel'; import { Sidebar } from './Sidebar'; const Container = styled.div` width: 900px; `; interface IProps { onClose: () => void; } export function SettingsModal({ onClose }: IProps) { const [selectedTab, selectTab] = useState(SETTINGS_VIEWS[0].id); return ( ); } ================================================ FILE: src/containers/SettingsModal/Sidebar.tsx ================================================ import cx from 'classnames'; import { partition } from 'lodash'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import styled from 'styled-components'; import { SettingsStore } from '../../store/settings'; import { SETTINGS_VIEWS } from './constants'; import { ISettingView } from './types'; const Wrapper = styled.div` width: 200px; font-family: Roboto; padding-right: 30px; flex-shrink: 0; `; const ItemHeader = styled.div` font-size: 16px; font-weight: 500; letter-spacing: 0.1em; padding-left: 12px; margin-bottom: 14px; `; const Item = styled.div` font-size: 18px; padding: 6px; padding-left: 12px; margin-bottom: 10px; cursor: pointer; `; interface IProps { selectedTab: string; selectTab: Function; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const Sidebar = compose( inject('settingsStore'), observer )(({ selectedTab, selectTab, settingsStore }) => { const [providerItems, staticItems] = partition(SETTINGS_VIEWS, 'isProvider'); function renderItems(items: ISettingView[]) { return items.map(({ label, id }) => ( selectTab(id)} className={cx( selectedTab === id && 'text-white bg-blue-500 font-medium rounded', settingsStore.isDark && 'text-gray-300' )} data-setting-tab={label} > {label} )); } const headerClass = settingsStore.isDark ? 'text-gray-600' : 'text-gray-500'; return ( Settings {renderItems(staticItems)} Providers {renderItems(providerItems)} ); }); ================================================ FILE: src/containers/SettingsModal/constants.ts ================================================ import { map } from 'lodash'; import { providers } from '../../providers'; import { CacheSettings, NightMode } from './tabs'; import { ISettingView } from './types'; export const SETTINGS_VIEWS: ISettingView[] = [ { id: 'night_mode', label: 'Night mode', component: NightMode, }, { id: 'cache', label: 'Cache', component: CacheSettings, }, ...map(providers, ({ label, settingsComponent }, key) => ({ id: key, label, component: settingsComponent, isProvider: true, })), ]; ================================================ FILE: src/containers/SettingsModal/index.ts ================================================ export { SettingsModal } from './SettingsModal'; ================================================ FILE: src/containers/SettingsModal/tabs/Cache.tsx ================================================ import React from 'react'; import { Button, ButtonType } from '../../../components/Button'; import { toast } from '../../../components/ToastManager'; import { Cache } from '../../../lib/cache'; export const CacheSettings = () => { function flushCache() { Cache.flush(); toast('Cache was successfully cleared', 'info'); } return (
Cached data

In order to limit the amount of requests to the various providers, a lot of them are actually cached. If for some reason you noticed inconsistencies or outdated data, please file an issue.

In the meantime, you can clear the cached data to get back to a coherent state.

); }; ================================================ FILE: src/containers/SettingsModal/tabs/NightMode.tsx ================================================ import React, { useEffect, useState } from 'react'; import { RadioCard } from '../../../components/RadioCard'; import { DARK_MODE } from '../../../constants/darkMode'; import { SettingsStore } from '../../../store/settings'; interface IProps { settings: SettingsStore; } export const NightMode = ({ settings }: IProps) => { const [darkMode, setDarkMode] = useState(settings.darkMode); useEffect(() => settings.updateDarkMode(darkMode), [darkMode]); return (
When should dark mode be enabled?
setDarkMode(DARK_MODE.DISABLED)} dark={settings.isDark} /> setDarkMode(DARK_MODE.ENABLED)} dark={settings.isDark} /> setDarkMode(DARK_MODE.AT_NIGHT)} dark={settings.isDark} />
); }; ================================================ FILE: src/containers/SettingsModal/tabs/index.ts ================================================ export { NightMode } from './NightMode'; export { CacheSettings } from './Cache'; ================================================ FILE: src/containers/SettingsModal/types.ts ================================================ export interface ISettingView { id: string; label: string; component: any; isProvider?: boolean; } ================================================ FILE: src/containers/index.ts ================================================ export { RepoCard } from './RepoCard'; export { FilterEditModal } from './FilterEditModal'; export { SettingsModal } from './SettingsModal'; ================================================ FILE: src/errors/InvalidCredentials.ts ================================================ import ExtendableError from 'es6-error'; export class InvalidCredentials extends ExtendableError { constructor(message = 'Your token seems to be invalid') { super(message); } } ================================================ FILE: src/errors/NeedTokenError.ts ================================================ import ExtendableError from 'es6-error'; export class NeedTokenError extends ExtendableError { constructor(message = 'Fetching failed. Try to set a Github token') { super(message); } } ================================================ FILE: src/errors/RateLimitError.ts ================================================ import ExtendableError from 'es6-error'; export class RateLimitError extends ExtendableError { public remainingRateLimit: number; constructor(remainingRateLimit: number) { super('Rate limited. Set a Github token to raise the limit'); this.remainingRateLimit = Math.round(remainingRateLimit); } } ================================================ FILE: src/errors/index.ts ================================================ export { NeedTokenError } from './NeedTokenError'; export { RateLimitError } from './RateLimitError'; export { InvalidCredentials } from './InvalidCredentials'; ================================================ FILE: src/index.html ================================================ OctoLenses - Github Dashboard
================================================ FILE: src/index.scss ================================================ @tailwind base; @tailwind components; @tailwind utilities; @import url('https://fonts.googleapis.com/css?family=Open+Sans|Roboto:400,500'); html { line-height: 1.15; } body { @apply text-gray-900 font-open; @apply m-0 min-h-screen; @apply bg-gray-100; font-size: 100%; &.dark { @apply bg-gray-900 text-white; } } h1, h2, h3, h4, h5, h6 { @apply font-roboto; } a, a:hover, a:active, a:visited { @apply no-underline; } #container { @apply min-h-full mx-auto; @apply px-8; max-width: 1180px; .App { @apply min-h-full flex flex-col; } } .rtl { direction: rtl; } ================================================ FILE: src/index.tsx ================================================ import 'babel-polyfill'; import { Provider } from 'mobx-react'; import React from 'react'; import ReactDOM from 'react-dom'; import { App } from './App'; import { bootstrap, filtersStore, navigationStore, settingsStore, trendsStore, } from './store'; bootstrap(); ReactDOM.render( , document.getElementById('container') ); ================================================ FILE: src/lib/assertUnreachable.ts ================================================ export function assertUnreachable(_: never, defaultValue: T): T { return defaultValue; } ================================================ FILE: src/lib/cache.ts ================================================ import { chain, startsWith } from 'lodash'; interface ICacheEntry { expiresAt: number; value: T; } export class Cache { public static prefix = 'cache'; /** * Remember the result of an expensive computation or data fetching, during a * certain period. The result is identified by a unique key. * @param key Key at which to store the result of the computation * @param lifespan How long, in second, to store the item * @param computer The method that computes the value, if not in the cache */ public static async remember( key: string, lifespan: number, computer: () => Promise ): Promise { const cached = Cache.get(key); if (cached) { if (Date.now() < cached.expiresAt) { return cached.value; } Cache.forget(key); } const value = await computer(); Cache.put(key, value, lifespan); return value; } /** * Retrieve an item from the cache * @param key Key at which the item is stored */ public static get(key: string): ICacheEntry { const item = localStorage.getItem(Cache.getPrefixedKey(key)); try { return JSON.parse(item); } catch (error) { // Do nothing } return null; } /** * Put an item into the cache * @param key Key at which to store the cached item * @param value Item to cache * @param lifespan How long, in seconds, we want to keep the item */ public static put(key: string, value: any, lifespan: number) { localStorage.setItem( Cache.getPrefixedKey(key), JSON.stringify({ expiresAt: Date.now() + lifespan * 1000, value, }) ); } /** * Forget a cached item * @param key Key at which the item is stored */ public static forget(key: string) { localStorage.removeItem(Cache.getPrefixedKey(key)); } /** * Flush the whole cache */ public static flush() { Cache.getCacheKeys().forEach(Cache.forget); } /** * Flush all the expired entries from the cache */ public static flushExpired() { Cache.getCacheKeys() .filter(key => Cache.get(key).expiresAt < Date.now()) .forEach(Cache.forget); } /** * Return a prefixed key * @param key */ private static getPrefixedKey(key: string) { if (Cache.isPrefixed(key)) { return key; } return `${Cache.prefix}:${key}`; } /** * Return whether a key is already prefixed or not * @param key */ private static isPrefixed(key: string) { return startsWith(key, `${Cache.prefix}:`); } /** * Return all the keys stored in the cache */ private static getCacheKeys() { const cachePrefix = `${Cache.prefix}:`; return chain(localStorage) .keys() .filter(key => startsWith(key, cachePrefix)) .value(); } } ================================================ FILE: src/lib/github/index.ts ================================================ export { fetchTrendingRepos } from './trending'; ================================================ FILE: src/lib/github/trending/index.ts ================================================ import hash from 'object-hash'; import { Cache } from '../../../lib/cache'; import { client } from '../../../providers/github/fetchers/client'; interface IFetchTrendingReposParams { language: string; date: string; token?: string; } /** * Fetch the trending repositories from GitHub * @param language What programming language the user is interested in * @param date From which date * @param token Github token of the user */ export const fetchTrendingRepos = async ({ language, date, token, }: IFetchTrendingReposParams) => { let query = `created:>${date}`; if (language !== null) { query += ` and language:${language}`; } const cacheKey = `github.trending.${hash(query)}`; const { items: repos } = await Cache.remember(cacheKey, 60 * 60, () => client({ endpoint: '/search/repositories', qs: `per_page=100&q=${query}&sort=stars&order=desc`, token, }) ); return repos; }; ================================================ FILE: src/migrations/index.ts ================================================ /* eslint-disable @typescript-eslint/camelcase */ import { IMigration } from './types'; import v0_to_v1 from './v0-to-v1'; import v1_to_v2 from './v1-to-v2'; import v2_to_v3 from './v2-to-v3'; import './testing-utils'; class Migrator { private migrations: IMigration[] = []; public migrate() { console.log('[migration] Running necessary migrations...'); this.migrations.forEach(migration => { if (migration.shouldRun()) { console.log(`[migration] Running migration ${migration.name}`); migration.run(); } }); } public registerMigration(migration: IMigration): Migrator { this.migrations.push(migration); return this; } } export const migrator = new Migrator() .registerMigration(new v0_to_v1()) .registerMigration(new v1_to_v2()) .registerMigration(new v2_to_v3()); ================================================ FILE: src/migrations/mocks/index.ts ================================================ /* eslint-disable @typescript-eslint/camelcase */ import v0 from './v0'; import v1 from './v1'; import v1_withoutToken from './v1-without-token'; import v2 from './v2'; import v2_withNegatedPredicates from './v2-with-negated-predicates'; import v3_withDefaultedOperators from './v3-with-defaulted-operators'; const allMocks: Record = { v0, v1, v1_withoutToken, v2, v2_withNegatedPredicates, v3_withDefaultedOperators, }; export default allMocks; ================================================ FILE: src/migrations/mocks/v0.ts ================================================ export default { filtersStore: { data: [ { id: '5c2382a0-5216-11e9-ad7a-73a635a52ed2', label: 'OctoLenses Issues', predicates: [ { type: 'type', value: 'issues' }, { type: 'repo', value: 'rgehan/octolenses', negated: false }, { type: 'status', value: 'open' }, ], }, { id: '92da29c0-5216-11e9-ad7a-73a635a52ed2', label: 'My Private Filter', predicates: [ { type: 'status', value: 'open' }, { type: 'repo', value: 'botify-hq/botify-report', negated: false, }, { type: 'author', value: 'rgehan', negated: false }, { type: 'status', value: 'open', negated: false }, ], }, ], }, settingsStore: { language: null, dateRange: 'last_week', token: '', wasOnboarded: true, darkMode: 'DISABLED', }, useNewTabPage: true, }; ================================================ FILE: src/migrations/mocks/v1-without-token.ts ================================================ export default { filtersStore: { data: [ { id: '5c2382a0-5216-11e9-ad7a-73a635a52ed2', label: 'OctoLenses Issues', predicates: [ { type: 'type', value: 'issues' }, { type: 'repo', value: 'rgehan/octolenses', negated: false }, { type: 'status', value: 'open' }, ], }, { id: '92da29c0-5216-11e9-ad7a-73a635a52ed2', label: 'My Private Filter', predicates: [ { type: 'status', value: 'open' }, { type: 'repo', value: 'botify-hq/botify-report', negated: false, }, { type: 'author', value: 'rgehan', negated: false }, { type: 'status', value: 'open', negated: false }, ], }, ], }, settingsStore: { language: null, dateRange: 'last_week', wasOnboarded: true, darkMode: 'DISABLED', schemaVersion: 1, }, useNewTabPage: true, }; ================================================ FILE: src/migrations/mocks/v1.ts ================================================ export default { filtersStore: { data: [ { id: '5c2382a0-5216-11e9-ad7a-73a635a52ed2', label: 'OctoLenses Issues', predicates: [ { type: 'type', value: 'issues' }, { type: 'repo', value: 'rgehan/octolenses', negated: false }, { type: 'status', value: 'open' }, ], }, { id: '92da29c0-5216-11e9-ad7a-73a635a52ed2', label: 'My Private Filter', predicates: [ { type: 'status', value: 'open' }, { type: 'repo', value: 'botify-hq/botify-report', negated: false, }, { type: 'author', value: 'rgehan', negated: false }, { type: 'status', value: 'open', negated: false }, ], }, ], }, settingsStore: { language: null, dateRange: 'last_week', token: '', wasOnboarded: true, darkMode: 'DISABLED', schemaVersion: 1, }, useNewTabPage: true, }; ================================================ FILE: src/migrations/mocks/v2-with-negated-predicates.ts ================================================ export default { filtersStore: { data: [ { id: '5c2382a0-5216-11e9-ad7a-73a635a52ed2', label: 'OctoLenses Issues', provider: 'github', predicates: [ { type: 'type', value: 'issues' }, { type: 'repo', value: 'rgehan/octolenses', negated: true }, { type: 'status', value: 'open', negated: false }, { type: 'author', value: 'rgehan', negated: false }, ], }, ], }, settingsStore: { language: null, dateRange: 'last_week', wasOnboarded: true, darkMode: 'DISABLED', schemaVersion: 2, }, useNewTabPage: true, githubProvider: { settings: { token: '', }, }, }; ================================================ FILE: src/migrations/mocks/v2.ts ================================================ export default { filtersStore: { data: [ { id: '5c2382a0-5216-11e9-ad7a-73a635a52ed2', label: 'OctoLenses Issues', provider: 'github', predicates: [ { type: 'type', value: 'issues' }, { type: 'repo', value: 'rgehan/octolenses', negated: false }, { type: 'status', value: 'open' }, ], }, { id: '92da29c0-5216-11e9-ad7a-73a635a52ed2', label: 'My Private Filter', provider: 'github', predicates: [ { type: 'status', value: 'open' }, { type: 'repo', value: 'botify-hq/botify-report', negated: false, }, { type: 'author', value: 'rgehan', negated: false }, { type: 'status', value: 'open', negated: false }, ], }, ], }, settingsStore: { language: null, dateRange: 'last_week', wasOnboarded: true, darkMode: 'DISABLED', schemaVersion: 2, }, useNewTabPage: true, githubProvider: { settings: { token: '', }, }, }; ================================================ FILE: src/migrations/mocks/v3-with-defaulted-operators.ts ================================================ export default { filtersStore: { data: [ { id: '5c2382a0-5216-11e9-ad7a-73a635a52ed2', label: 'OctoLenses Issues', provider: 'github', predicates: [ { type: 'type', value: 'issues' }, { type: 'repo', value: 'rgehan/octolenses', operator: 'not_equal' }, { type: 'status', value: 'open' }, { type: 'author', value: 'rgehan', operator: 'equal' }, ], }, ], }, settingsStore: { language: null, dateRange: 'last_week', wasOnboarded: true, darkMode: 'DISABLED', schemaVersion: 3, }, useNewTabPage: true, githubProvider: { settings: { token: '', }, }, }; ================================================ FILE: src/migrations/testing-utils.ts ================================================ /* tslint:disable no-console */ import { forEach, keys, pick, reduce } from 'lodash'; import { migrator } from './index'; import mocks from './mocks'; import { hydrateLocalStorageFromObject } from './utils'; declare global { // eslint-disable-next-line @typescript-eslint/interface-name-prefix interface Window { loadTestLocalStorage(name: string): void; migrate(): void; exportConfiguration(): void; loadConfiguration(json: string): void; } } window.loadTestLocalStorage = (name: string) => { if (!mocks[name]) { console.log('No such test data. Available keys: ' + keys(mocks).join(', ')); return; } console.log(`Loading test data: ${name}`); hydrateLocalStorageFromObject(mocks[name]); }; window.migrate = () => { migrator.migrate(); }; /** * Serializes the relevant data stores in an exportable format */ window.exportConfiguration = () => { const data = pick(localStorage, [ 'settingsStore', 'filtersStore', 'githubProvider', 'jiraProvider', 'useNewTabPage', ]); const stringified = JSON.stringify( reduce( data, (acc: any, value, key) => { acc[key] = btoa(value); return acc; }, {} ) ); console.log( `In order to import that configuration, run:\n` + `\twindow.loadConfiguration('${stringified}');` ); }; /** * Imports a previously serialized data store */ window.loadConfiguration = (json: string) => { forEach(JSON.parse(json), (v, k) => { localStorage.setItem(k, atob(v)); }); console.log('Import done.'); }; ================================================ FILE: src/migrations/types.ts ================================================ export interface IMigration { name: string; shouldRun(): boolean; run(): void; } ================================================ FILE: src/migrations/utils.ts ================================================ export function getFromLocalStorage(key: string) { const data = localStorage.getItem(key); if (data) { return JSON.parse(data); } return null; } export function saveToLocalStorage(key: string, value: any) { const data = JSON.stringify(value); localStorage.setItem(key, data); } export function hydrateLocalStorageFromObject(object: any) { localStorage.clear(); Object.keys(object).forEach(key => { saveToLocalStorage(key, object[key]); }); } export function dumpLocalStorageToObject() { const object: Record = {}; Object.keys(localStorage).forEach(key => { object[key] = getFromLocalStorage(key); }); return object; } ================================================ FILE: src/migrations/v0-to-v1.test.ts ================================================ /* eslint-disable @typescript-eslint/camelcase */ import { dumpLocalStorageToObject, hydrateLocalStorageFromObject, } from './utils'; import v0_to_v1 from './v0-to-v1'; import v0 from './mocks/v0'; import v1 from './mocks/v1'; describe('v0 to v1 migrations', () => { beforeEach(() => { localStorage.clear(); }); it('should run if no schemaVersion is set', () => { hydrateLocalStorageFromObject(v0); expect(new v0_to_v1().shouldRun()).toEqual(true); }); it('should not run if a schemaVersion is set', () => { hydrateLocalStorageFromObject(v1); expect(new v0_to_v1().shouldRun()).toEqual(false); }); it('should add schemaVersion to the settings', () => { hydrateLocalStorageFromObject(v0); new v0_to_v1().run(); const obj = dumpLocalStorageToObject(); expect(obj).toHaveProperty('settingsStore.schemaVersion', 1); }); }); ================================================ FILE: src/migrations/v0-to-v1.ts ================================================ /* tslint:disable no-console */ import { IMigration } from './types'; import { getFromLocalStorage, saveToLocalStorage } from './utils'; export default class implements IMigration { public name = 'v0-to-v1'; public shouldRun(): boolean { const settings = getFromLocalStorage('settingsStore'); if (settings && settings.schemaVersion === undefined) { return true; } return false; } public run() { const settings = getFromLocalStorage('settingsStore'); settings.schemaVersion = 1; console.log('[migration] Upgrading schema version to 1'); saveToLocalStorage('settingsStore', settings); } } ================================================ FILE: src/migrations/v1-to-v2.test.ts ================================================ /* eslint-disable @typescript-eslint/camelcase */ import { dumpLocalStorageToObject, hydrateLocalStorageFromObject, } from './utils'; import v1_to_v2 from './v1-to-v2'; import v1 from './mocks/v1'; import v1_withoutToken from './mocks/v1-without-token'; import v2 from './mocks/v2'; describe('v1 to v2 migrations', () => { beforeEach(() => { localStorage.clear(); }); it('should run if schemaVersion is 1', () => { hydrateLocalStorageFromObject(v1); expect(new v1_to_v2().shouldRun()).toEqual(true); }); it('should not run if schemaVersion is not 1', () => { hydrateLocalStorageFromObject(v2); expect(new v1_to_v2().shouldRun()).toEqual(false); }); it('should set all filters provider to "github" by default', () => { hydrateLocalStorageFromObject(v1); new v1_to_v2().run(); const obj = dumpLocalStorageToObject(); obj.filtersStore.data.map((filter: any) => { expect(filter.provider).toEqual('github'); }); }); it('should not add a token if there is none yet', () => { hydrateLocalStorageFromObject(v1_withoutToken); expect(v1_withoutToken).not.toHaveProperty('settingsStore.token'); new v1_to_v2().run(); const obj = dumpLocalStorageToObject(); expect(obj).not.toHaveProperty('githubProvider.settings.token'); expect(obj).not.toHaveProperty('githubProvider'); }); it('should create a githubProvider.settings object containing the token', () => { hydrateLocalStorageFromObject(v1); expect(v1).toHaveProperty('settingsStore.token'); new v1_to_v2().run(); const obj = dumpLocalStorageToObject(); expect(obj).toHaveProperty('githubProvider.settings.token'); }); it('should set schemaVersion to 2 upon completion', () => { hydrateLocalStorageFromObject(v1); new v1_to_v2().run(); const obj = dumpLocalStorageToObject(); expect(obj).toHaveProperty('settingsStore.schemaVersion', 2); }); }); ================================================ FILE: src/migrations/v1-to-v2.ts ================================================ /* tslint:disable no-console */ import { ProviderType } from '../providers'; import { Filter } from '../store/filters'; import { IMigration } from './types'; import { getFromLocalStorage, saveToLocalStorage } from './utils'; export default class implements IMigration { public name = 'v1-to-v2'; public shouldRun(): boolean { const settings = getFromLocalStorage('settingsStore'); if (settings && settings.schemaVersion === 1) { return true; } return false; } public run() { this.defaultFiltersProviderToGithub(); this.moveTokenFromSettingsToGithubProvider(); this.upgradeSchemaVersion(); } private defaultFiltersProviderToGithub() { const filters = getFromLocalStorage('filtersStore'); console.log('[migration] Defaulting all filters providers to "github"'); filters.data.forEach((filter: Filter) => { filter.provider = filter.provider || ProviderType.GITHUB; }); saveToLocalStorage('filtersStore', filters); } private moveTokenFromSettingsToGithubProvider() { const settings = getFromLocalStorage('settingsStore'); const githubProvider = getFromLocalStorage('githubProvider'); if (githubProvider || !settings.token) { return; } console.log('[migration] Moving set token to github provider settings'); saveToLocalStorage('githubProvider', { settings: { token: settings.token }, }); } private upgradeSchemaVersion() { const settings = getFromLocalStorage('settingsStore'); settings.schemaVersion = 2; console.log('[migration] Upgrading schema version to 2'); saveToLocalStorage('settingsStore', settings); } } ================================================ FILE: src/migrations/v2-to-v3.test.ts ================================================ /* eslint-disable @typescript-eslint/camelcase */ import { dumpLocalStorageToObject, hydrateLocalStorageFromObject, } from './utils'; import v2_to_v3 from './v2-to-v3'; import v2 from './mocks/v2-with-negated-predicates'; import v3 from './mocks/v3-with-defaulted-operators'; describe('v2 to v3 migrations', () => { beforeEach(() => { localStorage.clear(); }); it('should run if schemaVersion is 2', () => { hydrateLocalStorageFromObject(v2); expect(new v2_to_v3().shouldRun()).toEqual(true); }); it('should properly default operators', () => { hydrateLocalStorageFromObject(v2); new v2_to_v3().run(); const obj = dumpLocalStorageToObject(); expect(obj).toEqual(v3); }); it('should set schemaVersion to 3 upon completion', () => { hydrateLocalStorageFromObject(v2); new v2_to_v3().run(); const obj = dumpLocalStorageToObject(); expect(obj).toHaveProperty('settingsStore.schemaVersion', 3); }); }); ================================================ FILE: src/migrations/v2-to-v3.ts ================================================ /* tslint:disable no-console */ import { providers, ProviderType } from '../providers'; import { Filter } from '../store/filters'; import { IMigration } from './types'; import { getFromLocalStorage, saveToLocalStorage } from './utils'; export default class implements IMigration { public name = 'v2-to-v3'; public shouldRun(): boolean { const settings = getFromLocalStorage('settingsStore'); if (settings && settings.schemaVersion === 2) { return true; } return false; } public run() { this.defaultPredicatesOperators(); this.upgradeSchemaVersion(); } private defaultPredicatesOperators() { const filters = getFromLocalStorage('filtersStore'); // There shouldn't be any other provider in use for now const provider = providers[ProviderType.GITHUB]; console.log('[migration] Defaulting all predicates operators'); filters.data.forEach((filter: Filter) => { filter.predicates.forEach((predicate: any) => { const isNegated = predicate.negated; predicate.negated = undefined; const predicateDefinition = provider.findPredicate(predicate.type); // Shouldn't happen, but let's be safe if (!predicateDefinition) { return; } // Operator-less predicates don't have an `operator` key if (predicateDefinition.operators.length === 0) { return; } predicate.operator = isNegated ? 'not_equal' : 'equal'; }); }); saveToLocalStorage('filtersStore', filters); } private upgradeSchemaVersion() { const settings = getFromLocalStorage('settingsStore'); settings.schemaVersion = 3; console.log('[migration] Upgrading schema version to 3'); saveToLocalStorage('settingsStore', settings); } } ================================================ FILE: src/pages/Dashboard/Dashboard.tsx ================================================ import cx from 'classnames'; import ExtendableError from 'es6-error'; import { get, size } from 'lodash'; import { computed } from 'mobx'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { Loader } from '../../components'; import { FilterEditModal } from '../../containers'; import { providers } from '../../providers'; import { FiltersStore } from '../../store/filters'; import { SettingsStore } from '../../store/settings'; import { FilterLinkContainer } from './FilterLinkContainer'; interface IProps { filtersStore?: FiltersStore; settingsStore?: SettingsStore; } @inject('filtersStore', 'settingsStore') @observer export class Dashboard extends React.Component { public state = { filterModal: { isOpen: false, mode: 'adding' }, metaPressed: false, }; componentDidMount() { window.addEventListener('keydown', this.handleKeyDown); window.addEventListener('keyup', this.handleKeyUp); } componentWillUnmount() { window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keyup', this.handleKeyUp); } private handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Meta') { this.setState({ metaPressed: true }); } }; private handleKeyUp = (event: KeyboardEvent) => { if (event.key === 'Meta') { this.setState({ metaPressed: false }); } }; @computed get selectedFilter() { const { filtersStore, settingsStore } = this.props; const filter = filtersStore.findFilter(settingsStore.selectedFilterId); const firstFilter = filtersStore.getFirstFilter(); return filter || firstFilter; } public handleFilterSelected = (filterId: string) => { const { filtersStore, settingsStore } = this.props; if (filterId === settingsStore.selectedFilterId) { return; } // Clear the notifications of the filter that was selected filtersStore .findFilter(settingsStore.selectedFilterId) .clearNewItemsNotifications(); // Select the new filter settingsStore.selectedFilterId = filterId; }; public handleCloneFilter = () => { const { filtersStore } = this.props; const { id } = filtersStore.cloneFilter(this.selectedFilter.id); this.handleFilterSelected(id); }; public handleRefreshFilter = () => { this.selectedFilter.invalidateCache(); }; public handleRefreshAllFilters = () => { this.props.filtersStore.fetchAllFilters(); }; public handleDeleteFilter = () => { const { filtersStore, settingsStore } = this.props; if (!this.selectedFilter || filtersStore.count === 1) { return; } // Find out the index (in the list) of the filter const currentFilterIndex = filtersStore.findFilterIndex( this.selectedFilter.id ); // Find out which filter we'll have to select once removed const isDeletingLastFilter = currentFilterIndex === filtersStore.count - 1; const newlySelectedFilterIndex = isDeletingLastFilter ? currentFilterIndex - 1 : currentFilterIndex + 1; // Find the actual UUID of the filter const realIndex = filtersStore.getFilterAt(newlySelectedFilterIndex).id; // Remove the filter, then select the next one filtersStore.removeFilter(this.selectedFilter.id); settingsStore.selectedFilterId = realIndex; }; /* * Modal logic */ public handleOpenFilterModal = (mode: string) => () => { this.setState({ filterModal: { isOpen: true, mode, }, }); }; public handleCloseFilterModal = () => { this.setState({ filterModal: { isOpen: false, }, }); }; public reorderFilters = ({ oldIndex, newIndex }: any) => { const { filtersStore } = this.props; // Do nothing if the user cancelled the drag if (oldIndex === newIndex) { return; } // Select the filter we want to move... this.handleFilterSelected(filtersStore.getFilterAt(oldIndex).id); // ...and move it filtersStore.swapFilters(oldIndex, newIndex); }; public render() { const { filtersStore, settingsStore } = this.props; const { filterModal, metaPressed } = this.state; const LINKS = [ { handler: this.handleOpenFilterModal('adding'), text: 'Add', icon: 'far fa-plus-square', }, { handler: this.handleOpenFilterModal('editing'), text: 'Edit', icon: 'far fa-edit', }, { handler: this.handleCloneFilter, text: 'Clone', icon: 'far fa-clone', }, metaPressed ? { handler: this.handleRefreshAllFilters, text: 'Refresh All', icon: 'fas fa-sync-alt', } : { handler: this.handleRefreshFilter, text: 'Refresh', icon: 'fas fa-sync-alt', }, { handler: this.handleDeleteFilter, text: 'Delete', icon: 'far fa-trash-alt', }, ]; return (
{LINKS.map(({ handler, text, icon }) => (
{text}
))}
{this.renderResults()}
{filterModal.isOpen && ( )}
); } public renderResults() { if (!this.selectedFilter) { return null; } if (this.selectedFilter.loading) { return ; } if (this.selectedFilter.error) { const errorMessage = this.selectedFilter.error instanceof ExtendableError ? this.selectedFilter.error.message : 'Something failed, sorry.'; return (
{errorMessage}
); } if (size(this.selectedFilter.data) === 0) { return (
No results.
); } const CardComponent = providers[this.selectedFilter.provider].cardComponent; return this.selectedFilter.data.map((itemData, index) => ( )); } } ================================================ FILE: src/pages/Dashboard/FilterLinkContainer.tsx ================================================ import React from 'react'; import { SortableContainer } from 'react-sortable-hoc'; import { FilterLink } from '../../components'; import { Filter } from '../../store/filters'; interface IProps { links: Filter[]; selectedFilterId: string; onFilterSelected: (id: string) => void; } export const FilterLinkContainer = SortableContainer( ({ links, selectedFilterId, onFilterSelected }: IProps) => (
{links.map((link: Filter, index: number) => ( onFilterSelected(link.id)} /> ))}
) ); ================================================ FILE: src/pages/Dashboard/index.ts ================================================ export { Dashboard } from './Dashboard'; ================================================ FILE: src/pages/Discover/Discover.scss ================================================ .Discover { width: 100%; height: 100%; display: flex; flex-direction: column; &__Actions { display: flex; flex-direction: row; justify-content: flex-end; margin-bottom: 20px; > div { margin-left: 20px; } } &__ReposList { flex: 1; display: flex; @media screen and (max-width: 768px) { flex-direction: column; } @media screen and (min-width: 769px) { flex-wrap: wrap; } } } ================================================ FILE: src/pages/Discover/Discover.tsx ================================================ import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { Dropdown, Loader } from '../../components'; import { DATES, DateType } from '../../constants/dates'; import { LANGUAGES } from '../../constants/languages'; import { RepoCard } from '../../containers'; import { SettingsStore } from '../../store/settings'; import { TrendsStore } from '../../store/trends'; import './Discover.scss'; interface IInnerProps { settingsStore?: SettingsStore; trendsStore?: TrendsStore; } export const Discover = compose( inject('settingsStore', 'trendsStore'), observer )(({ settingsStore, trendsStore }) => { function handleChangeLanguage({ value }: { value: string }) { settingsStore.updateLanguage(value); trendsStore.fetchTrendingRepos(); } function handleChangeDateRange({ value }: { value: string }) { settingsStore.updateDateRange(value as DateType); trendsStore.fetchTrendingRepos(); } return (
{trendsStore.loading ? ( ) : ( trendsStore.data.map(repo => ) )}
); }); ================================================ FILE: src/pages/Discover/index.ts ================================================ export { Discover } from './Discover'; ================================================ FILE: src/pages/index.ts ================================================ export { Discover } from './Discover'; export { Dashboard } from './Dashboard'; ================================================ FILE: src/providers/AbstractProvider.ts ================================================ import { observable } from 'mobx'; import { persist } from 'mobx-persist'; import { Filter } from '../store/filters'; import { SettingsStore } from '../store/settings'; import { Predicate } from './types'; interface ISettingsComponentProps { settings: SettingsStore; } type SettingsComponent = ({ settings }: ISettingsComponentProps) => JSX.Element; type CardComponent = React.ComponentType<{ data: any }>; export abstract class AbstractProvider { /** * Unique identifier of the provider */ public id: string; /** * Name of the provider, as displayed in the Settings sidebar */ public label: string; /** * Component used to render the provider settings panel */ public settingsComponent: SettingsComponent; /** * Component used to render the provider's data on the dashboard */ public cardComponent: CardComponent; /** * A place for the provider to store its specific settings */ @persist('object') @observable public settings: T = {} as T; /** * Called after the app has been booted, so that we can perform initial * data fetching and initialization tasks. */ public abstract initialize(): Promise; /** * Returns an array of available predicates for the provider */ public abstract getAvailablePredicates(): Predicate[]; /** * Retrieve the definition of a predicate from its name * @param name Name of the predicate we want to retrieve */ public abstract findPredicate(name: string): Predicate; /** * Fetch a filter associated to this provider * @param filter * @param providerSettings */ public abstract fetchFilter(filter: Filter): Promise; /** * Resolve the unique identifier associated to a filter item * @param item A filter item */ public abstract resolveFilterItemIdentifier(item: any): string; } ================================================ FILE: src/providers/github/components/IssueCard/CheckStatusIndicator.tsx ================================================ import cx from 'classnames'; import React from 'react'; import { IIssue } from './IssueCard'; import { IssueStatus } from './types'; interface IProps { status: IIssue['status']; } const STATUS_TO_ICON = { [IssueStatus.SUCCESS]: 'fas fa-check text-green-500', [IssueStatus.FAILURE]: 'fas fa-times text-red-500', [IssueStatus.PENDING]: 'fas fa-circle text-orange-500', }; const STATUS_TO_LABEL = { [IssueStatus.SUCCESS]: 'All checks passed', [IssueStatus.FAILURE]: 'Some checks have failed', [IssueStatus.PENDING]: 'Checks are running', }; export const CheckStatusIndicator = ({ status }: IProps) => { if (status === IssueStatus.UNKNOWN) { return null; } const icon = STATUS_TO_ICON[status]; const label = STATUS_TO_LABEL[status]; return ; }; ================================================ FILE: src/providers/github/components/IssueCard/ConflictIndicator.tsx ================================================ import React from 'react'; interface IProps { conflicting: boolean; } export const ConflictIndicator = ({ conflicting }: IProps) => { if (!conflicting) { return null; } return ( ); }; ================================================ FILE: src/providers/github/components/IssueCard/ContextualDropdown.tsx ================================================ import cx from 'classnames'; import ClipboardJS from 'clipboard'; import { inject, observer } from 'mobx-react'; import React, { useEffect } from 'react'; import { compose } from 'recompose'; import styled from 'styled-components'; import { toast } from '../../../../components/ToastManager/ToastManager'; import { SettingsStore } from '../../../../store/settings'; import { IIssue } from './IssueCard'; const Wrapper = styled.div` .overlay { display: none; } :hover { .overlay { display: flex; } } `; const Overlay = styled.div<{ dark: boolean }>` top: 16px; left: 2px; :before { content: ''; height: 0px; width: 1px; border-bottom: 4px solid ${({ dark }) => (dark ? '#606f7b' : 'white')}; border-left: 4px solid transparent; border-right: 4px solid transparent; position: absolute; top: -4px; left: 6px; } `; const makeActions = (issue: IIssue) => { const type = issue.pull_request ? 'Pull request' : 'Issue'; return [ { label: 'Copy Link', 'data-clipboard-text': issue.html_url, onClick: () => toast(`${type} link was copied to the clipboard`, 'info'), }, { label: 'Copy Title', 'data-clipboard-text': issue.title, onClick: () => toast(`${type} title was copied to the clipboard`, 'info'), }, ]; }; interface IProps { issue: IIssue; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const ContextualDropdown = compose( inject('settingsStore'), observer )(({ issue, settingsStore }) => { useEffect(() => { const clipboard = new ClipboardJS('[data-clipboard-text]'); return () => clipboard.destroy(); }); const actions = makeActions(issue); return ( {actions.map(({ label, ...otherProps }) => (
{label}
))}
); }); ================================================ FILE: src/providers/github/components/IssueCard/IssueCard.tsx ================================================ import cx from 'classnames'; import { get } from 'lodash'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import * as timeago from 'timeago.js'; import { SettingsStore } from '../../../../store/settings'; import { CheckStatusIndicator } from './CheckStatusIndicator'; import { ConflictIndicator } from './ConflictIndicator'; import { ContextualDropdown } from './ContextualDropdown'; import { IssueStatusIndicator } from './IssueStatusIndicator'; import { LabelBadge } from './LabelBadge'; import { IssueStatus, TimelineItem, TimelineItemType } from './types'; export interface IIssue { type: 'PullRequest' | 'Issue'; title: string; url: string; html_url: string; state: 'open' | 'closed' | 'merged'; isDraft: boolean; status: IssueStatus; number: number; createdAt: string; conflicting: boolean; pull_request?: { url: string; }; author: { url: string; login: string; avatarUrl: string; }; repository: { url: string; nameWithOwner: string; }; reviews?: { totalCount: number; }; comments?: { totalCount: number; }; labels: Array<{ color: string; name: string }>; timelineItems?: TimelineItem[]; } interface IProps { data: IIssue; isNew: boolean; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const IssueCard = compose( inject('settingsStore'), observer )(({ data: issue, isNew, settingsStore }) => { const linkStyle = settingsStore.isDark ? 'text-blue-400' : 'text-blue-500 hover:text-blue-600'; const firstTimelineItem = get(issue, 'timelineItems.nodes.0'); function getTotalCommentsCount() { return ( get(issue, 'reviews.totalCount', 0) + get(issue, 'comments.totalCount', 0) ); } function getLastActivityDate(item: TimelineItem) { switch (item.__typename) { case TimelineItemType.ISSUE_COMMENT: return item.createdAt; case TimelineItemType.PULL_REQUEST_COMMIT: return item.commit.committedDate; case TimelineItemType.PULL_REQUEST_REVIEW: return item.createdAt; } } return (
#{issue.number} opened {timeago.format(issue.createdAt)} by{' '} {issue.author.login} {firstTimelineItem && ( {', '} last activity{' '} {timeago.format(getLastActivityDate(firstTimelineItem))} )}
{issue.labels.map(label => ( ))}
); }); ================================================ FILE: src/providers/github/components/IssueCard/IssueStatusIndicator.tsx ================================================ import cx from 'classnames'; import React from 'react'; import { IIssue } from './IssueCard'; import {assertUnreachable} from "../../../../lib/assertUnreachable"; interface IProps { issue: Pick; } export const IssueStatusIndicator = ({ issue }: IProps) => ( ); function getColor(state: IIssue['state'], isDraft: boolean) { switch (state) { case 'open': return isDraft ? 'text-gray-500' : 'text-green-500'; case 'closed': return 'text-red-500'; case 'merged': return 'text-purple-500'; default: return assertUnreachable(state, 'text-gray-500'); } } ================================================ FILE: src/providers/github/components/IssueCard/LabelBadge.tsx ================================================ import cx from 'classnames'; import contrast from 'contrast'; import React from 'react'; interface IProps { label: { name: string; color: string; }; } export const LabelBadge = ({ label }: IProps) => { const background = `#${label.color}`; const isDark = contrast(background) === 'dark'; return (
{label.name}
); }; ================================================ FILE: src/providers/github/components/IssueCard/index.ts ================================================ export { IssueCard } from './IssueCard'; ================================================ FILE: src/providers/github/components/IssueCard/types.ts ================================================ export enum IssueStatus { PENDING = 'PENDING', SUCCESS = 'SUCCESS', FAILURE = 'FAILURE', UNKNOWN = 'UNKNOWN', } export enum TimelineItemType { ISSUE_COMMENT = 'IssueComment', PULL_REQUEST_COMMIT = 'PullRequestCommit', PULL_REQUEST_REVIEW = 'PullRequestReview', } export type TimelineItem = | IIssueComment | IPullRequestCommit | IPullRequestReview; interface IIssueComment { __typename: TimelineItemType.ISSUE_COMMENT; createdAt: string; } interface IPullRequestCommit { __typename: TimelineItemType.PULL_REQUEST_COMMIT; commit: { committedDate: string; }; } interface IPullRequestReview { __typename: TimelineItemType.PULL_REQUEST_REVIEW; createdAt: string; } ================================================ FILE: src/providers/github/components/ProfileCard.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { IGithubProfile } from '../index'; import { SettingsStore } from '../../../store/settings'; interface IProps { profile: IGithubProfile; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const ProfileCard = compose( inject('settingsStore'), observer )(({ profile, settingsStore }) => { if (!profile) { return null; } return (
{profile.name}
@{profile.login}
); }); ================================================ FILE: src/providers/github/components/Settings.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React, { useState } from 'react'; import { compose } from 'recompose'; import styled from 'styled-components'; import { GithubProvider } from '..'; import { Button, ButtonType } from '../../../components/Button'; import { toast } from '../../../components/ToastManager'; import { SettingsStore } from '../../../store/settings'; import { ProfileCard } from './ProfileCard'; const CREATE_TOKEN_URL = 'https://github.com/settings/tokens/new?scopes=repo&description=octolenses'; const Input = styled.input<{ dark?: boolean }>` ::placeholder { color: ${({ dark }) => (dark ? '#8795a1' : '#b8c2cc')}; opacity: 1; } `; interface IProps { provider: GithubProvider; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const Settings = compose( inject('settingsStore'), observer )(({ provider, settingsStore }) => { const [token, setToken] = useState(provider.settings.token || ''); function handleSubmit() { provider.setToken(token); toast('Token was saved', 'info'); } return (
Github Personal Access Token

You can generate a Personal Access Token on{' '} this page .
It needs to have the following scope:{' '} repo

setToken(event.target.value)} placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx-xxxxx" dark={settingsStore.isDark} className={cx( 'w-full rounded outline-none pl-10 pr-3 py-2 text-gray-600 tracking-wider font-mono', settingsStore.isDark ? 'bg-gray-800' : 'bg-gray-100' )} />
); }); ================================================ FILE: src/providers/github/fetchers/client.ts ================================================ import { get, pickBy } from 'lodash'; import { InvalidCredentials, NeedTokenError, RateLimitError, } from '../../../errors'; interface IClientParams { endpoint: string; token?: string; qs?: string; body?: string; method?: 'GET' | 'POST'; } /** * Fetch data from GitHub API */ export const client = async ({ endpoint, token, qs, body, method = 'GET', }: IClientParams) => { const url = `https://api.github.com${endpoint}?${qs || ''}`; const response = await fetch(url, { body: method === 'POST' ? body : undefined, method, headers: pickBy({ 'User-Agent': 'OctoLenses Github Dashboard', Authorization: token && `Bearer ${token}`, Accept: 'application/vnd.github.antiope-preview+json', // Allow access to Previews API }), }); if (!response.ok) { await handleErrorResponse(response); } return await response.json(); }; /** * Handle an error response from GitHub API */ const handleErrorResponse = async (response: Response) => { const status = response.status; const { message = '', errors = [] } = await response.json(); const firstErrorMessage = get(errors, '0.message', ''); if (status === 401 && message.includes('Bad credentials')) { throw new InvalidCredentials(); } if (status === 403 && message.includes('API rate limit')) { const rateLimitReset = response.headers.get('X-RateLimit-Reset'); const remainingRateLimit = Number(rateLimitReset) - Date.now() / 1000; throw new RateLimitError(remainingRateLimit); } if ( status === 422 && firstErrorMessage.includes('do not exist or you do not have permission') ) { throw new NeedTokenError(); } }; ================================================ FILE: src/providers/github/fetchers/graphql/query.ts ================================================ const commonFields = ` url title state number createdAt author { url login avatarUrl } labels(first: 10) { edges { node { name color } } } repository { nameWithOwner url } comments { totalCount } `; const issueFragment = ` fragment IssueFragment on Issue { ${commonFields} }`; const pullRequestFragment = ` fragment PullRequestFragment on PullRequest { ${commonFields} mergeable isDraft reviews { totalCount } commits(last: 1) { nodes { commit { status { state } checkSuites(first: 1) { nodes { status conclusion app { name } } } } } } timelineItems(itemTypes: [PULL_REQUEST_COMMIT, PULL_REQUEST_REVIEW, ISSUE_COMMENT], last: 1) { nodes { __typename ... on IssueComment { createdAt } ... on PullRequestReview { createdAt } ... on PullRequestCommit { commit { committedDate } } } } }`; export const makeQuery = (filterString: string) => ` ${pullRequestFragment} ${issueFragment} query results { search(query: "${filterString}", type: ISSUE, first: 100) { edges { node { __typename ... on PullRequest { ...PullRequestFragment } ... on Issue { ...IssueFragment } } } } }`; ================================================ FILE: src/providers/github/fetchers/graphql/search.ts ================================================ import { chain, omit, map } from 'lodash'; import { Cache } from '../../../../lib/cache'; import { Filter } from '../../../../store/filters'; import { client } from '../client'; import { makeQuery } from './query'; import { extractConflictStatus, extractGraphqlLabels, extractGraphqlStatus, } from './utils'; /** * Fetch a filter using the shiny GraphQL API * @param {object} options.filter */ export const search = async (filter: Filter, token: string) => { const filterString = chain(filter.predicates) .map(predicate => filter.serializePredicate(predicate)) .join(' ') .replace(/"/g, '\\"') .value(); const cacheKey = `github.graphql.${filter.hash}`; const response = await Cache.remember(cacheKey, 60, async () => client({ endpoint: '/graphql', method: 'POST', body: JSON.stringify({ query: makeQuery(filterString) }), token, }) ); return formatResponse(response); }; /** * Format a graphql response so that it's easy to use */ export const formatResponse = (response: any) => { const issues = map(response.data.search.edges, 'node'); return issues.map(issue => ({ ...omit(issue, ['commits', '__typename', 'mergeable']), type: issue.__typename, status: extractGraphqlStatus(issue), labels: extractGraphqlLabels(issue), conflicting: extractConflictStatus(issue), })); }; ================================================ FILE: src/providers/github/fetchers/graphql/utils.ts ================================================ import { get, map } from 'lodash'; import { IssueStatus } from '../../components/IssueCard/types'; export const COMMIT_STATUS_TO_STATUS: Record = { EXPECTED: IssueStatus.UNKNOWN, ERROR: IssueStatus.FAILURE, FAILURE: IssueStatus.FAILURE, PENDING: IssueStatus.PENDING, SUCCESS: IssueStatus.SUCCESS, }; export const CHECK_CONCLUSION_TO_STATUS: Record = { ACTION_REQUIRED: IssueStatus.UNKNOWN, TIMED_OUT: IssueStatus.UNKNOWN, CANCELLED: IssueStatus.UNKNOWN, FAILURE: IssueStatus.FAILURE, SUCCESS: IssueStatus.SUCCESS, NEUTRAL: IssueStatus.UNKNOWN, }; export function extractGraphqlStatus(issue: any) { const commitStatus = get(issue, 'commits.nodes.0.commit.status.state'); const checkStatus = get(issue, 'commits.nodes.0.commit.checkSuites.nodes.0'); // Old status API if (commitStatus) { return COMMIT_STATUS_TO_STATUS[commitStatus]; } // New check status API if (checkStatus) { const { status, conclusion } = checkStatus; if (status !== 'COMPLETED') { return IssueStatus.PENDING; } return CHECK_CONCLUSION_TO_STATUS[conclusion]; } return IssueStatus.UNKNOWN; } export function extractGraphqlLabels(issue: any) { return map(issue.labels.edges, 'node'); } export function extractConflictStatus(issue: any) { return issue.mergeable === 'CONFLICTING'; } ================================================ FILE: src/providers/github/fetchers/index.ts ================================================ import { IGithubSettings } from '..'; import { Filter } from '../../../store/filters'; import { search as graphqlSearch } from './graphql/search'; import { search as restSearch } from './rest/search'; export const fetchFilter = async ( filter: Filter, settings: IGithubSettings ) => { if (settings.token) { return graphqlSearch(filter, settings.token); } return restSearch(filter); }; ================================================ FILE: src/providers/github/fetchers/rest/profile.ts ================================================ import { client } from '../client'; export const fetchProfile = async (token: string) => { return client({ endpoint: '/user', token }); }; ================================================ FILE: src/providers/github/fetchers/rest/search.ts ================================================ import { chain, map, pick } from 'lodash'; import { Cache } from '../../../../lib/cache'; import { Filter } from '../../../../store/filters'; import { IssueStatus } from '../../components/IssueCard/types'; import { client } from '../client'; /** * Fetch a filter on the old REST API. This is only supposed to be * used when the user has not set a token. This won't return all the * value we need (for example there are no build status). * @param {object} options.filter */ export const search = async (filter: Filter) => { const filterString = chain(filter.predicates) .map(predicate => filter.serializePredicate(predicate)) .map(encodeURIComponent) .join('+') .value(); const cacheKey = `github.rest.${filter.hash}`; const { items: issues = [] } = await Cache.remember(cacheKey, 60, async () => client({ endpoint: '/search/issues', qs: `per_page=100&q=${filterString}`, }) ); return formatResponse(issues); }; /** * Format a REST response so that it's compatible with the GraphQL one */ export const formatResponse = (response: any) => { return map(response, issue => ({ url: issue.html_url, title: issue.title, number: issue.number, state: issue.state, createdAt: issue.created_at, author: { url: issue.user.html_url, login: issue.user.login, avatarUrl: issue.user.avatar_url, }, repository: { nameWithOwner: parseRestRepoName(issue.repository_url), url: parseRestRepoUrl(issue.repository_url), }, labels: extractRestLabels(issue), type: issue.pull_request ? 'PullRequest' : 'Issue', comments: { totalCount: issue.comments }, reviews: { totalCount: 0 }, status: IssueStatus.UNKNOWN, })); }; const extractRestLabels = (issue: any) => map(issue.labels, label => pick(label, ['color', 'name'])); const parseRestRepoName = (url: string) => chain(url) .split('/') .slice(-2) .join('/') .value(); const parseRestRepoUrl = (apiUrl: string) => apiUrl.replace('api.github.com/repos/', 'github.com/'); ================================================ FILE: src/providers/github/index.tsx ================================================ import { find } from 'lodash'; import { action, observable } from 'mobx'; import React from 'react'; import { Filter } from '../../store/filters'; import { AbstractProvider } from '../AbstractProvider'; import { IssueCard } from './components/IssueCard'; import { Settings } from './components/Settings'; import { fetchFilter } from './fetchers'; import { fetchProfile } from './fetchers/rest/profile'; import { availablePredicates } from './predicates'; export interface IGithubSettings { token: string; } export interface IGithubProfile { login: string; name: string; avatar_url: string; html_url: string; } export class GithubProvider extends AbstractProvider { public id = 'github'; public label = 'GitHub'; public settingsComponent = () => ; public cardComponent = IssueCard; @observable public profile: IGithubProfile = null; @action.bound public async initialize() { await this.fetchProfile(); } public async fetchFilter(filter: Filter) { return fetchFilter(filter, this.settings); } public resolveFilterItemIdentifier(item: any) { return item.number; } public getAvailablePredicates = () => availablePredicates; public findPredicate(name: string) { return find(this.getAvailablePredicates(), { name }); } @action.bound public async fetchProfile() { if (!this.settings.token) { return; } this.profile = await fetchProfile(this.settings.token); } @action.bound public setToken(token: string) { this.settings.token = token; if (token) { this.fetchProfile(); } else { this.profile = null; } } } export const github = new GithubProvider(); ================================================ FILE: src/providers/github/predicates/createdOrUpdatedAt.ts ================================================ import moment from 'moment'; import { IDropdownPredicate, PredicateType } from '../../types'; enum Preset { ONE_HOUR_AGO = 'one_hour_ago', ONE_DAY_AGO = 'one_day_ago', ONE_WEEK_AGO = 'one_week_ago', ONE_MONTH_AGO = 'one_month_ago', } enum Operator { MORE_THAN = 'more_than', LESS_THAN = 'less_than', } function makeDatePredicate(name: string, label: string, githubField: string): IDropdownPredicate { return { name, label, type: PredicateType.DROPDOWN, operators: [ { value: Operator.MORE_THAN, label: 'More than' }, { value: Operator.LESS_THAN, label: 'Less than' }, ], choices: [ { value: Preset.ONE_HOUR_AGO, label: '1 hour ago' }, { value: Preset.ONE_DAY_AGO, label: '1 day ago' }, { value: Preset.ONE_WEEK_AGO, label: '1 week ago' }, { value: Preset.ONE_MONTH_AGO, label: '1 month ago' }, ], serialize: ({ value, operator }) => { let date = null; switch (value) { // Less than case Preset.ONE_HOUR_AGO: date = moment().subtract(1, 'hour'); break; case Preset.ONE_DAY_AGO: date = moment().subtract(1, 'day'); break; case Preset.ONE_WEEK_AGO: date = moment().subtract(1, 'week'); break; case Preset.ONE_MONTH_AGO: date = moment().subtract(1, 'month'); break; } let operatorSymbol = null; switch (operator) { case Operator.LESS_THAN: operatorSymbol = '>'; break; case Operator.MORE_THAN: operatorSymbol = '<'; break; } if (date === null || operator === null) return null; return `${githubField}:${operatorSymbol}${date.format('YYYY-MM-DD')}`; }, }; } export const createdAt: IDropdownPredicate = makeDatePredicate('created at', 'Created At', 'created'); export const updatedAt: IDropdownPredicate = makeDatePredicate('updated at', 'Updated At', 'updated'); ================================================ FILE: src/providers/github/predicates/draft.ts ================================================ import { IDropdownPredicate, PredicateType } from '../../types'; export const draft: IDropdownPredicate = { name: 'draft', label: 'Is Draft?', type: PredicateType.DROPDOWN, choices: [ { value: 'false', label: 'No' }, { value: 'true', label: 'Yes' }, ], operators: [], serialize: ({ value }) => `draft:${value}`, }; ================================================ FILE: src/providers/github/predicates/index.ts ================================================ import { capitalize } from 'lodash'; import { Predicate, PredicateType } from '../../types'; import { mergeStatus } from './mergeStatus'; import { review } from './review'; import { status } from './status'; import { type } from './type'; import { draft } from "./draft"; import { createdAt, updatedAt } from './createdOrUpdatedAt'; enum GithubOperators { EQUAL = 'equal', NOT_EQUAL = 'not_equal', } interface ISimplePredicatePayload { name: string; placeholder: string; label?: string; } /** * Makes a simple text predicate * @param options Options configuring the predicate */ export const makeSimplePredicate = ({ name, label, placeholder, }: ISimplePredicatePayload): Predicate => ({ name, label: label || capitalize(name), placeholder, type: PredicateType.TEXT, operators: [ { value: GithubOperators.EQUAL, label: '=' }, { value: GithubOperators.NOT_EQUAL, label: '!=' }, ], serialize: ({ value, operator }) => { const modifier = operator === GithubOperators.NOT_EQUAL ? '-' : ''; return `${modifier}${name}:"${value}"`; }, }); export const availablePredicates: Predicate[] = [ makeSimplePredicate({ name: 'assignee', label: 'Assignee', placeholder: 'USERNAME', }), makeSimplePredicate({ name: 'author', label: 'Author', placeholder: 'USERNAME', }), makeSimplePredicate({ name: 'label', label: 'Label', placeholder: 'LABEL', }), makeSimplePredicate({ name: 'project', label: 'Project', placeholder: 'USERNAME/REPOSITORY/PROJECT', }), makeSimplePredicate({ name: 'mentions', label: 'Mentions', placeholder: 'USERNAME', }), makeSimplePredicate({ name: 'team', label: 'Team', placeholder: 'ORGNAME/TEAMNAME', }), makeSimplePredicate({ name: 'commenter', label: 'Commenter', placeholder: 'USERNAME', }), makeSimplePredicate({ name: 'involves', label: 'Involves', placeholder: 'USERNAME', }), makeSimplePredicate({ name: 'milestone', label: 'Milestone', placeholder: 'MILESTONE', }), makeSimplePredicate({ name: 'user', label: 'User', placeholder: 'USERNAME', }), makeSimplePredicate({ name: 'repo', label: 'Repository', placeholder: 'USERNAME/REPOSITORY', }), makeSimplePredicate({ name: 'org', label: 'Organization', placeholder: 'ORGNAME', }), makeSimplePredicate({ name: 'reviewed-by', label: 'Reviewed by', placeholder: 'USERNAME', }), makeSimplePredicate({ name: 'review-requested', label: 'Requested Reviewer (User)', placeholder: 'USERNAME', }), makeSimplePredicate({ name: 'team-review-requested', label: 'Requested Reviewer (Team)', placeholder: 'ORGNAME/TEAMNAME', }), type, status, mergeStatus, review, draft, createdAt, updatedAt ]; ================================================ FILE: src/providers/github/predicates/mergeStatus.ts ================================================ import { IDropdownPredicate, PredicateType } from '../../types'; export const mergeStatus: IDropdownPredicate = { name: 'merge status', label: 'Merge Status', type: PredicateType.DROPDOWN, choices: [ { value: 'merged', label: 'Merged' }, { value: 'unmerged', label: 'Unmerged' }, ], operators: [], serialize: ({ value }) => `is:${value}`, }; ================================================ FILE: src/providers/github/predicates/review.ts ================================================ import { IDropdownPredicate, PredicateType } from '../../types'; export const review: IDropdownPredicate = { name: 'review', label: 'Review Status', type: PredicateType.DROPDOWN, choices: [ { value: 'none', label: 'None' }, { value: 'required', label: 'Required' }, { value: 'approved', label: 'Approved' }, { value: 'changes_requested', label: 'Changes Requested' }, ], operators: [], serialize: ({ value }) => `review:${value}`, }; ================================================ FILE: src/providers/github/predicates/status.ts ================================================ import { IDropdownPredicate, PredicateType } from '../../types'; export const status: IDropdownPredicate = { name: 'status', label: 'Status', type: PredicateType.DROPDOWN, choices: [ { value: 'open', label: 'Open' }, { value: 'closed', label: 'Closed' }, ], operators: [], serialize: ({ value }) => `is:${value}`, }; ================================================ FILE: src/providers/github/predicates/type.ts ================================================ import { IDropdownPredicate, PredicateType } from '../../types'; export const type: IDropdownPredicate = { name: 'type', label: 'Type', type: PredicateType.DROPDOWN, choices: [ { value: 'pr', label: 'PRs' }, { value: 'issue', label: 'Issues' }, ], operators: [], serialize: ({ value }) => `type:${value}`, }; ================================================ FILE: src/providers/index.ts ================================================ import { github } from './github'; import { jira } from './jira'; import { ProviderType } from './types'; export { AbstractProvider } from './AbstractProvider'; export * from './types'; export const providers = { [ProviderType.GITHUB]: github, [ProviderType.JIRA]: jira, }; ================================================ FILE: src/providers/jira/components/AvailableResources.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { SettingsStore } from '../../../store/settings'; import { IJiraResource } from '../index'; interface IProps { resources: IJiraResource[]; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const AvailableResources = compose( inject('settingsStore'), observer )(({ resources, settingsStore }) => { if (!resources || !resources.length) { return null; } return (
Available resources
{resources.map((resource, index) => (
{resource.name}
{resource.id}
))}
); }); ================================================ FILE: src/providers/jira/components/IssueCard/IssueCard.tsx ================================================ import cx from 'classnames'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import * as timeago from 'timeago.js'; import { SettingsStore } from '../../../../store/settings'; import { JiraProvider } from '../../index'; import { StatusBadge } from './StatusBadge'; export interface IJiraIssue { key: string; fields: { summary: string; description: string; created: string; updated: string; issuetype: { name: string; iconUrl: string; }; project: { name: string; key: string; avatarUrls: { '48x48': string; }; }; priority?: { iconUrl: string; name: string; }; assignee: { displayName: string; emailAddress: string; avatarUrls: { '48x48': string; }; }; status: { name: string; statusCategory: { colorName: string; }; }; }; } export interface IProps { provider: JiraProvider; data: IJiraIssue; isNew: boolean; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const IssueCard = compose( inject('settingsStore'), observer )(({ data: issue, isNew, provider, settingsStore }) => { // The API doesn't seem to return the URL to the actual issue on the web // interface, so we use the one of the first resource. Note that for an // account that has access to multiple resources, this will generate wrong // URLs. Feel free to suggest a change if it doesn't work for you. const url = `${provider.resources[0].url}/browse/${issue.key}`; const linkStyle = settingsStore.isDark ? 'text-blue-400' : 'text-blue-500 hover:text-blue-600'; return (
{issue.fields.assignee && ( )}
{issue.fields.priority && ( )} {issue.fields.summary}
{issue.key} opened {timeago.format(issue.fields.created)}
); }); ================================================ FILE: src/providers/jira/components/IssueCard/StatusBadge.tsx ================================================ import cx from 'classnames'; import React from 'react'; import { IJiraIssue } from './IssueCard'; interface IProps { issue: IJiraIssue; } type JiraColor = 'green' | 'yellow' | 'blue-gray'; const COLORS_TO_STYLE: Record = { green: 'text-green-500 bg-green-100', yellow: 'text-yellow-800 bg-yellow-200', 'blue-gray': 'text-blue-600 bg-blue-100', }; export const StatusBadge = ({ issue }: IProps) => { const name = issue.fields.status.name; const colorName = issue.fields.status.statusCategory.colorName; return (
{name}
); }; ================================================ FILE: src/providers/jira/components/IssueCard/index.ts ================================================ export { IssueCard } from './IssueCard'; export type { IProps } from './IssueCard'; ================================================ FILE: src/providers/jira/components/LoginButton.tsx ================================================ import cx from 'classnames'; import { chain } from 'lodash'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { compose } from 'recompose'; import { JiraProvider } from '..'; import { Button, ButtonType } from '../../../components/Button'; import { SettingsStore } from '../../../store/settings'; import { ISwapResult, swapToken } from '../fetchers/swapToken'; const CLIENT_ID = '4WgiRI4XRQ2OTWof5i7yCKmlekkIldH0'; interface IProps { provider: JiraProvider; } interface IInnerProps extends IProps { settingsStore: SettingsStore; } export const LoginButton = compose( inject('settingsStore'), observer )(({ provider, settingsStore }) => { async function handleLogin() { try { const data = await initJiraOauthFlow(); provider.setAuth(data); } catch (error) { console.error('Could not connect the Jira account', error); } } return ( <>
Connect your Atlassian account

Click on the button below to connect OctoLenses to your Atlassian account.

The only way to connect your Atlassian account is using the OAuth flow. This means you’ll have to grant access to your account to the OctoLenses Jira application.

During the authentication flow, a token swap service (whose source code is auditable{' '} here ) is used in order for you to obtain an access token.

); }); /** * Start an OAuth authentication flow with Jira. * It automatically handles the redirection_url as part of the chrome.identity * API. An external (privately hosted by me) token swap service is then used * to obtain access/refresh tokens. */ async function initJiraOauthFlow(): Promise { const redirectUri = chrome.identity.getRedirectURL('provider_cb'); const redirectRegexp = new RegExp(redirectUri + '[#?](.*)'); return new Promise((resolve, reject) => { const options = { interactive: true, url: 'https://auth.atlassian.com/authorize' + '?audience=api.atlassian.com' + `&client_id=${CLIENT_ID}` + '&scope=read%3Ajira-user%20read%3Ajira-work%20offline_access' + `&redirect_uri=${encodeURIComponent(redirectUri)}` + `&response_type=code` + `&prompt=consent`, }; chrome.identity.launchWebAuthFlow(options, response => { // A generic error happened if (chrome.runtime.lastError) { return reject(chrome.runtime.lastError.message); } // Unable to extract an authorization code from the response const authCode = chain(redirectRegexp.exec(response)) .get(1) .split('=') .get(1) .value(); if (!authCode) { return reject( `Couldn't extract a authorization code from Jira response` ); } // Swap the authorization code for an access and refresh token. swapToken(authCode, redirectUri) .then(data => { resolve(data); }) .catch(err => { reject(err); }); }); }); } ================================================ FILE: src/providers/jira/components/LogoutButton.tsx ================================================ import { observer } from 'mobx-react'; import React from 'react'; import { JiraProvider } from '..'; import { Button, ButtonType } from '../../../components/Button'; interface IProps { provider: JiraProvider; } export const LogoutButton = observer(({ provider }: IProps) => { function handleLogout() { provider.disconnect(); } return ( <>
Connect your Atlassian account

Your Atlassian account is properly connected to OctoLenses.

); }); ================================================ FILE: src/providers/jira/components/Settings.tsx ================================================ import { observer } from 'mobx-react'; import React from 'react'; import { JiraProvider } from '..'; import { AvailableResources } from './AvailableResources'; import { LoginButton } from './LoginButton'; import { LogoutButton } from './LogoutButton'; interface IProps { provider: JiraProvider; } export const Settings = observer(({ provider }: IProps) => (
{provider.settings.auth ? ( ) : ( )}
)); ================================================ FILE: src/providers/jira/fetchers/index.ts ================================================ import { chain, get } from 'lodash'; import hash from 'object-hash'; import { IJiraResource, IJiraSettings } from '..'; import { Cache } from '../../../lib/cache'; import { Filter } from '../../../store/filters'; export async function fetchFilter( filter: Filter, settings: IJiraSettings, resource: IJiraResource ): Promise { const site = resource.id; const token = get(settings, 'auth.access_token'); const filterString = chain(filter.predicates) .map(predicate => filter.serializePredicate(predicate)) .map(encodeURIComponent) .join('+AND+') .value(); const url = `https://api.atlassian.com/ex/jira/${site}/rest/api/2/search?jql=${filterString}`; const cacheKey = `jira.filter.${hash(url)}`; const { issues } = await Cache.remember(cacheKey, 60, () => fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }).then(res => res.json()) ); return issues; } ================================================ FILE: src/providers/jira/fetchers/refreshToken.ts ================================================ interface IRefreshResult { access_token: string; expires_in: number; } export const refreshToken = async ( refreshToken: string ): Promise => { const url = 'https://octolenses.now.sh/api/refresh'; const { data } = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken, }), }).then(res => res.json()); return data; }; ================================================ FILE: src/providers/jira/fetchers/resources.ts ================================================ export const fetchResources = async (token: string) => { const url = 'https://api.atlassian.com/oauth/token/accessible-resources'; const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }); return await response.json(); }; ================================================ FILE: src/providers/jira/fetchers/swapToken.ts ================================================ export interface ISwapResult { access_token: string; refresh_token: string; expires_in: number; } export const swapToken = async ( authCode: string, redirectUri: string ): Promise => { const url = 'https://octolenses.now.sh/api/swap'; const { data } = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: authCode, redirect_uri: redirectUri, }), }).then(res => res.json()); return data; }; ================================================ FILE: src/providers/jira/index.tsx ================================================ /* eslint-disable @typescript-eslint/camelcase */ import { find, get } from 'lodash'; import { action, computed, observable } from 'mobx'; import hash from 'object-hash'; import React from 'react'; import { Cache } from '../../lib/cache'; import { Filter } from '../../store/filters'; import { AbstractProvider } from '../AbstractProvider'; import { IProps as IIssueCardProps, IssueCard } from './components/IssueCard'; import { Settings } from './components/Settings'; import { fetchFilter } from './fetchers'; import { refreshToken } from './fetchers/refreshToken'; import { fetchResources } from './fetchers/resources'; import { ISwapResult } from './fetchers/swapToken'; import { availablePredicates } from './predicates'; const FIVE_MINUTES = 5 * 60 * 1000; // ms export interface IJiraSettings { auth: { access_token: string; refresh_token: string; expires_at: number; }; } export interface IJiraResource { avatarUrl: string; id: string; name: string; scopes: string[]; url: string; } export class JiraProvider extends AbstractProvider { public id = 'jira'; public label = 'Jira'; public settingsComponent = () => ; public cardComponent = (props: Omit) => ( ); @observable public resources: IJiraResource[] = []; public async initialize() { if (this.shouldRefreshToken) { await this.refreshToken(); } await this.fetchResources(); } public async fetchFilter(filter: Filter) { return fetchFilter(filter, this.settings, this.resources[0]); } public resolveFilterItemIdentifier(item: any): string { return item.key; } public getAvailablePredicates = () => availablePredicates; public findPredicate(name: string) { return find(this.getAvailablePredicates(), { name }); } @computed private get shouldRefreshToken() { const refresh_token = get(this.settings, 'auth.refresh_token'); const expires_at = get(this.settings, 'auth.expires_at'); return refresh_token && expires_at - FIVE_MINUTES <= Date.now(); } @action.bound public async refreshToken() { const { refresh_token } = this.settings.auth; const { access_token, expires_in } = await refreshToken(refresh_token); this.setAuth({ access_token, expires_in, refresh_token }); } @action.bound public setAuth({ access_token, expires_in, refresh_token }: ISwapResult) { this.settings.auth = { refresh_token, access_token, expires_at: Date.now() + expires_in * 1000, }; this.fetchResources(); } @action.bound public disconnect() { this.settings.auth = null; this.resources = []; } @action.bound public async fetchResources() { const token = get(this.settings, 'auth.access_token'); if (!token) { return; } const cacheKey = `jira.resources.${hash(token)}`; this.resources = await Cache.remember(cacheKey, 5 * 60, () => fetchResources(token) ); } } export const jira = new JiraProvider(); ================================================ FILE: src/providers/jira/predicates/index.ts ================================================ import { capitalize, chain } from 'lodash'; import { Predicate, PredicateType } from '../../types'; enum JiraOperators { EQ = '=', NEQ = '!=', GT = '>', GTE = '>=', LT = '<', LTE = '<=', IN = 'IN', NOT_IN = 'NOT IN', CONTAINS = '~', NOT_CONTAINS = '!~', } interface ISimplePredicatePayload { name: string; placeholder?: string; label?: string; categorical?: boolean; numerical?: boolean; textual?: boolean; } /** * Makes a simple text predicate * @param options Options configuring the predicate */ export const makePredicate = ({ name, label, placeholder = '', categorical = true, numerical = false, textual = false, }: ISimplePredicatePayload): Predicate => ({ name, label: label || capitalize(name), placeholder, type: PredicateType.TEXT, operators: makeAvailableOperators(categorical, numerical, textual), serialize: ({ value, operator }) => `${name} ${operator} ${value}`, }); const makeAvailableOperators = ( categorical: boolean, numerical: boolean, textual: boolean ) => chain([]) .concat( numerical && [ { value: JiraOperators.EQ, label: '=' }, { value: JiraOperators.NEQ, label: '!=' }, { value: JiraOperators.GT, label: '>' }, { value: JiraOperators.GTE, label: '>=' }, { value: JiraOperators.GTE, label: '<' }, { value: JiraOperators.GTE, label: '<=' }, ], categorical && [ { value: JiraOperators.EQ, label: '=' }, { value: JiraOperators.NEQ, label: '!=' }, { value: JiraOperators.IN, label: 'IN' }, { value: JiraOperators.NOT_IN, label: 'NOT IN' }, ], textual && [ { value: JiraOperators.CONTAINS, label: '~' }, { value: JiraOperators.NOT_CONTAINS, label: '!~' }, ] ) .compact() .uniq() .value(); export const availablePredicates: Predicate[] = [ makePredicate({ name: 'project', placeholder: 'MYPROJECT' }), makePredicate({ name: 'status', placeholder: 'open/closed' }), makePredicate({ name: 'resolution' }), makePredicate({ name: 'sprint' }), makePredicate({ name: 'assignee' }), makePredicate({ name: 'component' }), makePredicate({ name: 'created', numerical: true }), makePredicate({ name: 'creator', numerical: true }), makePredicate({ name: 'label' }), makePredicate({ name: 'level' }), makePredicate({ name: 'priority', numerical: true }), makePredicate({ name: 'reporter' }), makePredicate({ name: 'resolved', numerical: true }), makePredicate({ name: 'timeSpent', numerical: true }), makePredicate({ name: 'type' }), makePredicate({ name: 'updated', numerical: true }), makePredicate({ name: 'workRatio', label: 'Work Ratio', numerical: true }), makePredicate({ name: 'comment', categorical: false, textual: true }), makePredicate({ name: 'description', categorical: false, textual: true }), makePredicate({ name: 'summary', categorical: false, textual: true }), makePredicate({ name: 'text', categorical: false, textual: true }), makePredicate({ name: 'issueKey', label: 'Issue Key', numerical: true, }), makePredicate({ name: 'remainingEstimate', label: 'Remaining Estimate', numerical: true, }), ]; ================================================ FILE: src/providers/types.ts ================================================ export enum ProviderType { GITHUB = 'github', JIRA = 'jira', } type PredicateIdentifier = string; // Template predicate, as returned by providers interface IBasePredicate { type: PredicateType; name: PredicateIdentifier; label: string; operators: IPredicateOperator[]; serialize?: (payload: { value: string; operator?: string }) => string; } // A simple text predicate interface ITextPredicate extends IBasePredicate { type: PredicateType.TEXT; placeholder: string; } // A dropdown predicate, allowing to pick from multiple choices export interface IDropdownPredicate extends IBasePredicate { type: PredicateType.DROPDOWN; choices: Array<{ value: string; label: string }>; } export type Predicate = ITextPredicate | IDropdownPredicate; // A predicate once it's been stored and configured export interface IStoredPredicate { type: PredicateIdentifier; value: string; operator?: string; } export enum PredicateType { TEXT = 'text', DROPDOWN = 'dropdown', } interface IPredicateOperator { value: string; label: string; } ================================================ FILE: src/service_worker/cache.js ================================================ import 'babel-polyfill'; import { difference } from 'lodash'; const PRECACHE = 'precache-v1'; const RUNTIME = 'runtime'; // List of URLs of resources we want to always serve from the cache. const PRECACHE_URLS = [ 'https://use.fontawesome.com/releases/v5.6.0/css/all.css', 'https://use.fontawesome.com/releases/v5.6.0/webfonts/fa-brands-400.woff2', 'https://use.fontawesome.com/releases/v5.6.0/webfonts/fa-solid-900.woff2', 'https://use.fontawesome.com/releases/v5.6.0/webfonts/fa-regular-400.woff2', 'https://fonts.googleapis.com/css?family=Open+Sans|Roboto:400,500', ]; // On install of the Service Worker, precache all the resources that need to. self.addEventListener('install', event => { event.waitUntil( caches .open(PRECACHE) .then(cache => cache.addAll(PRECACHE_URLS)) .then(self.skipWaiting()) ); }); // On activation of the Service Worker, remove the old cache buckets. self.addEventListener('activate', event => { const currentCaches = [PRECACHE, RUNTIME]; event.waitUntil( caches .keys() .then(cacheNames => difference(cacheNames, currentCaches)) .then(cachesToDelete => { return Promise.all( cachesToDelete.map(cacheToDelete => { return caches.delete(cacheToDelete); }) ); }) .then(() => self.clients.claim()) ); }); ================================================ FILE: src/service_worker/index.js ================================================ import './cache.js'; function openInNewTab() { chrome.tabs.create({ url: chrome.runtime.getURL('index.html'), }); } // On click of the extension's icon, open OctoLenses in a new tab. chrome.action.onClicked.addListener(openInNewTab); ================================================ FILE: src/setupTests.ts ================================================ require('jest-localstorage-mock'); // tslint:disable-line no-var-requires ================================================ FILE: src/store/filters.ts ================================================ import { find, findIndex } from 'lodash'; import { action, computed, observable } from 'mobx'; import { persist } from 'mobx-persist'; import { arrayMove } from 'react-sortable-hoc'; import { ProviderType } from '../providers'; import { Filter, FilterIdentifier } from './models/filter'; import { settingsStore } from './settings'; export { Filter }; export class FiltersStore { @persist('list', Filter) @observable private data: Filter[] = []; @computed get count() { return this.data.length; } public findFilter(id: string) { return find(this.data, { id }); } public findFilterIndex(id: string) { return findIndex(this.data, { id }); } public getFilters() { return this.data; } public getFilterAt(index: number) { return this.data[index]; } public getFirstFilter() { return this.data[0] || null; } // TODO Any @action.bound public saveFilter(filterPayload: any) { const index = findIndex(this.data, { id: filterPayload.id }); // If we're saving a filter that already exists, we only need to update // some of its attributes if (index !== -1) { this.data[index].update(filterPayload); return; } // Else create a new filter const filter = Filter.fromAttributes(filterPayload); this.data.push(filter); filter.fetchFilter(); settingsStore.selectedFilterId = filter.id; } @action.bound public cloneFilter(id: FilterIdentifier) { const index = findIndex(this.data, { id }); const filter = this.data[index]; const clonedFilter = filter.clone(); this.data.splice(index + 1, 0, clonedFilter); return clonedFilter; } @action.bound public removeFilter(id: FilterIdentifier) { const index = findIndex(this.data, { id }); this.data.splice(index, 1); } @action.bound public swapFilters(oldIndex: number, newIndex: number) { this.data = arrayMove(this.data, oldIndex, newIndex); } public async fetchAllFilters() { await Promise.all(this.data.map(filter => filter.invalidateCache())); } } export const EMPTY_FILTER_PAYLOAD = { provider: ProviderType.GITHUB, label: 'Unnamed filter', predicates: [{ type: 'status', value: 'open' }], }; export const filtersStore = new FiltersStore(); ================================================ FILE: src/store/index.ts ================================================ import { chain } from 'lodash'; import { create } from 'mobx-persist'; import { Cache } from '../lib/cache'; import { migrator } from '../migrations'; import { providers, ProviderType } from '../providers'; import { filtersStore } from './filters'; import { navigationStore } from './navigation'; import { settingsStore } from './settings'; import { trendsStore } from './trends'; const hydrateStores = async () => { const hydrate = create({}); // Re-hydrate the stores await Promise.all([ hydrate('navigationStore', navigationStore), hydrate('settingsStore', settingsStore), hydrate('filtersStore', filtersStore), ]); // Re-hydrate the providers await Promise.all( chain(providers) .values() .map(provider => hydrate(`${provider.id}Provider`, provider)) .value() ); }; const initializeProviders = async () => { await Promise.all( chain(providers) .values() .map(provider => provider.initialize()) .value() ); }; const performOnboarding = () => { if (settingsStore.wasOnboarded) { return; } filtersStore.saveFilter({ label: 'OctoLenses Issues', provider: ProviderType.GITHUB, data: [], loading: false, predicates: [ { type: 'type', value: 'issue' }, { type: 'repo', value: 'rgehan/octolenses' }, { type: 'status', value: 'open' }, ], }); settingsStore.updateWasOnboarded(true); }; export const refreshAllData = async () => { // prettier-ignore await Promise.all([ trendsStore.fetchTrendingRepos(), filtersStore.fetchAllFilters(), ]); }; export const bootstrap = async () => { migrator.migrate(); await hydrateStores(); await initializeProviders(); performOnboarding(); await refreshAllData(); Cache.flushExpired(); }; // This shouldn't be typed, as we don't want to advertize that this is available // on the global window object. It's only there for debugging purposes (window as any).stores = { navigationStore, filtersStore, trendsStore, settingsStore, }; export { navigationStore, filtersStore, trendsStore, settingsStore }; ================================================ FILE: src/store/models/filter.ts ================================================ import { difference, map, merge } from 'lodash'; import { action, computed, observable, reaction } from 'mobx'; import { persist } from 'mobx-persist'; import hash from 'object-hash'; import uuidv1 from 'uuid/v1'; import { toast } from '../../components/ToastManager'; import { providers, ProviderType, IStoredPredicate } from '../../providers'; export type FilterIdentifier = string; export class Filter { @persist public provider: ProviderType; @persist @observable public id: FilterIdentifier; @persist @observable public label = ''; @persist('list') @observable public predicates: IStoredPredicate[] = []; @observable public data: any[] = []; // TODO @observable public loading = true; @observable public error: Error = null; @persist @observable public lastModified = 0; @persist('list') @observable private previousItemsIdentifiers: string[] = []; @observable private newItemsIdentifiers: string[] = []; @observable private disableNotificationsOnNextFetch: false; constructor() { // When the hash of the filter changes, re-fetch it reaction( () => this.hash, () => { this.fetchFilter(); } ); } /* * Static */ public static fromAttributes({ id, ...otherAttributes }: any) { const filter = new Filter(); filter.id = id || uuidv1(); merge(filter, otherAttributes); return filter; } /* * Public API */ public serializePredicate(payload: IStoredPredicate): string { const provider = providers[this.provider]; const predicate = provider.findPredicate(payload.type); return predicate.serialize(payload); } public clone(): Filter { return Filter.fromAttributes({ provider: this.provider, label: `${this.label} (Copy)`, predicates: this.predicates, data: this.data, loading: this.loading, error: this.error, lastModified: this.lastModified, previousItemsIdentifiers: this.previousItemsIdentifiers, newItemsIdentifiers: this.newItemsIdentifiers, }); } public isItemNew(item: any) { const identifier = this.getItemIdentifier(item); return this.newItemsIdentifiers.includes(identifier); } public clearNewItemsNotifications() { this.newItemsIdentifiers = []; } public update(payload: any) { Object.assign(this, { disableNotificationsOnNextFetch: true, ...payload, }); } /* * Computed */ @computed public get hash(): string { return hash({ id: this.id, lastModified: this.lastModified, predicates: this.predicates, }); } @computed public get newItemsCount() { return this.newItemsIdentifiers.length; } /* * Actions */ @action.bound public async fetchFilter() { this.loading = true; this.error = null; try { const result = await providers[this.provider].fetchFilter(this); this.setData(result); } catch (error) { toast('Oops, something failed with your filter!', 'error'); this.error = error; } this.loading = false; } @action.bound public invalidateCache() { this.lastModified = Date.now(); } @action.bound public setData(data: any) { const currentItemsIdentifiers = this.getItemsIdentifiers(data); // Update the data this.data = data; if (!this.disableNotificationsOnNextFetch) { // Store the IDs of the items that weren't previously known this.newItemsIdentifiers = difference( currentItemsIdentifiers, this.previousItemsIdentifiers ); } else { this.newItemsIdentifiers = []; this.disableNotificationsOnNextFetch = false; } // Store the IDs of the current items, for later comparison this.previousItemsIdentifiers = currentItemsIdentifiers; } private getItemsIdentifiers(items: any[]) { return map(items, this.getItemIdentifier); } private getItemIdentifier = (item: any) => { const provider = providers[this.provider]; return provider.resolveFilterItemIdentifier(item); }; } ================================================ FILE: src/store/navigation.ts ================================================ import { action, observable } from 'mobx'; import { persist } from 'mobx-persist'; export class NavigationStore { @persist @observable public page = 'dashboard'; @action.bound public navigateTo(newPage: string) { this.page = newPage; } } export const navigationStore = new NavigationStore(); ================================================ FILE: src/store/settings.ts ================================================ import { action, autorun, observable } from 'mobx'; import { persist } from 'mobx-persist'; import { DARK_MODE } from '../constants/darkMode'; import { DATES, DateType } from '../constants/dates'; import { LANGUAGES } from '../constants/languages'; export class SettingsStore { /** * Language used in the "Discover" page */ @persist @observable public language = LANGUAGES[0].value; /** * How far in the past to find repos in the "Discover" page */ @persist @observable public dateRange = DATES[0].value; /** * DEPRECATED. Legacy place where the GitHub token was stored. */ @persist @observable public token: string = undefined; /** * Current dark mode state. Whether it's always on/off, or only at night. */ @persist @observable public darkMode = DARK_MODE.DISABLED; /** * Whether the "onboarding" was run */ @persist @observable public wasOnboarded = false; /** * Id of the filter that is selected in the sidebar */ @persist @observable public selectedFilterId: string = null; /** * Version of the current settings schema. It is used to determine which * migrations to run. */ @persist @observable public schemaVersion = 3; @observable public isDark = false; public applyDarkMode() { const hours = new Date().getHours(); const isNightTime = hours >= 19 || hours <= 7; if (this.darkMode === DARK_MODE.ENABLED) { this.isDark = true; } else if (this.darkMode === DARK_MODE.AT_NIGHT && isNightTime) { this.isDark = true; } else { this.isDark = false; } document.body.className = this.isDark ? 'dark' : 'light'; } @action.bound public updateDarkMode(darkMode: string) { this.darkMode = darkMode; } @action.bound public updateWasOnboarded(wasOnboarded: boolean) { this.wasOnboarded = wasOnboarded; } @action.bound public updateLanguage(language: string) { this.language = language; } @action.bound public updateDateRange(dateRange: DateType) { this.dateRange = dateRange; } } export const settingsStore = new SettingsStore(); // Apply darkMode on the page whenever it changes autorun(() => { settingsStore.applyDarkMode(); }); // Update dark mode periodically in case it's now the night setInterval(() => { settingsStore.applyDarkMode(); }, 1000); ================================================ FILE: src/store/trends.ts ================================================ import { action, observable } from 'mobx'; import { getDateFromValue } from '../constants/dates'; import { fetchTrendingRepos } from '../lib/github'; import { settingsStore } from './settings'; export class TrendsStore { @observable public data: any[] = []; @observable public loading = false; @action.bound public updateRepos(newRepos: any[]) { this.data = newRepos; } @action.bound public setLoading(isLoading: boolean) { this.loading = isLoading; } @action.bound public async fetchTrendingRepos() { const language = settingsStore.language; const date = getDateFromValue(settingsStore.dateRange); const token = settingsStore.token; this.loading = true; this.data = await fetchTrendingRepos({ language, date, token }); this.loading = false; } } export const trendsStore = new TrendsStore(); ================================================ FILE: tailwind.config.js ================================================ module.exports = { theme: { fontFamily: { open: ['Open Sans', 'sans-serif'], roboto: ['Roboto', 'sans-serif'], mono: [ 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace', ], }, extend: { fontSize: { '2xs': '.625rem', // 10px }, inset: { '4': '1rem', }, }, screens: { sm: '640px', md: '768px', lg: '1024px', xl: '1280px', }, fontSize: { xs: '0.75rem', sm: '0.875rem', base: '1rem', lg: '1.125rem', xl: '1.25rem', '2xl': '1.5rem', '3xl': '1.875rem', '4xl': '2.25rem', '5xl': '3rem', '6xl': '4rem', }, colors: { transparent: 'transparent', current: 'currentColor', black: '#000', white: '#fff', gray: { 100: '#f7fafc', 200: '#edf2f7', 300: '#e2e8f0', 400: '#cbd5e0', 500: '#a0aec0', 600: '#718096', 700: '#4a5568', 800: '#2d3748', 900: '#1a202c', }, red: { 100: '#fff5f5', 200: '#fed7d7', 300: '#feb2b2', 400: '#fc8181', 500: '#f56565', 600: '#e53e3e', 700: '#c53030', 800: '#9b2c2c', 900: '#742a2a', }, orange: { 100: '#fffaf0', 200: '#feebc8', 300: '#fbd38d', 400: '#f6ad55', 500: '#ed8936', 600: '#dd6b20', 700: '#c05621', 800: '#9c4221', 900: '#7b341e', }, yellow: { 100: '#fffff0', 200: '#fefcbf', 300: '#faf089', 400: '#f6e05e', 500: '#ecc94b', 600: '#d69e2e', 700: '#b7791f', 800: '#975a16', 900: '#744210', }, green: { 100: '#f0fff4', 200: '#c6f6d5', 300: '#9ae6b4', 400: '#68d391', 500: '#48bb78', 600: '#38a169', 700: '#2f855a', 800: '#276749', 900: '#22543d', }, teal: { 100: '#e6fffa', 200: '#b2f5ea', 300: '#81e6d9', 400: '#4fd1c5', 500: '#38b2ac', 600: '#319795', 700: '#2c7a7b', 800: '#285e61', 900: '#234e52', }, blue: { 100: '#ebf8ff', 200: '#bee3f8', 300: '#90cdf4', 400: '#63b3ed', 500: '#4299e1', 600: '#3182ce', 700: '#2b6cb0', 800: '#2c5282', 900: '#2a4365', }, indigo: { 100: '#ebf4ff', 200: '#c3dafe', 300: '#a3bffa', 400: '#7f9cf5', 500: '#667eea', 600: '#5a67d8', 700: '#4c51bf', 800: '#434190', 900: '#3c366b', }, purple: { 100: '#faf5ff', 200: '#e9d8fd', 300: '#d6bcfa', 400: '#b794f4', 500: '#9f7aea', 600: '#805ad5', 700: '#6b46c1', 800: '#553c9a', 900: '#44337a', }, pink: { 100: '#fff5f7', 200: '#fed7e2', 300: '#fbb6ce', 400: '#f687b3', 500: '#ed64a6', 600: '#d53f8c', 700: '#b83280', 800: '#97266d', 900: '#702459', }, }, }, variants: { backgroundColor: ['responsive', 'hover', 'active'], }, plugins: [], }; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "outDir": "./dist/", "allowJs": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true, "lib": ["dom", "es2015", "es2016", "es2017", "es2018"], "jsx": "react", "target": "es6", "module": "esNext", "moduleResolution": "node", "noImplicitAny": true, "noUnusedLocals": true, "noUnusedParameters": true, "removeComments": false, "preserveConstEnums": true, "sourceMap": true, "skipLibCheck": true, "noEmitOnError": false, "experimentalDecorators": true, "resolveJsonModule": true, "types": ["chrome", "jest", "node"] }, "include": ["./src/**/*.ts", "./src/**/*.tsx"], "exclude": ["node_modules", "dist"] }