diff --git a/browserslist b/.browserslistrc similarity index 100% rename from browserslist rename to .browserslistrc diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d255c61a..7250be3b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,39 @@ ### Summary: +Fixes #[Issue_number] + +#### Type of change: + +(Delete where appropriate) + +- โœจ New Feature/ Enhancement +- ๐Ÿ› Bug Fix +- ๐Ÿงช Tests Update +- ๐Ÿ“ Documentation Update (CATcher-org/WATcher-docs#[Issue_number]) +- ๐ŸŽจ Code Refactoring + ### Changes Made: -- +- [Description of the changes made in your PR] + +### Screenshots: -### Commit Message: +(If applicable, provide screenshots or GIFs to visually demonstrate the changes.) + +### Proposed Commit Message: ``` -Let's +Commit message to be used when the PR is merged +(NOTE: Wrap the body at 72 characters) ``` + +
+

Checklist:

+
+ +- [ ] I have tested my changes thoroughly. +- [ ] I have created tests for any new code files created in this PR or provided a link to a issue/PR that addresses this. +- [ ] I have added or modified code comments to improve code readability where necessary. +- [ ] I have updated the project's documentation as necessary. + +
diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml index a37cef4a..0ec7609f 100644 --- a/.github/workflows/coverage-report.yml +++ b/.github/workflows/coverage-report.yml @@ -6,16 +6,16 @@ name: Get Code Coverage Report on: push: - branches: [ master, release ] + branches: [master, release] pull_request: - branches: [ master, release ] + branches: [master, release] jobs: code-coverage: runs-on: ubuntu-latest strategy: matrix: - node-version: [12.x] + node-version: [14.x] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} diff --git a/.github/workflows/deployment-actions.yml b/.github/workflows/deployment-actions.yml index d45cbd9f..0b5b6bae 100644 --- a/.github/workflows/deployment-actions.yml +++ b/.github/workflows/deployment-actions.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [12.x] + node-version: [14.x] steps: - name: Checkout uses: actions/checkout@v2 @@ -56,84 +56,3 @@ jobs: - name: Set upload_url Context id: set_upload_url run: echo "::set-output name=upload_url::${{ steps.draft_release.outputs.upload_url }}" - - deploy_linux: - runs-on: ubuntu-latest - needs: draft_release - strategy: - matrix: - node-version: [12.x] - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Build - run: | - npm install - npm run electron:linux - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.draft_release.outputs.upload_url }} - asset_path: ./release/WATcher-${{ needs.draft_release.outputs.version_num }}.AppImage - asset_name: WATcher-${{ needs.draft_release.outputs.version_num }}.AppImage - asset_content_type: application/octet-stream - - deploy_windows: - runs-on: windows-latest - needs: draft_release - strategy: - matrix: - node-version: [12.x] - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Build - run: | - npm install - npm run electron:windows - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.draft_release.outputs.upload_url }} - asset_path: ./release/WATcher ${{ needs.draft_release.outputs.version_num }}.exe - asset_name: WATcher-${{ needs.draft_release.outputs.version_num }}.exe - asset_content_type: application/octet-stream - - deploy_macos: - runs-on: macos-latest - needs: draft_release - strategy: - matrix: - node-version: [12.x] - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Build - run: | - npm install - npm run electron:mac - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.draft_release.outputs.upload_url }} - asset_path: ./release/WATcher-${{ needs.draft_release.outputs.version_num }}.dmg - asset_name: WATcher-${{ needs.draft_release.outputs.version_num }}.dmg - asset_content_type: application/octet-stream diff --git a/.github/workflows/deployment-staging.yml b/.github/workflows/deployment-staging.yml index 131ec3e2..736a2621 100644 --- a/.github/workflows/deployment-staging.yml +++ b/.github/workflows/deployment-staging.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [12.x] + node-version: [14.x] steps: - name: Checkout uses: actions/checkout@v2 diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 000fcf22..b7a17294 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -6,25 +6,25 @@ name: Setup Builds and Tests on: push: - branches: [ main, release ] + branches: [main, release] pull_request: - branches: [ main, release ] + branches: [main, release] jobs: linux-setup-and-tests: runs-on: ubuntu-latest strategy: matrix: - node-version: [12.x] + node-version: [14.x] steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm update - - run: npm run lint + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm update + - run: npm run lint # - run: npm test -- "--karma-config=./tests/karma.ci.conf.js" # - run: npm run webdriver-manager update -- --gecko false --standalone false --versions.chrome $(curl https://chromedriver.storage.googleapis.com/LATEST_RELEASE_$(google-chrome --version | grep -iEo "[0-9]{1,3}" | head -n1)) # - run: npm run webdriver-manager update -- --chrome false --standalone false @@ -34,30 +34,30 @@ jobs: runs-on: macos-latest strategy: matrix: - node-version: [12.x] + node-version: [14.x] steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm update - - run: npm run lint + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm update + - run: npm run lint # - run: npm test -- "--karma-config=./tests/karma.ci.conf.js" || npm test -- "--karma-config=./tests/karma.ci.conf.js" # retry tests once, in case they timed out on Mac OS windows-setup-and-tests: runs-on: windows-latest strategy: matrix: - node-version: [12.x] + node-version: [14.x] steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm update - - run: npm run lint + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm update + - run: npm run lint # - run: npm test -- "--karma-config=./tests/karma.ci.conf.js" diff --git a/.github/workflows/staging-fetch.yml b/.github/workflows/staging-fetch.yml index 949e4a76..6e25936a 100644 --- a/.github/workflows/staging-fetch.yml +++ b/.github/workflows/staging-fetch.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [12.x] + node-version: [14.x] steps: - name: Checkout Upstream uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 295702ac..da66daf9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ /release main.js src/**/*.js -electron-utils/*.js *.js.map # tests coverage diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..9f418990 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v14.17.6 \ No newline at end of file diff --git a/README.md b/README.md index 0c840426..01024f94 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,13 @@ **WAT**cher is a sister application to CATcher to be used to monitor software projects in a visual way, for educational use in particular. - [**WATcher Ideation Discussion Page**](https://github.com/CATcher-org/CATcher/discussions/938) -- **WATcher Documentation Home** :construction: -(under development) - - Direct link to the **User Guide** - - Direct link to the **Developer Guide** +- [**WATcher Documentation Home**](https://catcher-org.github.io/WATcher-docs/) :construction: + (under development) + - [Direct link to the **User Guide**](https://catcher-org.github.io/WATcher-docs/ug/) + - [Direct link to the **Developer Guide**](https://catcher-org.github.io/WATcher-docs/dg/) + +This project is based in [NUS School of Computing](https://www.comp.nus.edu.sg/), and part of the [NUS-OSS initiative](https://nus-oss.github.io/). + +Licence: MIT + +Contact: `seer@comp.nus.edu.sg` diff --git a/angular.json b/angular.json index 7edfb4f5..a2a68754 100644 --- a/angular.json +++ b/angular.json @@ -11,6 +11,7 @@ "build": { "builder": "@angular-devkit/build-angular:browser", "options": { + "aot": true, "outputPath": "dist", "index": "src/index.html", "main": "src/main.ts", @@ -24,21 +25,17 @@ "src/favicon.256x256.png", "src/favicon.512x512.png" ], - "styles": [ - "node_modules/prismjs/themes/prism-okaidia.css", - "src/styles.css", - "src/markdown.css", - "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css" - ], - "scripts": [ - "node_modules/marked/lib/marked.js", - "node_modules/prismjs/prism.js", - "node_modules/prismjs/components/prism-csharp.min.js", - "node_modules/prismjs/components/prism-css.min.js" - ] + "styles": ["src/styles.css", "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css"], + "scripts": [] }, "configurations": { "staging": { + "budgets": [ + { + "type": "anyComponentStyle", + "maximumWarning": "6kb" + } + ], "optimization": true, "outputHashing": "all", "sourceMap": false, @@ -56,6 +53,12 @@ ] }, "production": { + "budgets": [ + { + "type": "anyComponentStyle", + "maximumWarning": "6kb" + } + ], "optimization": true, "outputHashing": "all", "sourceMap": false, @@ -73,6 +76,12 @@ ] }, "test": { + "budgets": [ + { + "type": "anyComponentStyle", + "maximumWarning": "6kb" + } + ], "optimization": false, "outputHashing": "all", "sourceMap": true, @@ -145,10 +154,13 @@ "schematics": { "@schematics/angular:component": { "prefix": "app", - "styleext": "css" + "style": "css" }, "@schematics/angular:directive": { "prefix": "app" } + }, + "cli": { + "analytics": "80a9cde9-16b0-438d-9c52-044c077b19d0" } } diff --git a/codegen.yml b/codegen.yml index 901a4fd5..32397492 100644 --- a/codegen.yml +++ b/codegen.yml @@ -1,5 +1,7 @@ overwrite: true -schema: "graphql/schema/github-schema.ts" +schema: + - "graphql/schema/github-schema.ts" + - "graphql/schema/schema-extension.graphql" documents: - graphql/fragments/*.graphql - graphql/queries/*.graphql diff --git a/electron-builder.json b/electron-builder.json deleted file mode 100644 index 0757e77a..00000000 --- a/electron-builder.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "appId": "WATcher", - "productName": "WATcher", - "directories": { - "output": "release/" - }, - "files": [ - "**/*", - "!**/*.ts", - "!*.code-workspace", - "!LICENSE.md", - "!package.json", - "!package-lock.json", - "!src/", - "!e2e/", - "!hooks/", - "!angular.json", - "!_config.yml", - "!karma.conf.js", - "!tsconfig.json", - "!tslint.json" - ], - "win": { - "icon": "dist/favicon.png", - "target": ["portable"] - }, - "mac": { - "icon": "dist/favicon.png", - "target": ["dmg"] - }, - "linux": { - "icon": "dist/favicon.png", - "target": ["AppImage"] - } -} diff --git a/electron-utils/menu-bar.ts b/electron-utils/menu-bar.ts deleted file mode 100644 index 12264fad..00000000 --- a/electron-utils/menu-bar.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { app, BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions } from 'electron'; - -// Edited version of a template menu-bar provided by the electron API, -// refer to https://electronjs.org/docs/api/menu for more information. -const fileMenu: MenuItemConstructorOptions = { - label: 'File', - submenu: [ - { - label: 'Quit WATcher', - accelerator: 'CmdOrCtrl+Q', - click() { - app.quit(); - } - } - ] -}; - -const editMenu: MenuItemConstructorOptions = { - label: 'Edit', - submenu: [ - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'selectAll' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { role: 'delete' } - ] -}; - -const viewMenu: MenuItemConstructorOptions = { - label: 'View', - submenu: [{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }] -}; - -export function createMenuOptions(isDevMode: boolean): MenuItemConstructorOptions[] { - const mainMenuTemplate: MenuItemConstructorOptions[] = [fileMenu, editMenu, viewMenu]; - - if (isDevMode) { - let viewSubMenu: MenuItemConstructorOptions[]; - viewSubMenu = mainMenuTemplate[2].submenu as MenuItemConstructorOptions[]; - viewSubMenu.push({ type: 'separator' }, { role: 'toggleDevTools' }); - } - return mainMenuTemplate; -} - -function createInspectElementMenuItem(contextMenuCoords: { x; y }): MenuItem { - return new MenuItem({ - label: 'Inspect Element', - click: (menuItem, window, event) => { - window.webContents.inspectElement(contextMenuCoords.x, contextMenuCoords.y); - } - }); -} - -/** - * Creates a menu that is displayed when the context-menu event fires on the - * given BrowserWindow (i.e. usually when user right-clicks on the window). - * This menu will contain an 'Inspect Element' MenuItem. - */ -export function createContextMenu(win: BrowserWindow): void { - const contextMenuCoords = { x: null, y: null }; - const contextMenu = new Menu(); - contextMenu.append(createInspectElementMenuItem(contextMenuCoords)); - - win.webContents.on('context-menu', (event, contextMenuParams) => { - // record the mouse position, when context-menu event is fired - contextMenuCoords.x = contextMenuParams.x; - contextMenuCoords.y = contextMenuParams.y; - contextMenu.popup({ window: win }); - }); -} diff --git a/electron-utils/oauth.ts b/electron-utils/oauth.ts deleted file mode 100644 index 3a2021b0..00000000 --- a/electron-utils/oauth.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { BrowserWindow, shell } from 'electron'; -import { v4 as uuid } from 'uuid'; - -const nodeUrl = require('url'); -const fetch = require('node-fetch'); -const Logger = require('electron-log'); - -const CLIENT_ID = 'dda4282bac3cac95148b'; // Client ID sent to github oauth -const BASE_URL = 'https://github.com'; -const ACCESS_TOKEN_URL = 'https://catcher-auth.herokuapp.com/authenticate'; -const CALLBACK_URL = 'http://localhost:4200'; - -let authWindow; - -/** - * Will retrieve the access token from a proxy server which acts as a intermediary to retrieve the tokens from Github. - * @param window - The main window of WATcher. - * @param repoPermissionLevel - The level of permission required to be granted by the user to use WATcher. - */ -export function getAccessToken(window: BrowserWindow, repoPermissionLevel: string): Promise { - return getAuthorizationCode(window, repoPermissionLevel) - .then((code) => { - Logger.info('Obtained authorization code from Github'); - const accessTokenUrl = `${ACCESS_TOKEN_URL}/${code}`; - return fetch(accessTokenUrl) - .then((res) => res.json()) - .then((data) => { - if (data.error) { - throw new Error(data.error); - } - return data; - }); - }) - .catch((error) => { - throw error; - }); -} - -/** - * Get the authorization code from Github after success login. - * @param parentWindow - The main window of WATcher - * @param repoPermissionLevel - The level of permission required to be granted by the user to use WATcher. - */ -function getAuthorizationCode(parentWindow: BrowserWindow, repoPermissionLevel: string) { - let state: string; - state = generateStateString(); - const oauthUrl = encodeURI( - `${BASE_URL}/login/oauth/authorize?client_id=${CLIENT_ID}&scope=${repoPermissionLevel},read:user&state=${state}` - ); - - return new Promise(function (resolve, reject) { - const windowParams = { - autoHideMenuBar: true, - alwaysOnTop: false, - fullscreenable: false, - parent: parentWindow, - webPreferences: { - nodeIntegration: true - } - }; - authWindow = new BrowserWindow(windowParams); - authWindow.loadURL(oauthUrl); - authWindow.show(); - Logger.info('Opening authentication window'); - - authWindow.on('closed', (event) => { - reject(new Error('WINDOW_CLOSED')); - }); - - authWindow.webContents.on('will-navigate', (event, newUrl) => { - if (newUrl.startsWith(CALLBACK_URL)) { - onCallback(newUrl); - } else if (newUrl.startsWith(`${BASE_URL}/session`) || newUrl.startsWith(`${BASE_URL}/login`)) { - // continue navigation within the auth window - return; - } else { - // do not navigate to external links in the auth window - // instead, navigate to them in the user's browser - event.preventDefault(); - shell.openExternal(newUrl).then(() => Logger.info('External link is clicked on auth window, opening system browser...')); - } - }); - - authWindow.webContents.on('new-window', (event, url, frameName, disposition, options) => { - event.preventDefault(); - shell.openExternal(url).then(() => Logger.info('External link is clicked on auth window, opening system browser...')); - }); - - authWindow.webContents.on('will-redirect', (event, newUrl) => { - Logger.info('Received redirect in auth window'); - if (newUrl.startsWith(CALLBACK_URL)) { - onCallback(newUrl); - } - }); - - function onCallback(callbackUrl: string) { - const url_parts = nodeUrl.parse(callbackUrl, true); - const query = url_parts.query; - const code = query.code; - const error = query.error; - const returnedState = query.state; - - if (error !== undefined) { - reject(error); - } else if (isReturnedStateSame(state, returnedState) && code) { - resolve(code); - } - setImmediate(function () { - authWindow.close(); - authWindow.on('closed', () => { - Logger.info('Closing authentication window'); - authWindow = null; - }); - }); - } - }); -} - -/** - * Generates and assigns an unguessable random 'state' string to pass to Github for protection against cross-site request forgery attacks - */ -function generateStateString(): string { - return uuid(); -} - -function isReturnedStateSame(state: string, returnedState: string): boolean { - return state === returnedState; -} diff --git a/electron-utils/supporting-logic.ts b/electron-utils/supporting-logic.ts deleted file mode 100644 index 6e6f522d..00000000 --- a/electron-utils/supporting-logic.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { app } from 'electron'; - -export function isDeveloperMode(): boolean { - const args = process.argv.slice(1), - serve = args.some((val) => val === '--serve'); - return !!serve; -} - -export function isWindowsOs(): boolean { - return process.platform === 'win32'; -} - -export function isMacOs(): boolean { - return process.platform === 'darwin'; -} - -export function isLinuxOs(): boolean { - return process.platform === 'linux'; -} - -export function getCurrentDirectory(isWindowsOs: boolean, isDevMode: boolean): string { - return isWindowsOs ? (isDevMode ? app.getAppPath() : process.env.PORTABLE_EXECUTABLE_FILE) : app.getAppPath(); -} - -export const appTitle: string = require('../package.json').name + ' ' + require('../package.json').version; diff --git a/graphql/fragments/issue-model.fragment.graphql b/graphql/fragments/issue-model.fragment.graphql index e6c336da..bf90701b 100644 --- a/graphql/fragments/issue-model.fragment.graphql +++ b/graphql/fragments/issue-model.fragment.graphql @@ -22,4 +22,5 @@ fragment issueModel on Issue { } } } + isDraft } diff --git a/graphql/queries/fetch-issues-by-team.graphql b/graphql/queries/fetch-issues-by-team.graphql deleted file mode 100644 index 8a27f4ea..00000000 --- a/graphql/queries/fetch-issues-by-team.graphql +++ /dev/null @@ -1,36 +0,0 @@ -query FetchIssuesByTeam($owner: String!, $name: String!, $tutorial: String!, $filter: IssueFilters, $cursor: String, $commentCursor: String) { - repository(owner: $owner, name: $name) { - label(name: $tutorial) { - issues(first: 100, filterBy: $filter, after: $cursor) { - edges { - cursor - node { - ...issue - labels(first: 100) { - edges { - node { - ...issueLabel - } - } - } - assignees(first: 100) { - edges { - node { - ...issueAssignee - } - } - } - comments(first: 100, after: $commentCursor) { - edges { - cursor - node { - ...issueComment - } - } - } - } - } - } - } - } -} diff --git a/graphql/queries/fetch-pullrequests.graphql b/graphql/queries/fetch-pullrequests.graphql index f9a2ff5e..f85eaa03 100644 --- a/graphql/queries/fetch-pullrequests.graphql +++ b/graphql/queries/fetch-pullrequests.graphql @@ -5,6 +5,7 @@ query FetchPullRequests($owner: String!, $name: String!, $cursor: String) { cursor node { ...pullrequest + isDraft labels(first: 100) { edges { node { diff --git a/graphql/schema/schema-extension.graphql b/graphql/schema/schema-extension.graphql new file mode 100644 index 00000000..632a77fb --- /dev/null +++ b/graphql/schema/schema-extension.graphql @@ -0,0 +1,3 @@ +extend type Issue { + isDraft: Boolean +} \ No newline at end of file diff --git a/main.ts b/main.ts deleted file mode 100644 index 960b0200..00000000 --- a/main.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { app, BrowserWindow, ipcMain, Menu, MenuItemConstructorOptions, nativeTheme, screen, shell } from 'electron'; -import * as path from 'path'; -import * as url from 'url'; -import { createContextMenu, createMenuOptions } from './electron-utils/menu-bar'; -import { getAccessToken } from './electron-utils/oauth'; -import { appTitle, isDeveloperMode, isLinuxOs, isMacOs } from './electron-utils/supporting-logic'; - -const Logger = require('electron-log'); -const ICON_PATH = path.join(__dirname, 'dist/favicon.512x512.png'); - -let win: BrowserWindow = null; -const isDevMode = isDeveloperMode(); - -/** - * Will start the OAuth Web Flow and obtain the access token from Github. - */ -ipcMain.on('github-oauth', (event, repoPermissionLevel) => { - Logger.info('Starting authentication'); - getAccessToken(win, repoPermissionLevel) - .then((data) => { - Logger.info('Obtained access token from Github'); - event.sender.send('github-oauth-reply', { token: data.token }); - }) - .catch((error) => { - event.sender.send('github-oauth-reply', { - error: error.message, - isWindowClosed: error.message === 'WINDOW_CLOSED' - }); - }); -}); - -ipcMain.handle('clear-storage', () => { - return win.webContents.session.clearStorageData(); -}); - -ipcMain.handle('open-link', (e, address) => { - shell.openExternal(address); -}); - -function createWindow() { - Logger.info('Creating primary window.'); - const size = screen.getPrimaryDisplay().workAreaSize; - const windowOptions = { - x: 0, - y: 0, - width: size.width, - height: size.height, - webPreferences: { - nodeIntegration: true, - allowRunningInsecureContent: !isDevMode - } - }; - - if (isLinuxOs()) { - // app icon needs to be set manually on Linux platforms - windowOptions['icon'] = ICON_PATH; - } - - // Create the browser window. - win = new BrowserWindow(windowOptions); - win.setTitle(appTitle); - - nativeTheme.themeSource = 'light'; - - if (isDevMode) { - require('electron-reload')(__dirname, { - electron: require(`${__dirname}/node_modules/electron`) - }); - - createContextMenu(win); - win.loadURL('http://localhost:4200'); - win.webContents.openDevTools(); - } else { - win.loadURL( - url.format({ - pathname: path.join(__dirname, 'dist/index.html'), - protocol: 'file:', - slashes: true - }) - ); - } - - // Emitted when the window is closed. - win.on('closed', () => { - // Dereference the window object, usually you would store window - // in an array if your app supports multi windows, this is the time - // when you should delete the corresponding element. - win = null; - }); -} - -try { - Logger.info('Initializing Electron app.'); - // This method will be called when Electron has finished - // initialization and is ready to create browser windows. - // Some APIs can only be used after this event occurs. - app.on('ready', () => { - Logger.info('Electron app in ready state.'); - // Build and Attach Menu-bar template to application. - const mainMenuTemplate: MenuItemConstructorOptions[] = createMenuOptions(isDevMode); - const mainMenu = Menu.buildFromTemplate(mainMenuTemplate); - Menu.setApplicationMenu(mainMenu); - - createWindow(); - }); - - // Quit when all windows are closed. - app.on('window-all-closed', () => { - Logger.info('Closing all windows in Electron.'); - // On OS X it is common for applications and their menu bar - // to stay active until the user quits explicitly with Cmd + Q - if (!isMacOs()) { - app.quit(); - } - }); - - app.on('activate', () => { - Logger.info('Electron app is activated.'); - // On OS X it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (win === null) { - createWindow(); - } - }); -} catch (e) { - Logger.error('Something went wrong in Electron.', e); -} diff --git a/package.json b/package.json index 7f9bcc16..0879ac16 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,19 @@ { "name": "WATcher", - "version": "0.0.2", + "version": "1.0.0", "main": "main.js", "scripts": { - "postinstall": "npm run postinstall:electron && electron-builder install-app-deps", - "postinstall:electron": "node postinstall", - "postinstall:web": "node postinstall-web", "ng": "ng", - "start": "npm run postinstall:electron && npm-run-all -p codegen:gql-watch ng:serve electron:serve", - "build": "npm run postinstall:electron && npm run codegen:gql && npm run electron:serve-tsc && ng build", - "build:prod": "npm run build -- -c production --base-href=./", "build:staging": "npm run codegen:gql && ng build --c staging --base-href=https://catcher-org.github.io/WATcher-staging/", "build:prod:web": "npm run codegen:gql && ng build --prod --base-href=https://CATcher-org.github.io/WATcher/", "ng:serve": "npm run codegen:gql && ng serve", - "ng:serve:web": "npm run postinstall:web && npm run ng:serve -- -o", + "ng:serve:web": "npm run ng:serve -- -o", "deploy:staging": "npm run build:staging && ngh --dir=dist --name=WATcher --email=se-edu@comp.nus.edu.sg --no-silent", - "ng:serve:test": "npm run postinstall:web && npm run ng:serve -- --configuration=test -o", + "ng:serve:test": "npm run ng:serve -- --configuration=test -o", "deploy:web": "npm run build:prod:web && ngh --dir=dist --name=WATcher --email=se-edu@comp.nus.edu.sg --no-silent", - "electron:serve-tsc": "tsc -p tsconfig-serve.json", - "electron:serve": "wait-on http-get://localhost:4200/ && npm run electron:serve-tsc && electron . --serve", - "electron:local": "npm run build:prod && electron .", - "electron:linux": "npm run build:prod && electron-builder build --linux --publish never", - "electron:windows": "npm run build:prod && electron-builder build --windows --publish never", - "electron:mac": "npm run build:prod && electron-builder build --mac --publish never", "test": "npm run codegen:gql && ng test", - "e2e": "npm run codegen:gql && npm run postinstall:web && ng e2e", - "actions:e2e": "npm run codegen:gql && npm run postinstall:web && ng e2e --protractor-config=e2e/protractor.gh-actions.conf --webdriver-update=false", + "e2e": "npm run codegen:gql && ng e2e", + "actions:e2e": "npm run codegen:gql && ng e2e --protractor-config=e2e/protractor.gh-actions.conf --webdriver-update=false", "webdriver-manager": "webdriver-manager", "lint": "ng lint", "lint:fix": "ng lint --fix", @@ -40,89 +28,75 @@ } }, "dependencies": { - "@angular/animations": "^8.2.14", - "@angular/cdk": "^7.3.7", - "@angular/common": "^8.2.14", - "@angular/compiler": "^8.2.14", - "@angular/core": "^8.2.14", - "@angular/forms": "^8.2.14", - "@angular/material": "^7.3.7", - "@angular/platform-browser": "^8.2.14", - "@angular/platform-browser-dynamic": "^8.2.14", - "@angular/router": "^8.2.14", + "@angular/animations": "^10.2.5", + "@angular/cdk": "^10.2.7", + "@angular/common": "^10.2.5", + "@angular/compiler": "^10.2.5", + "@angular/core": "^10.2.5", + "@angular/forms": "^10.2.5", + "@angular/material": "^10.2.7", + "@angular/platform-browser": "^10.2.5", + "@angular/platform-browser-dynamic": "^10.2.5", + "@angular/router": "^10.2.5", "@octokit/rest": "^16.37.0", - "@primer/octicons": "^17.3.0", + "@primer/octicons": "^17.12.0", "@types/geojson": "7946.0.8", - "ajv": "^6.11.0", "apollo-angular": "^1.9.1", "apollo-angular-link-http": "^1.10.0", "apollo-cache-inmemory": "^1.6.0", "apollo-client": "^2.6.0", "apollo-link": "^1.2.14", "apollo-link-context": "^1.0.20", - "arcsecond": "^4.1.0", - "core-js": "^3.16.4", - "d3": "^7.4.4", - "d3-time": "^3.0.0", - "d3-time-format": "^4.1.0", - "d3-timelines": "^1.3.1", + "core-js": "^3.28.0", "diff-match-patch": "^1.0.4", - "dompurify": "^2.3.1", - "electron-log": "^4.4.1", "graphql": "^14.6.0", "graphql-tag": "2.11.0", "karma-spec-reporter": "0.0.32", "moment": "^2.24.0", - "ngx-markdown": "^8.2.1", - "ngx-mat-select-search": "^1.8.0", - "node-fetch": "^2.6.0", - "rxjs": "6.5.3", - "tslib": "^1.9.0", + "ngx-mat-select-search": "^3.3.3", + "node-fetch": "^2.6.9", + "rxjs": "6.5.5", + "tslib": "^2.0.0", "uuid": "7.0.3", - "zone.js": "~0.9.1" + "zone.js": "~0.10.2" }, "devDependencies": { - "@angular-devkit/build-angular": "~0.803.29", - "@angular/cli": "^8.3.29", - "@angular/compiler-cli": "^8.2.14", - "@angular/language-service": "^8.2.14", + "@angular-devkit/build-angular": "~0.1002.4", + "@angular/cli": "^10.2.4", + "@angular/compiler-cli": "^10.2.5", + "@angular/language-service": "^10.2.5", "@graphql-codegen/cli": "1.17.7", "@graphql-codegen/typescript": "1.17.7", "@graphql-codegen/typescript-document-nodes": "1.17.7", "@graphql-codegen/typescript-operations": "^1.18.4", "@graphql-codegen/typescript-resolvers": "^1.20.0", "@octokit/graphql-schema": "^8.24.0", - "@types/d3": "^7.4.0", - "@types/dompurify": "^2.3.1", "@types/jasmine": "^3.8.2", "@types/jasminewd2": "2.0.8", - "@types/node": "~12.12.6", - "@types/primer__octicons": "^17.0.0", + "@types/node": "^14.17.6", + "@types/primer__octicons": "^17.11.0", "angular-cli-ghpages": "^1.0.0-rc.2", - "codelyzer": "^5.0.1", - "electron": "11.4.8", - "electron-builder": "22.2.0", - "electron-reload": "1.5.0", + "codelyzer": "^6.0.2", "graphql-codegen-fragment-matcher": "^0.18.2", "husky": "^4.2.5", "jasmine": "^3.9.0", - "jasmine-core": "3.5.0", - "jasmine-spec-reporter": "4.2.1", - "karma": "^4.4.1", - "karma-chrome-launcher": "3.1.0", - "karma-coverage-istanbul-reporter": "2.1.1", + "jasmine-core": "~3.8.0", + "jasmine-spec-reporter": "~5.0.0", + "karma": "~5.0.0", + "karma-chrome-launcher": "~3.1.0", + "karma-coverage-istanbul-reporter": "~3.0.2", "karma-firefox-launcher": "^2.1.1", - "karma-jasmine": "3.1.0", - "karma-jasmine-html-reporter": "1.5.1", + "karma-jasmine": "~4.0.0", + "karma-jasmine-html-reporter": "^1.5.0", "npm-run-all": "4.1.5", "prettier": "2.2.1", "pretty-quick": "^3.1.1", - "protractor": "5.4.2", + "protractor": "~7.0.0", "scuri": "^0.9.4", "ts-node": "^7.0.1", - "tslint": "5.20.1", + "tslint": "~6.1.0", "tslint-config-prettier": "^1.18.0", - "typescript": "3.5.3", + "typescript": "4.0.8", "wait-on": "3.3.0", "webdriver-manager": "12.1.7" } diff --git a/postinstall-web.js b/postinstall-web.js deleted file mode 100644 index 7dce7645..00000000 --- a/postinstall-web.js +++ /dev/null @@ -1,16 +0,0 @@ -// Allow angular using electron module (native node modules) -const fs = require('fs'); -const f_angular = 'node_modules/@angular-devkit/build-angular/src/angular-cli-files/models/webpack-configs/browser.js'; - -fs.readFile(f_angular, 'utf8', function (err, data) { - if (err) { - return console.log(err); - } - var result = data.replace(/target: "electron-renderer",/g, ''); - var result = result.replace(/target: "web",/g, ''); - var result = result.replace(/return \{/g, 'return {target: "web",'); - - fs.writeFile(f_angular, result, 'utf8', function (err) { - if (err) return console.log(err); - }); -}); \ No newline at end of file diff --git a/postinstall.js b/postinstall.js deleted file mode 100644 index 1fb12759..00000000 --- a/postinstall.js +++ /dev/null @@ -1,16 +0,0 @@ -// Allow angular using electron module (native node modules) -const fs = require('fs'); -const f_angular = 'node_modules/@angular-devkit/build-angular/src/angular-cli-files/models/webpack-configs/browser.js'; - -fs.readFile(f_angular, 'utf8', function (err, data) { - if (err) { - return console.log(err); - } - var result = data.replace(/target: "electron-renderer",/g, ''); - var result = result.replace(/target: "web",/g, ''); - var result = result.replace(/return \{/g, 'return {target: "electron-renderer",'); - - fs.writeFile(f_angular, result, 'utf8', function (err) { - if (err) return console.log(err); - }); -}); \ No newline at end of file diff --git a/src/app/activity-dashboard/event-tables/GithubEventDataTable.ts b/src/app/activity-dashboard/event-tables/GithubEventDataTable.ts index fe36e0af..b7f1a7aa 100644 --- a/src/app/activity-dashboard/event-tables/GithubEventDataTable.ts +++ b/src/app/activity-dashboard/event-tables/GithubEventDataTable.ts @@ -1,5 +1,6 @@ import { DataSource } from '@angular/cdk/table'; -import { MatPaginator, MatSort } from '@angular/material'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; import * as moment from 'moment'; import { BehaviorSubject, merge, Observable, Subscription } from 'rxjs'; import { flatMap, map } from 'rxjs/operators'; diff --git a/src/app/activity-dashboard/event-tables/event-paginator.ts b/src/app/activity-dashboard/event-tables/event-paginator.ts index ededda69..65791508 100644 --- a/src/app/activity-dashboard/event-tables/event-paginator.ts +++ b/src/app/activity-dashboard/event-tables/event-paginator.ts @@ -1,4 +1,4 @@ -import { MatPaginator } from '@angular/material'; +import { MatPaginator } from '@angular/material/paginator'; import { EventWeek } from '../event-week.model'; export function paginateData(paginator: MatPaginator, data: EventWeek[]): EventWeek[] { diff --git a/src/app/activity-dashboard/event-tables/event-tables.component.ts b/src/app/activity-dashboard/event-tables/event-tables.component.ts index 93bd52cd..b863792b 100644 --- a/src/app/activity-dashboard/event-tables/event-tables.component.ts +++ b/src/app/activity-dashboard/event-tables/event-tables.component.ts @@ -1,6 +1,7 @@ import { animate, state, style, transition, trigger } from '@angular/animations'; import { AfterViewInit, Component, Input, OnInit, ViewChild } from '@angular/core'; -import { MatPaginator, MatSort } from '@angular/material'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; import { GithubUser } from '../../core/models/github-user.model'; import { GithubEventService } from '../../core/services/githubevent.service'; import { LoggingService } from '../../core/services/logging.service'; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ccc2a91c..c1a797f7 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,6 +1,5 @@ import { AfterViewInit, Component } from '@angular/core'; import { AppConfig } from '../environments/environment'; -import { ElectronService } from './core/services/electron.service'; import { ErrorHandlingService } from './core/services/error-handling.service'; import { LoggingService } from './core/services/logging.service'; @@ -12,40 +11,16 @@ import { LoggingService } from './core/services/logging.service'; export class AppComponent implements AfterViewInit { NOT_CONNECTED_ERROR: Error = new Error('You are not connected to the internet.'); - constructor(public electronService: ElectronService, logger: LoggingService, public errorHandlingService: ErrorHandlingService) { + constructor(logger: LoggingService, public errorHandlingService: ErrorHandlingService) { logger.info('AppComponent: AppConfig', AppConfig); - if (electronService.isElectron()) { - logger.info('AppComponent: Mode electron'); - } else { - logger.info('AppComponent: Mode web'); - } + logger.info('AppComponent: Mode web'); } ngAfterViewInit() { - this.addListenerForHttpLinks(); this.addListenerForNetworkOffline(); } - /** - * This listener will prevent the default behaviour of electron to open http links on electron browser itself. - * Will use the client's default OS browser to open the link. - */ - addListenerForHttpLinks() { - document.addEventListener( - 'click', - (event) => { - const elem = (event.target).closest('a[href^="http"]'); - if (elem) { - event.preventDefault(); - event.stopPropagation(); - this.electronService.openLink(elem.href); - } - }, - false - ); - } - /** * This listener checks if WATcher has a connection to a network, and will show an error snackbar if it does not. */ diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9d59b345..7dd8801e 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -8,7 +8,6 @@ import { HttpLink, HttpLinkModule } from 'apollo-angular-link-http'; import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import { ApolloLink } from 'apollo-link'; import { setContext } from 'apollo-link-context'; -import { MarkdownModule, MarkedOptions } from 'ngx-markdown'; import 'reflect-metadata'; import graphqlTypes from '../../graphql/graphql-types'; import '../polyfills'; @@ -18,7 +17,6 @@ import { AppComponent } from './app.component'; import { AuthModule } from './auth/auth.module'; import { UserConfirmationComponent } from './core/guards/user-confirmation/user-confirmation.component'; import { AuthService } from './core/services/auth.service'; -import { ElectronService } from './core/services/electron.service'; import { ErrorHandlingService } from './core/services/error-handling.service'; import { AuthServiceFactory } from './core/services/factories/factory.auth.service'; import { GithubServiceFactory } from './core/services/factories/factory.github.service'; @@ -26,6 +24,7 @@ import { IssueServiceFactory } from './core/services/factories/factory.issue.ser import { GithubService } from './core/services/github.service'; import { GithubEventService } from './core/services/githubevent.service'; import { IssueService } from './core/services/issue.service'; +import { LabelService } from './core/services/label.service'; import { LoggingService } from './core/services/logging.service'; import { PhaseService } from './core/services/phase.service'; import { SessionFixConfirmationComponent } from './core/services/session-fix-confirmation/session-fix-confirmation.component'; @@ -33,7 +32,6 @@ import { UserService } from './core/services/user.service'; import { IssuesViewerModule } from './issues-viewer/issues-viewer.module'; import { LabelDefinitionPopupComponent } from './shared/label-definition-popup/label-definition-popup.component'; import { HeaderComponent } from './shared/layout'; -import { markedOptionsFactory } from './shared/lib/marked'; import { RepoChangeFormComponent } from './shared/repo-change-form/repo-change-form.component'; import { SharedModule } from './shared/shared.module'; @@ -54,12 +52,6 @@ import { SharedModule } from './shared/shared.module'; ActivityDashboardModule, SharedModule, HttpClientModule, - MarkdownModule.forRoot({ - markedOptions: { - provide: MarkedOptions, - useFactory: markedOptionsFactory - } - }), AppRoutingModule, ApolloModule, HttpLinkModule @@ -68,36 +60,36 @@ import { SharedModule } from './shared/shared.module'; { provide: GithubService, useFactory: GithubServiceFactory, - deps: [ErrorHandlingService, Apollo, ElectronService, LoggingService] + deps: [ErrorHandlingService, Apollo, LoggingService] }, { provide: AuthService, useFactory: AuthServiceFactory, deps: [ - ElectronService, Router, NgZone, GithubService, UserService, IssueService, + LabelService, PhaseService, GithubEventService, Title, + ErrorHandlingService, LoggingService ] }, { provide: IssueService, useFactory: IssueServiceFactory, - deps: [GithubService, UserService, PhaseService, ElectronService] + deps: [GithubService, UserService, PhaseService] }, { provide: ErrorHandler, useClass: ErrorHandlingService } ], - bootstrap: [AppComponent], - entryComponents: [UserConfirmationComponent, SessionFixConfirmationComponent, LabelDefinitionPopupComponent, RepoChangeFormComponent] + bootstrap: [AppComponent] }) export class AppModule { constructor(private apollo: Apollo, private httpLink: HttpLink, private authService: AuthService, private logger: LoggingService) { diff --git a/src/app/auth/auth.component.html b/src/app/auth/auth.component.html index ee113cc7..605b674f 100644 --- a/src/app/auth/auth.component.html +++ b/src/app/auth/auth.component.html @@ -1,13 +1,16 @@ + + diff --git a/src/app/auth/auth.component.ts b/src/app/auth/auth.component.ts index 47cec5d9..9bb41d18 100644 --- a/src/app/auth/auth.component.ts +++ b/src/app/auth/auth.component.ts @@ -6,7 +6,6 @@ import { AppConfig } from '../../environments/environment'; import { GithubUser } from '../core/models/github-user.model'; import { ApplicationService } from '../core/services/application.service'; import { AuthService, AuthState } from '../core/services/auth.service'; -import { ElectronService } from '../core/services/electron.service'; import { ErrorHandlingService } from '../core/services/error-handling.service'; import { LoggingService } from '../core/services/logging.service'; import { PhaseService } from '../core/services/phase.service'; @@ -30,7 +29,6 @@ export class AuthComponent implements OnInit, OnDestroy { constructor( public appService: ApplicationService, - public electronService: ElectronService, private authService: AuthService, private userService: UserService, private errorHandlingService: ErrorHandlingService, @@ -39,20 +37,7 @@ export class AuthComponent implements OnInit, OnDestroy { private ngZone: NgZone, private activatedRoute: ActivatedRoute, private logger: LoggingService - ) { - this.electronService.registerIpcListener('github-oauth-reply', (event, { token, error, isWindowClosed }) => { - this.ngZone.run(() => { - if (error) { - if (!isWindowClosed) { - this.errorHandlingService.handleError(error); - } - this.goToSessionSelect(); - return; - } - this.authService.storeOAuthAccessToken(token); - }); - }); - } + ) {} ngOnInit() { this.logger.startSession(); @@ -108,7 +93,6 @@ export class AuthComponent implements OnInit, OnDestroy { } ngOnDestroy() { - this.electronService.removeIpcListeners('github-oauth-reply'); if (this.authStateSubscription) { this.authStateSubscription.unsubscribe(); } @@ -150,6 +134,14 @@ export class AuthComponent implements OnInit, OnDestroy { return this.authState === AuthState.ConfirmOAuthUser; } + isUserAuthenticated(): boolean { + return this.authState === AuthState.Authenticated; + } + + isRepoSet(): boolean { + return this.phaseService.isRepoSet(); + } + get currentSessionOrg(): string { if (!this.sessionInformation) { // Retrieve org details of session information from local storage diff --git a/src/app/auth/auth.module.ts b/src/app/auth/auth.module.ts index 1b7cdb09..46d81bbf 100644 --- a/src/app/auth/auth.module.ts +++ b/src/app/auth/auth.module.ts @@ -4,13 +4,20 @@ import { SharedModule } from '../shared/shared.module'; import { AuthRoutingModule } from './auth-routing.module'; import { AuthComponent } from './auth.component'; import { ConfirmLoginComponent } from './confirm-login/confirm-login.component'; +import { LoginComponent } from './login/login.component'; import { JsonParseErrorDialogComponent } from './profiles/json-parse-error-dialog/json-parse-error-dialog.component'; import { ProfilesComponent } from './profiles/profiles.component'; import { SessionSelectionComponent } from './session-selection/session-selection.component'; @NgModule({ imports: [AuthRoutingModule, SharedModule, CommonModule], - declarations: [AuthComponent, ProfilesComponent, JsonParseErrorDialogComponent, ConfirmLoginComponent, SessionSelectionComponent], - entryComponents: [JsonParseErrorDialogComponent] + declarations: [ + AuthComponent, + ProfilesComponent, + JsonParseErrorDialogComponent, + LoginComponent, + ConfirmLoginComponent, + SessionSelectionComponent + ] }) export class AuthModule {} diff --git a/src/app/auth/confirm-login/confirm-login.component.html b/src/app/auth/confirm-login/confirm-login.component.html index de429fef..38d2a365 100644 --- a/src/app/auth/confirm-login/confirm-login.component.html +++ b/src/app/auth/confirm-login/confirm-login.component.html @@ -3,10 +3,7 @@ Continue as {{ this.username }} - -
+
To change account, please sign into the desired account from the official Github website.
diff --git a/src/app/auth/confirm-login/confirm-login.component.ts b/src/app/auth/confirm-login/confirm-login.component.ts index 754061af..94c414d3 100644 --- a/src/app/auth/confirm-login/confirm-login.component.ts +++ b/src/app/auth/confirm-login/confirm-login.component.ts @@ -1,13 +1,7 @@ import { Component, Input, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; -import { flatMap } from 'rxjs/operators'; -import { Phase } from '../../core/models/phase.model'; import { AuthService, AuthState } from '../../core/services/auth.service'; -import { ElectronService } from '../../core/services/electron.service'; import { ErrorHandlingService } from '../../core/services/error-handling.service'; -import { GithubEventService } from '../../core/services/githubevent.service'; import { LoggingService } from '../../core/services/logging.service'; -import { PhaseService } from '../../core/services/phase.service'; import { UserService } from '../../core/services/user.service'; @Component({ @@ -20,14 +14,10 @@ export class ConfirmLoginComponent implements OnInit { @Input() currentSessionOrg: string; constructor( - public electronService: ElectronService, private authService: AuthService, - private phaseService: PhaseService, private userService: UserService, private errorHandlingService: ErrorHandlingService, - private githubEventService: GithubEventService, private logger: LoggingService, - private router: Router ) {} ngOnInit() {} @@ -39,31 +29,20 @@ export class ConfirmLoginComponent implements OnInit { logIntoAnotherAccount() { this.logger.info('ConfirmLoginComponent: Logging into another account'); - this.electronService.clearCookies(); this.authService.startOAuthProcess(); } - /** - * Handles the clean up required after authentication and setting up of user data is completed. - */ - handleAuthSuccess() { - this.authService.setTitleWithPhaseDetail(); - this.router.navigateByUrl(Phase.issuesViewer); - this.authService.changeAuthState(AuthState.Authenticated); - } - /** * Will complete the process of logging in the given user. */ completeLoginProcess(): void { this.authService.changeAuthState(AuthState.AwaitingAuthentication); - this.phaseService.initializeCurrentRepository(); + this.logger.info(`ConfirmLoginComponent: Completing login process`); this.userService .createUserModel(this.username) - .pipe(flatMap(() => this.githubEventService.setLatestChangeEvent())) .subscribe( () => { - this.handleAuthSuccess(); + this.authService.changeAuthState(AuthState.Authenticated); }, (error) => { this.authService.changeAuthState(AuthState.NotAuthenticated); @@ -71,6 +50,5 @@ export class ConfirmLoginComponent implements OnInit { this.logger.info(`ConfirmLoginComponent: Completion of login process failed with an error: ${error}`); } ); - this.handleAuthSuccess(); } } diff --git a/src/app/auth/login/login.component.css b/src/app/auth/login/login.component.css new file mode 100644 index 00000000..dec75881 --- /dev/null +++ b/src/app/auth/login/login.component.css @@ -0,0 +1,18 @@ +.login-button { + background: #f7fcfe; + line-height: 45px; + border: 1px solid currentColor; + width: 100%; +} + +.logo { + align-items: center; + display: inline-flex; + margin: 0 3px 3px 3px; +} + +.github-logo { + font-size: 20px; + width: 20px; + height: 20px; +} diff --git a/src/app/auth/login/login.component.html b/src/app/auth/login/login.component.html new file mode 100644 index 00000000..afcde449 --- /dev/null +++ b/src/app/auth/login/login.component.html @@ -0,0 +1,4 @@ + diff --git a/src/app/auth/login/login.component.ts b/src/app/auth/login/login.component.ts new file mode 100644 index 00000000..b08195d5 --- /dev/null +++ b/src/app/auth/login/login.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { AuthService, AuthState } from '../../core/services/auth.service'; +import { ErrorHandlingService } from '../../core/services/error-handling.service'; +import { LoggingService } from '../../core/services/logging.service'; + +@Component({ + selector: 'app-auth-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.css'] +}) + +export class LoginComponent { + constructor( + private authService: AuthService, + private errorHandlingService: ErrorHandlingService, + private logger: LoggingService + ) {} + + startLoginProcess() { + this.logger.info('LoginComponent: Beginning login process'); + try { + this.authService.startOAuthProcess(); + } catch (error) { + this.authService.changeAuthState(AuthState.NotAuthenticated); + this.errorHandlingService.handleError(error); + this.logger.info(`LoginComponent: Login process failed with an error: ${error}`); + } + } +} diff --git a/src/app/auth/profiles/json-parse-error-dialog/json-parse-error-dialog.component.ts b/src/app/auth/profiles/json-parse-error-dialog/json-parse-error-dialog.component.ts index b297194f..923d3cb0 100644 --- a/src/app/auth/profiles/json-parse-error-dialog/json-parse-error-dialog.component.ts +++ b/src/app/auth/profiles/json-parse-error-dialog/json-parse-error-dialog.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { MatDialogRef } from '@angular/material'; +import { MatDialogRef } from '@angular/material/dialog'; import { ProfilesComponent } from '../profiles.component'; /** diff --git a/src/app/auth/profiles/profiles.component.ts b/src/app/auth/profiles/profiles.component.ts index f10dec9a..1abfe13a 100644 --- a/src/app/auth/profiles/profiles.component.ts +++ b/src/app/auth/profiles/profiles.component.ts @@ -1,6 +1,6 @@ import { animate, state, style, transition, trigger } from '@angular/animations'; import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { MatDialog } from '@angular/material'; +import { MatDialog } from '@angular/material/dialog'; import { isValidProfile, Profile } from '../../core/models/profile.model'; import { ErrorHandlingService } from '../../core/services/error-handling.service'; import { MALFORMED_PROFILES_ERROR, ProfileService } from '../../core/services/profile.service'; diff --git a/src/app/auth/session-selection/session-selection.component.html b/src/app/auth/session-selection/session-selection.component.html index c875f004..e16fb48a 100644 --- a/src/app/auth/session-selection/session-selection.component.html +++ b/src/app/auth/session-selection/session-selection.component.html @@ -6,7 +6,12 @@
diff --git a/src/app/auth/session-selection/session-selection.component.ts b/src/app/auth/session-selection/session-selection.component.ts index 684c2d6a..8ba5421a 100644 --- a/src/app/auth/session-selection/session-selection.component.ts +++ b/src/app/auth/session-selection/session-selection.component.ts @@ -1,9 +1,11 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Observable } from 'rxjs'; import { Profile } from '../../core/models/profile.model'; -import { AuthService, AuthState } from '../../core/services/auth.service'; +import { AuthService } from '../../core/services/auth.service'; import { ErrorHandlingService } from '../../core/services/error-handling.service'; import { LoggingService } from '../../core/services/logging.service'; +import { RepoUrlCacheService } from '../../core/services/repo-url-cache.service'; @Component({ selector: 'app-session-selection', @@ -15,6 +17,7 @@ export class SessionSelectionComponent implements OnInit { isSettingUpSession: boolean; profileForm: FormGroup; repoForm: FormGroup; + filteredSuggestions: Observable; @Input() urlEncodedSessionName: string; @Input() urlEncodedRepo: string; @@ -25,6 +28,7 @@ export class SessionSelectionComponent implements OnInit { private formBuilder: FormBuilder, private logger: LoggingService, private authService: AuthService, + private repoUrlCacheService: RepoUrlCacheService, private errorHandlingService: ErrorHandlingService ) {} @@ -56,19 +60,30 @@ export class SessionSelectionComponent implements OnInit { /** * Persist repo information in local browser storage * To retrieve after authentication redirects back to WATcher + * + * Since localStorage::setItem with an undefined value can result in + * the subsequent value being stored as a string being 'undefined', check + * if undefined before storing it. Let's reset the items before setting them. */ - window.localStorage.setItem('org', repoOrg); - window.localStorage.setItem('dataRepo', repoName); - this.logger.info(`SessionSelectionComponent: Selected Repository: ${repoInformation}`); + window.localStorage.removeItem('org'); + window.localStorage.removeItem('dataRepo'); + + if (repoOrg && repoName) { + window.localStorage.setItem('org', repoOrg); + window.localStorage.setItem('dataRepo', repoName); - try { - this.authService.startOAuthProcess(); - } catch (error) { - this.errorHandlingService.handleError(error); - this.authService.changeAuthState(AuthState.NotAuthenticated); - this.isSettingUpSession = false; + this.repoUrlCacheService.cache(repoInformation); } + + this.logger.info(`SessionSelectionComponent: Selected Repository: ${repoInformation}`); + + this.authService.setRepo() + .subscribe( + (res) => { + this.isSettingUpSession = false; + } + ); } /** @@ -97,6 +112,8 @@ export class SessionSelectionComponent implements OnInit { this.repoForm = this.formBuilder.group({ repo: ['', Validators.required] }); + + this.filteredSuggestions = this.repoUrlCacheService.getFilteredSuggestions(this.repoForm.get('repo')); } private autofillRepo() { diff --git a/src/app/core/guards/user-confirmation/user-confirmation.component.ts b/src/app/core/guards/user-confirmation/user-confirmation.component.ts index 6f3ae9dd..4070b5bd 100644 --- a/src/app/core/guards/user-confirmation/user-confirmation.component.ts +++ b/src/app/core/guards/user-confirmation/user-confirmation.component.ts @@ -1,5 +1,5 @@ import { Component, Inject, OnInit } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { CanDeactivateIssueGuard } from '../can-deactivate-issue-guard.service'; /** diff --git a/src/app/core/models/assignee.model.ts b/src/app/core/models/assignee.model.ts deleted file mode 100644 index 21def1e1..00000000 --- a/src/app/core/models/assignee.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface UserData { - id: number; - login: string; - url: string; -} - -export default class Assignee implements UserData { - id: number; - login: string; - url: string; - - constructor(data: UserData) { - Object.assign(this, data); - this.login = data.login.toLowerCase(); - } -} diff --git a/src/app/core/models/checkbox.model.ts b/src/app/core/models/checkbox.model.ts deleted file mode 100644 index 17a7d6de..00000000 --- a/src/app/core/models/checkbox.model.ts +++ /dev/null @@ -1,17 +0,0 @@ -export class Checkbox { - description: string; // in the format of - [ ] or - [x] - isChecked: boolean; - - constructor(description: string, isChecked: boolean) { - this.description = description; - this.isChecked = isChecked; - } - - setChecked(isChecked: boolean) { - this.isChecked = isChecked; - } - - toString(): string { - return `- ${this.isChecked ? '[x]' : '[ ]'} ${this.description}`; - } -} diff --git a/src/app/core/models/conflict/addition.model.ts b/src/app/core/models/conflict/addition.model.ts deleted file mode 100644 index c569f303..00000000 --- a/src/app/core/models/conflict/addition.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Changes } from './changes.model'; - -export class Addition extends Changes { - readonly TYPE = 'ADDITION'; - readonly TAG = 'ins'; - readonly STYLES = ['background: #d4fcbc', 'text-decoration: none']; - readonly content: string; - - constructor(content: string) { - super(); - this.content = content; - } -} diff --git a/src/app/core/models/conflict/changes.model.ts b/src/app/core/models/conflict/changes.model.ts deleted file mode 100644 index 0bdc74a2..00000000 --- a/src/app/core/models/conflict/changes.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { escapeHTML } from '../../../shared/lib/html'; - -export abstract class Changes { - abstract readonly TYPE: string; - abstract readonly TAG: string; - abstract readonly STYLES: string[]; - abstract readonly content: string; - - getHtmlString(): string { - return `<${this.TAG} style="${this.STYLES.join(';')}">${escapeHTML(this.content)}`; - } -} diff --git a/src/app/core/models/conflict/conflict.model.ts b/src/app/core/models/conflict/conflict.model.ts deleted file mode 100644 index 29d96cd6..00000000 --- a/src/app/core/models/conflict/conflict.model.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { diff_match_patch } from 'diff-match-patch'; -import { escapeHTML, replaceNewlinesWithBreakLines } from '../../../shared/lib/html'; -import { Addition } from './addition.model'; -import { Changes } from './changes.model'; -import { NoChange } from './no-change.model'; -import { Removal } from './removal.model'; - -/** - * A model to represent the difference/conflict between two text. - */ -export class Conflict { - outdatedContent: string; - updatedContent: string; - changes: Changes[] = []; - - constructor(outdatedContent: string, updatedContent: string) { - this.outdatedContent = outdatedContent; - this.updatedContent = updatedContent; - - const matcher = new diff_match_patch(); - const diffs = matcher.diff_main(outdatedContent, updatedContent); - matcher.diff_cleanupSemantic(diffs); - for (const diff of diffs) { - if (diff[0] === -1) { - this.changes.push(new Removal(diff[1])); - } else if (diff[0] === 1) { - this.changes.push(new Addition(diff[1])); - } else { - this.changes.push(new NoChange(diff[1])); - } - } - } - - getHtmlDiffString(): string { - let result = ''; - for (const change of this.changes) { - result += change.getHtmlString(); - } - return replaceNewlinesWithBreakLines(result); - } - - getHtmlUpdatedString(): string { - return replaceNewlinesWithBreakLines(escapeHTML(this.updatedContent)); - } -} diff --git a/src/app/core/models/conflict/no-change.model.ts b/src/app/core/models/conflict/no-change.model.ts deleted file mode 100644 index ffb31578..00000000 --- a/src/app/core/models/conflict/no-change.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Changes } from './changes.model'; - -export class NoChange extends Changes { - readonly TYPE = 'NO_CHANGE'; - readonly TAG = 'span'; - readonly STYLES = []; - readonly content: string; - - constructor(content: string) { - super(); - this.content = content; - } -} diff --git a/src/app/core/models/conflict/removal.model.ts b/src/app/core/models/conflict/removal.model.ts deleted file mode 100644 index 30682967..00000000 --- a/src/app/core/models/conflict/removal.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Changes } from './changes.model'; - -export class Removal extends Changes { - readonly TYPE = 'REMOVAL'; - readonly TAG = 'del'; - readonly STYLES = ['background: #fbb']; - readonly content: string; - - constructor(content: string) { - super(); - this.content = content; - } -} diff --git a/src/app/core/models/generators/github-issue.generator.ts b/src/app/core/models/generators/github-issue.generator.ts deleted file mode 100644 index 0dc7240b..00000000 --- a/src/app/core/models/generators/github-issue.generator.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { GithubIssue } from '../github/github-issue.model'; - -export default function generateGithubIssuesArray(numberOfElements: number = 1): Array { - const created_and_updated_date: string = getRandomDate().toISOString(); - return new Array(10).map((value: GithubIssue, index: number, array: GithubIssue[]) => { - return new GithubIssue({ - id: index, - number: Math.random(), - assignees: undefined, - body: `Automatically Generated Issue No id: ${index}.`, - created_at: created_and_updated_date, - labels: undefined, - title: `Autogen Issue ${index}`, - updated_at: created_and_updated_date, - url: '', - user: undefined, - comments: undefined - }); - }); -} - -/** - * Returns a random Date between the start and end dates. - * @param start - Date representing the start of the date range. Default: 1/1/2018 - * @param end - Date representing the end of the date range. Default: Current Date - */ -function getRandomDate(start: Date = new Date(2018, 1, 1), end: Date = new Date()): Date { - return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); -} diff --git a/src/app/core/models/github/github-graphql.issue-or-pr.ts b/src/app/core/models/github/github-graphql.issue-or-pr.ts index 50bb0782..3010808f 100644 --- a/src/app/core/models/github/github-graphql.issue-or-pr.ts +++ b/src/app/core/models/github/github-graphql.issue-or-pr.ts @@ -19,7 +19,8 @@ export class GithubGraphqlIssueOrPr extends GithubIssue { }, assignees: flattenEdges(issue.assignees.edges), labels: flattenEdges(issue.labels.edges), - milestone: issue.milestone ? issue.milestone : null + milestone: issue.milestone ? issue.milestone : null, + isDraft: issue.isDraft }); } } diff --git a/src/app/core/models/github/github-issue.model.ts b/src/app/core/models/github/github-issue.model.ts index 6000f149..3cb48a33 100644 --- a/src/app/core/models/github/github-issue.model.ts +++ b/src/app/core/models/github/github-issue.model.ts @@ -27,6 +27,7 @@ export class GithubIssue { }; comments?: Array; issueOrPr?: string; + isDraft: boolean; constructor(githubIssue: {}) { Object.assign(this, githubIssue); @@ -68,8 +69,4 @@ export class GithubIssue { } } } - - findTeamId(): string { - return `${this.findLabel('team')}.${this.findLabel('tutorial')}`; - } } diff --git a/src/app/core/models/issue-dispute.model.ts b/src/app/core/models/issue-dispute.model.ts deleted file mode 100644 index c68bd0e2..00000000 --- a/src/app/core/models/issue-dispute.model.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Checkbox } from './checkbox.model'; -export class IssueDispute { - readonly TODO_DESCRIPTION = 'Done'; - readonly INITIAL_RESPONSE = '[replace this with your explanation]'; - readonly TITLE_PREFIX = '## :question: '; - readonly LINE_BREAK = '-------------------\n'; - title: string; // e.g Issue severity - description: string; // e.g Team says: xxx\n Tester says: xxx. - tutorResponse: string; // e.g Not justified. I've changed it back. - todo: Checkbox; // e.g - [x] Done - - constructor(title: string, description: string) { - this.title = title; - this.description = description; - this.tutorResponse = this.INITIAL_RESPONSE; - this.todo = new Checkbox(this.TODO_DESCRIPTION, false); - } - - isDone(): boolean { - return this.todo.isChecked; - } - - /* - This method is used to format the tutor's response so that the app can upload it on Github. - Refer to format in https://github.com/CATcher-org/templates#app-collect-tutor-response - */ - toTutorResponseString(): string { - let toString = ''; - toString += this.TITLE_PREFIX + this.title + '\n\n'; - toString += this.todo.toString() + '\n\n'; - toString += this.tutorResponse + '\n\n'; - toString += this.LINE_BREAK; - return toString; - } - - compareTo(anotherResponse: IssueDispute): number { - if (this.isDone() === anotherResponse.isDone()) { - return this.tutorResponse.localeCompare(anotherResponse.tutorResponse); - } - return this.isDone() ? 1 : -1; - } - - toString(): string { - let toString = ''; - toString += this.TITLE_PREFIX + this.title + '\n\n'; - toString += this.description + '\n\n'; - toString += this.LINE_BREAK; - return toString; - } - - setTutorResponse(response: string) { - this.tutorResponse = response; - } - - setIsDone(isDone: boolean) { - this.todo.setChecked(isDone); - } -} diff --git a/src/app/core/models/issue.model.ts b/src/app/core/models/issue.model.ts index 6663ce8d..034ab0cf 100644 --- a/src/app/core/models/issue.model.ts +++ b/src/app/core/models/issue.model.ts @@ -1,19 +1,9 @@ import * as moment from 'moment'; -import { Phase } from '../models/phase.model'; -import { IssueComment } from './comment.model'; import { GithubComment } from './github/github-comment.model'; import { GithubIssue } from './github/github-issue.model'; import { GithubLabel } from './github/github-label.model'; import { HiddenData } from './hidden-data.model'; -import { IssueDispute } from './issue-dispute.model'; import { Milestone } from './milestone.model'; -import { Team } from './team.model'; -import { TeamAcceptedTemplate } from './templates/team-accepted-template.model'; -import { TeamResponseTemplate } from './templates/team-response-template.model'; -import { TesterResponseTemplate } from './templates/tester-response-template.model'; -import { TutorModerationIssueTemplate } from './templates/tutor-moderation-issue-template.model'; -import { TutorModerationTodoTemplate } from './templates/tutor-moderation-todo-template.model'; -import { TesterResponse } from './tester-response.model'; export class Issue { /** Basic Fields */ @@ -27,41 +17,17 @@ export class Issue { hiddenDataInDescription: HiddenData; updated_at: string; closed_at: string; - milestone?: Milestone; + milestone: Milestone; state: string; issueOrPr: string; author: string; - - /** Fields derived from Labels */ - severity: string; - type: string; - responseTag?: string; - duplicated?: boolean; - status?: string; - pending?: string; - unsure?: boolean; - teamAssigned?: Team; + isDraft: boolean; /** Depending on the phase, assignees attribute can be derived from Github's assignee feature OR from the Github's issue description */ assignees?: string[]; labels?: string[]; githubLabels?: GithubLabel[]; - /** Fields derived from parsing of Github's issue description */ - duplicateOf?: number; - teamResponse?: string; - testerResponses?: TesterResponse[]; - testerDisagree?: boolean; // whether tester agrees or disagree to teams reponse - issueComment?: IssueComment; // Issue comment is used for Tutor Response and Tester Response - issueDisputes?: IssueDispute[]; - teamChosenSeverity?: string; - teamChosenType?: string; - teamAccepted?: boolean; - - /** Fields for error messages during parsing of Github's issue description */ - teamResponseError: boolean; - testerResponseError: boolean; - /** * Formats the text to create space at the end of the user input to prevent any issues with * the markdown interpretation. @@ -94,14 +60,6 @@ export class Issue { return Issue.orDefaultString(Issue.formatText(description), defaultString); } - /** - * Processes and cleans a raw team response obtained from user input. - */ - static updateTeamResponse(teamResponse: string): string { - const defaultString = 'No details provided by team.'; - return Issue.orDefaultString(Issue.formatText(teamResponse), defaultString); - } - /** * Given two strings, returns the first if it is not an empty string or a false value such as null/undefined. * Returns the second string if the first is an empty string. @@ -123,205 +81,32 @@ export class Issue { this.title = githubIssue.title; this.hiddenDataInDescription = new HiddenData(githubIssue.body); this.description = Issue.updateDescription(this.hiddenDataInDescription.originalStringWithoutHiddenData); - this.milestone = githubIssue.milestone ? new Milestone(githubIssue.milestone) : null; + // githubIssue without milestone will be set to default milestone + this.milestone = githubIssue.milestone ? new Milestone(githubIssue.milestone) : Milestone.DefaultMilestone; this.state = githubIssue.state; this.issueOrPr = githubIssue.issueOrPr; this.author = githubIssue.user.login; // this.githubIssue = githubIssue; + this.isDraft = githubIssue.isDraft; this.assignees = githubIssue.assignees.map((assignee) => assignee.login); this.githubLabels = githubIssue.labels; this.labels = githubIssue.labels.map((label) => label.name); - - /** Fields derived from Labels */ - this.severity = githubIssue.findLabel(GithubLabel.LABELS.severity); - this.type = githubIssue.findLabel(GithubLabel.LABELS.type); - this.responseTag = githubIssue.findLabel(GithubLabel.LABELS.response); - this.duplicated = !!githubIssue.findLabel(GithubLabel.LABELS.duplicated, false); - this.status = githubIssue.findLabel(GithubLabel.LABELS.status); - this.pending = githubIssue.findLabel(GithubLabel.LABELS.pending); } public static createPhaseBugReportingIssue(githubIssue: GithubIssue): Issue { return new Issue(githubIssue); } - public static createPhaseTeamResponseIssue(githubIssue: GithubIssue, teamData: Team): Issue { - const issue = new Issue(githubIssue); - const template = new TeamResponseTemplate(githubIssue.comments); - - issue.githubComments = githubIssue.comments; - issue.teamAssigned = teamData; - issue.assignees = githubIssue.assignees.map((assignee) => assignee.login); - - issue.teamResponseError = template.parseFailure; - issue.issueComment = template.comment; - issue.teamResponse = template.teamResponse && Issue.updateTeamResponse(template.teamResponse.content); - issue.duplicateOf = template.duplicateOf && template.duplicateOf.issueNumber; - issue.duplicated = issue.duplicateOf !== undefined && issue.duplicateOf !== null; - - return issue; - } - - public static createPhaseTesterResponseIssue(githubIssue: GithubIssue): Issue { - const issue = new Issue(githubIssue); - const testerResponseTemplate = new TesterResponseTemplate(githubIssue.comments); - const teamAcceptedTemplate = new TeamAcceptedTemplate(githubIssue.comments); - - issue.githubComments = githubIssue.comments; - issue.testerResponseError = testerResponseTemplate.parseFailure && teamAcceptedTemplate.parseFailure; - issue.teamAccepted = teamAcceptedTemplate.teamAccepted; - issue.issueComment = testerResponseTemplate.comment; - issue.teamResponse = testerResponseTemplate.teamResponse && Issue.updateTeamResponse(testerResponseTemplate.teamResponse.content); - issue.testerResponses = testerResponseTemplate.testerResponse && testerResponseTemplate.testerResponse.testerResponses; - issue.testerDisagree = testerResponseTemplate.testerDisagree; - - issue.teamChosenSeverity = testerResponseTemplate.teamChosenSeverity || null; - issue.teamChosenType = testerResponseTemplate.teamChosenType || null; - - return issue; - } - - public static createPhaseModerationIssue(githubIssue: GithubIssue, teamData: Team): Issue { - const issue = new Issue(githubIssue); - const issueTemplate = new TutorModerationIssueTemplate(githubIssue); - const todoTemplate = new TutorModerationTodoTemplate(githubIssue.comments); - - issue.githubComments = githubIssue.comments; - issue.teamAssigned = teamData; - issue.description = issueTemplate.description.content; - issue.teamResponse = issueTemplate.teamResponse && Issue.updateTeamResponse(issueTemplate.teamResponse.content); - issue.issueDisputes = issueTemplate.dispute.disputes; - - if (todoTemplate.moderation && todoTemplate.comment) { - issue.issueDisputes = todoTemplate.moderation.disputesToResolve.map((dispute, i) => { - dispute.description = issueTemplate.dispute.disputes[i].description; - return dispute; - }); - issue.issueComment = todoTemplate.comment; - } - return issue; - } - - /** - * Creates a new copy of an exact same issue. - * This would come useful in the event when you want to update the issue but not the actual - * state of the application. - */ - clone(phase: Phase): Issue { - switch (phase) { - case Phase.issuesViewer: - return Issue.createPhaseBugReportingIssue(this.githubIssue); - default: - return Issue.createPhaseBugReportingIssue(this.githubIssue); - } - } - - /** - * Depending on the phase of the peer testing, each phase will have a response associated to them. - * This function will allow the current instance of issue to retain the state of response of the given `issue`. - * - * @param phase - The phase in which you want to retain your responses. - * @param issue - The issue which you want your current instance to retain from. - */ - retainResponses(phase: Phase, issue: Issue) { - this.issueComment = issue.issueComment; - this.githubComments = issue.githubComments; - switch (phase) { - case Phase.issuesViewer: - this.description = issue.description; - break; - default: - break; - } - } - - /** - * Updates the tester's responses and team response based on the given githubComment. - * @param githubComment - A version of githubComment to update the issue with. - */ - updateTesterResponse(githubComment: GithubComment): void { - const template = new TesterResponseTemplate([githubComment]); - this.issueComment = template.comment; - this.teamResponse = template.teamResponse && template.teamResponse.content; - this.testerResponses = template.testerResponse && template.testerResponse.testerResponses; - } - - /** - * Updates the tutor's resolution of the disputes with a new version of githubComment. - * @param githubComment - A version of githubComment to update the dispute with. - */ - updateDispute(githubComment: GithubComment): void { - const todoTemplate = new TutorModerationTodoTemplate([githubComment]); - this.issueComment = todoTemplate.comment; - this.issueDisputes = todoTemplate.moderation.disputesToResolve.map((dispute, i) => { - dispute.description = this.issueDisputes[i].description; - return dispute; - }); - } - createGithubIssueDescription(): string { return `${this.description}\n${this.hiddenDataInDescription.toString()}`; } - - // Template url: https://github.com/CATcher-org/templates#dev-response-phase - createGithubTeamResponse(): string { - return ( - `# Team\'s Response\n${this.teamResponse}\n` + - `## Duplicate status (if any):\n${this.duplicateOf ? `Duplicate of #${this.duplicateOf}` : `--`}` - ); - } - - // Template url: https://github.com/CATcher-org/templates#tutor-moderation - createGithubTutorResponse(): string { - let tutorResponseString = '# Tutor Moderation\n\n'; - for (const issueDispute of this.issueDisputes) { - tutorResponseString += issueDispute.toTutorResponseString(); - } - return tutorResponseString; - } - - // Template url: https://github.com/CATcher-org/templates#teams-response-1 - createGithubTesterResponse(): string { - return ( - `# Team\'s Response\n${this.teamResponse}\n` + - `# Items for the Tester to Verify\n${this.getTesterResponsesString(this.testerResponses)}` - ); - } - - /** - * Gets the number of unresolved disputes in an Issue. - */ - numOfUnresolvedDisputes(): number { - if (!this.issueDisputes) { - return 0; - } - - return this.issueDisputes.reduce((prev, current) => prev + Number(!current.isDone()), 0); - } - - private getTesterResponsesString(testerResponses: TesterResponse[]): string { - let testerResponsesString = ''; - for (const testerResponse of testerResponses) { - testerResponsesString += testerResponse.toString(); - } - return testerResponsesString; - } } export interface Issues { [id: number]: Issue; } -export const SEVERITY_ORDER = { '-': 0, VeryLow: 1, Low: 2, Medium: 3, High: 4 }; - -export const ISSUE_TYPE_ORDER = { '-': 0, DocumentationBug: 1, FeatureFlaw: 2, FunctionalityBug: 3 }; - -export enum STATUS { - Incomplete = 'Incomplete', - Done = 'Done' -} - export const IssuesFilter = { issuesViewer: { Student: 'NO_FILTER', diff --git a/src/app/core/models/label.model.ts b/src/app/core/models/label.model.ts index ca02cbc0..18799a19 100644 --- a/src/app/core/models/label.model.ts +++ b/src/app/core/models/label.model.ts @@ -1,29 +1,30 @@ /** * Represents a label and its attributes. */ -export class Label { +export class Label implements SimpleLabel { readonly category: string; readonly name: string; + readonly formattedName: string; // 'category'.'name' (e.g. severity.Low) if a category exists or 'name' if the category does not exist. color: string; definition?: string; constructor(label: { name: string; color: string; definition?: string }) { const containsDotRegex = /\.\b/g; // contains dot in middle of name [this.category, this.name] = containsDotRegex.test(label.name) ? label.name.split('.') : [undefined, label.name]; + this.formattedName = this.category === undefined || this.category === '' ? this.name : this.category.concat('.', this.name); this.color = label.color; this.definition = label.definition; } - /** - * Returns the name of the label with the format of - * 'category'.'name' (e.g. severity.Low) if a category exists or - * 'name' if the category does not exist. - */ - public getFormattedName(): string { - return this.category === undefined || this.category === '' ? this.name : this.category.concat('.', this.name); - } - public equals(label: Label) { return this.name === label.name && this.category === label.category; } } + +/** + * Represents a simplified label with name and color + */ +export type SimpleLabel = { + formattedName: string; + color: string; +}; diff --git a/src/app/core/models/milestone.model.ts b/src/app/core/models/milestone.model.ts index 3ff71daf..b2a00e0f 100644 --- a/src/app/core/models/milestone.model.ts +++ b/src/app/core/models/milestone.model.ts @@ -2,6 +2,7 @@ * Represents a milestone and its attributes fetched from Github. */ export class Milestone { + static DefaultMilestone: Milestone = new Milestone({ number: 'untracked', title: 'Without a milestone', state: null }); readonly number: string; // equivalent to the id of an issue e.g. milestone #1 title: string; state: string; diff --git a/src/app/core/models/parser/admins.model.ts b/src/app/core/models/parser/admins.model.ts deleted file mode 100644 index 9d6ddd8a..00000000 --- a/src/app/core/models/parser/admins.model.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface Admins { - [name: string]: Object; -} diff --git a/src/app/core/models/parser/parsed-user-data.model.ts b/src/app/core/models/parser/parsed-user-data.model.ts deleted file mode 100644 index dc8f6407..00000000 --- a/src/app/core/models/parser/parsed-user-data.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ParsedUserData { - role?: string; - name?: string; - team?: string; -} diff --git a/src/app/core/models/parser/roles.model.ts b/src/app/core/models/parser/roles.model.ts deleted file mode 100644 index 45205eb1..00000000 --- a/src/app/core/models/parser/roles.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface Roles { - students?: { - [loginId: string]: string; - }; - tutors?: { - [loginId: string]: string; - }; - admins?: { - [loginId: string]: string; - }; -} diff --git a/src/app/core/models/parser/students.model.ts b/src/app/core/models/parser/students.model.ts deleted file mode 100644 index 9e72226a..00000000 --- a/src/app/core/models/parser/students.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Students { - [studentname: string]: { - teamId?: string; - }; -} diff --git a/src/app/core/models/parser/tabulated-user-data.model.ts b/src/app/core/models/parser/tabulated-user-data.model.ts deleted file mode 100644 index 9e195b2a..00000000 --- a/src/app/core/models/parser/tabulated-user-data.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Admins } from './admins.model'; -import { Roles } from './roles.model'; -import { Students } from './students.model'; -import { Teams } from './teams.model'; -import { Tutors } from './tutors.model'; - -export interface TabulatedUserData { - 'admins-allocation'?: Admins; - roles?: Roles; - 'students-allocation'?: Students; - 'team-structure'?: Teams; - 'tutors-allocation'?: Tutors; -} diff --git a/src/app/core/models/parser/teams.model.ts b/src/app/core/models/parser/teams.model.ts deleted file mode 100644 index 5ac634f1..00000000 --- a/src/app/core/models/parser/teams.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Teams { - [teamId: string]: { - [teamMember: string]: string; - }; -} diff --git a/src/app/core/models/parser/tutors.model.ts b/src/app/core/models/parser/tutors.model.ts deleted file mode 100644 index 9c70a9e1..00000000 --- a/src/app/core/models/parser/tutors.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Tutors { - [name: string]: { - [tutorialGroup: string]: string; - }; -} diff --git a/src/app/core/models/repo.model.ts b/src/app/core/models/repo.model.ts index d34c9197..466f875b 100644 --- a/src/app/core/models/repo.model.ts +++ b/src/app/core/models/repo.model.ts @@ -16,13 +16,36 @@ export class Repo { public static of(repoUrl: string) { const repoUrlSplit = repoUrl.split('/'); if (repoUrlSplit.length !== 2) { - return undefined; // throw error? + throw new Error('Invalid repository name. Please provide repository name in the format Org/Repository Name.'); } return new Repo(repoUrlSplit[0], repoUrlSplit[1]); } + public static ofEmptyRepo() { + return EMPTY_REPO; + } + + public static isInvalidRepoName(repo: unknown) { + if (repo instanceof Repo) { + const otherRepo = repo as Repo; + return otherRepo.equals(EMPTY_REPO); + } + + return false; + } + /** String representation of a Repo. */ public toString(): string { return this.owner + '/' + this.name; } + + public equals(otherRepo: unknown): boolean { + if (otherRepo instanceof Repo) { + return otherRepo.name === this.name && otherRepo.owner === this.owner; + } + + return false; + } } + +const EMPTY_REPO = new Repo('', ''); diff --git a/src/app/core/models/templates/sections/common-parsers.model.ts b/src/app/core/models/templates/sections/common-parsers.model.ts deleted file mode 100644 index 9ab039e0..00000000 --- a/src/app/core/models/templates/sections/common-parsers.model.ts +++ /dev/null @@ -1,23 +0,0 @@ -const { char, choice, coroutine, everyCharUntil, str, whitespace } = require('arcsecond'); - -const TEAM_RESPONSE_HEADER = "# Team's Response"; - -export function buildTeamResponseSectionParser(nextHeader: string) { - return coroutine(function* () { - yield str(TEAM_RESPONSE_HEADER); - yield whitespace; - const teamResponse = yield everyCharUntil(str(nextHeader)); - - return teamResponse.trim(); - }); -} - -export function buildCheckboxParser(description: string) { - return coroutine(function* () { - yield str('- ['); - const checkbox = yield choice([char('x'), whitespace]); - yield str('] ' + description); - - return checkbox === 'x'; - }); -} diff --git a/src/app/core/models/templates/sections/duplicate-of-section.model.ts b/src/app/core/models/templates/sections/duplicate-of-section.model.ts deleted file mode 100644 index 86f7f9fa..00000000 --- a/src/app/core/models/templates/sections/duplicate-of-section.model.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Section, SectionalDependency } from './section.model'; - -export class DuplicateOfSection extends Section { - private readonly duplicateOfRegex = /Duplicate of\s*#(\d+)/i; - issueNumber: number; - - constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { - super(sectionalDependency, unprocessedContent); - if (!this.parseError) { - this.issueNumber = this.parseDuplicateOfValue(this.content); - } - } - - private parseDuplicateOfValue(toParse): number { - const result = this.duplicateOfRegex.exec(toParse); - return result ? +result[1] : null; - } - - toString(): string { - let toString = ''; - toString += `${this.header}\n`; - toString += this.parseError ? '--' : `Duplicate of ${this.issueNumber}\n`; - return toString; - } -} diff --git a/src/app/core/models/templates/sections/issue-dispute-section-parser.model.ts b/src/app/core/models/templates/sections/issue-dispute-section-parser.model.ts deleted file mode 100644 index 554e19b1..00000000 --- a/src/app/core/models/templates/sections/issue-dispute-section-parser.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IssueDispute } from '../../issue-dispute.model'; - -const { coroutine, everyCharUntil, optionalWhitespace, str } = require('arcsecond'); - -const SECTION_TITLE_PREFIX = '## :question: '; -const TEAM_SAYS_HEADER = '### Team says:'; -const LINE_SEPARATOR = '-------------------'; - -export const IssueDisputeSectionParser = coroutine(function* () { - yield str(SECTION_TITLE_PREFIX); - const title = yield everyCharUntil(str(TEAM_SAYS_HEADER)); - - const description = yield everyCharUntil(str(LINE_SEPARATOR)); - yield str(LINE_SEPARATOR); - yield optionalWhitespace; - - return new IssueDispute(title.trim(), description.trim()); -}); diff --git a/src/app/core/models/templates/sections/issue-dispute-section.model.ts b/src/app/core/models/templates/sections/issue-dispute-section.model.ts deleted file mode 100644 index e99abeb2..00000000 --- a/src/app/core/models/templates/sections/issue-dispute-section.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IssueDispute } from '../../issue-dispute.model'; -import { Section, SectionalDependency } from './section.model'; - -export class IssueDisputeSection extends Section { - disputes: IssueDispute[] = []; - - constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { - super(sectionalDependency, unprocessedContent); - if (!this.parseError) { - let matches; - const regex = /#{2} *:question: *(.*)[\r\n]*([\s\S]*?(?=-{19}))/gi; - while ((matches = regex.exec(this.content))) { - if (matches) { - const [_regexString, title, description] = matches; - this.disputes.push(new IssueDispute(title, description.trim())); - } - } - } - } - - toString(): string { - let toString = ''; - toString += `${this.header.toString()}\n`; - for (const dispute of this.disputes) { - toString += `${dispute.toString()}\n`; - } - return toString; - } -} diff --git a/src/app/core/models/templates/sections/moderation-section-parser.model.ts b/src/app/core/models/templates/sections/moderation-section-parser.model.ts deleted file mode 100644 index 8b06501d..00000000 --- a/src/app/core/models/templates/sections/moderation-section-parser.model.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Checkbox } from '../../checkbox.model'; -import { IssueDispute } from '../../issue-dispute.model'; -import { buildCheckboxParser } from './common-parsers.model'; - -const { coroutine, everyCharUntil, lookAhead, optionalWhitespace, str, whitespace } = require('arcsecond'); - -const SECTION_TITLE_PREFIX = '## :question: '; -const DONE_CHECKBOX_DESCRIPTION = 'Done'; -const LINE_SEPARATOR = '-------------------'; - -export const DoneCheckboxParser = buildCheckboxParser(DONE_CHECKBOX_DESCRIPTION); - -export const ModerationSectionParser = coroutine(function* () { - yield str(SECTION_TITLE_PREFIX); - const title = yield everyCharUntil(str('- [')); // every char until the done checkbox - - const description = yield lookAhead(everyCharUntil(str(LINE_SEPARATOR))); - - const doneCheckboxValue = yield DoneCheckboxParser; - yield whitespace; - const tutorResponse = yield everyCharUntil(str(LINE_SEPARATOR)); - yield str(LINE_SEPARATOR); - yield optionalWhitespace; - - const dispute = new IssueDispute(title.trim(), description.trim()); - dispute.todo = new Checkbox(DONE_CHECKBOX_DESCRIPTION, doneCheckboxValue); - dispute.tutorResponse = tutorResponse.trim(); - - return dispute; -}); diff --git a/src/app/core/models/templates/sections/moderation-section.model.ts b/src/app/core/models/templates/sections/moderation-section.model.ts deleted file mode 100644 index edde99a2..00000000 --- a/src/app/core/models/templates/sections/moderation-section.model.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Checkbox } from '../../checkbox.model'; -import { IssueDispute } from '../../issue-dispute.model'; -import { Section, SectionalDependency } from './section.model'; - -export class ModerationSection extends Section { - disputesToResolve: IssueDispute[] = []; - - constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { - super(sectionalDependency, unprocessedContent); - if (!this.parseError) { - let matches; - const regex = /#{2} *:question: *(.*)[\n\r]*(.*)[\n\r]*([\s\S]*?(?=-{19}))/gi; - while ((matches = regex.exec(this.content))) { - if (matches) { - const [_regexString, title, todo, tutorResponse] = matches; - const description = `${todo}\n${tutorResponse}`; - const newDispute = new IssueDispute(title, description); - - newDispute.todo = new Checkbox(todo, false); - newDispute.tutorResponse = tutorResponse.trim(); - this.disputesToResolve.push(newDispute); - } - } - } - } - - get todoList(): Checkbox[] { - return this.disputesToResolve.map((e) => e.todo); - } - - toString(): string { - let toString = ''; - toString += `${this.header.toString()}\n`; - for (const dispute of this.disputesToResolve) { - toString += `${dispute.toTutorResponseString()}\n`; - } - return toString; - } -} diff --git a/src/app/core/models/templates/sections/section.model.ts b/src/app/core/models/templates/sections/section.model.ts deleted file mode 100644 index 1d6bf3a7..00000000 --- a/src/app/core/models/templates/sections/section.model.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * A SectionalDependency defines a format that is needed to create a successful Section in a template. - * It will require the Section's header to be defined and the other headers that are present in the template. - * - * Reason for the dependencies on other headers: We need them to create a regex expression that is capable of parsing the current - * section out of the string. - */ -import { Header } from '../template.model'; - -export interface SectionalDependency { - sectionHeader: Header; - remainingTemplateHeaders: Header[]; -} - -export class Section { - header: Header; - sectionRegex: RegExp; - content: string; - parseError: string; - - /** - * - * @param sectionalDependency The dependency that is need to create a section's regex - * @param unprocessedContent The string that stores the section's amongst other things - */ - constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { - this.header = sectionalDependency.sectionHeader; - // If length === 0, match till end of string else match till regex hits another section - const matchTillRegex = - sectionalDependency.remainingTemplateHeaders.length === 0 ? '$' : sectionalDependency.remainingTemplateHeaders.join('|'); - this.sectionRegex = new RegExp(`(${this.header})\\s+([\\s\\S]*?)(?=${matchTillRegex}|$)`, 'i'); - const matches = this.sectionRegex.exec(unprocessedContent); - if (matches) { - const [_originalString, _header, description] = matches; - this.content = description.trim(); - this.parseError = null; - } else { - this.content = null; - this.parseError = `Unable to extract ${this.header.name} Section`; - } - } -} diff --git a/src/app/core/models/templates/sections/tester-response-section-parser.model.ts b/src/app/core/models/templates/sections/tester-response-section-parser.model.ts deleted file mode 100644 index 9c7ef10d..00000000 --- a/src/app/core/models/templates/sections/tester-response-section-parser.model.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { buildCheckboxParser } from './common-parsers.model'; - -const { - between, - coroutine, - everyCharUntil, - letters, - lookAhead, - optionalWhitespace, - pipeParsers, - possibly, - str, - whitespace -} = require('arcsecond'); - -const SECTION_TITLE_PREFIX = '## :question: Issue '; -const TEAM_CHOSE_PREFIX = 'Team chose '; -const TESTER_CHOSE_PREFIX = 'Originally '; -const DISAGREE_CHECKBOX_DESCRIPTION = 'I disagree'; -const DISAGREEMENT_REASON_PREFIX = '**Reason for disagreement:** '; -const LINE_SEPARATOR = '-------------------'; -const DUPLICATE_STATUS_MESSAGE = - "Team chose to mark this issue as a duplicate of another issue (as explained in the _**Team's response**_ above)"; - -export const DisagreeCheckboxParser = buildCheckboxParser(DISAGREE_CHECKBOX_DESCRIPTION); - -function buildExtractResponseParser(category: string) { - return between(str('[`' + category + '.'))(str('`]'))(letters); -} - -function buildTeamResponseParser(category: string) { - const extractResponseParser = buildExtractResponseParser(category); - - return pipeParsers([str(TEAM_CHOSE_PREFIX), extractResponseParser]); -} - -function buildTesterResponseParser(category: string) { - const extractResponseParser = buildExtractResponseParser(category); - - return pipeParsers([str(TESTER_CHOSE_PREFIX), extractResponseParser]); -} - -export const DisagreeReasonParser = coroutine(function* () { - yield str(DISAGREEMENT_REASON_PREFIX); - const reasonForDisagreement = yield everyCharUntil(str(LINE_SEPARATOR)); - yield str(LINE_SEPARATOR); - - return reasonForDisagreement.trim(); -}); - -// Issue duplicate section has a different format than the other three -const DuplicateSectionParser = coroutine(function* () { - yield str('status'); - yield whitespace; - yield str(DUPLICATE_STATUS_MESSAGE); - yield whitespace; - - const disagreeCheckboxValue = yield DisagreeCheckboxParser; - yield whitespace; - const reasonForDisagreement = yield DisagreeReasonParser; - - return { - disagreeCheckboxValue: disagreeCheckboxValue, - reasonForDisagreement: reasonForDisagreement - }; -}); - -export const TesterResponseSectionParser = coroutine(function* () { - // section title - yield str(SECTION_TITLE_PREFIX); - const title = yield letters; - yield whitespace; - - if (title === 'duplicate') { - const dupSectionResult = yield DuplicateSectionParser; - yield optionalWhitespace; - - return { - title: title + ' status', - description: DUPLICATE_STATUS_MESSAGE, - teamChose: null, - testerChose: null, - disagreeCheckboxValue: dupSectionResult.disagreeCheckboxValue, - reasonForDisagreement: dupSectionResult.reasonForDisagreement - }; - } - - const description = yield lookAhead(everyCharUntil(DisagreeCheckboxParser)); - - // team and tester response - const teamResponseParser = buildTeamResponseParser(title); - const testerResponseParser = buildTesterResponseParser(title); - - const teamChose = yield teamResponseParser; - yield whitespace; - // response section does not have tester response - const testerChose = yield possibly(testerResponseParser); - yield optionalWhitespace; - - const disagreeCheckboxValue = yield DisagreeCheckboxParser; - yield whitespace; - const reasonForDisagreement = yield DisagreeReasonParser; - yield optionalWhitespace; - - return { - title: title, - description: description.trim(), - teamChose: teamChose, - testerChose: testerChose, - disagreeCheckboxValue: disagreeCheckboxValue, - reasonForDisagreement: reasonForDisagreement - }; -}); diff --git a/src/app/core/models/templates/sections/tester-response-section.model.ts b/src/app/core/models/templates/sections/tester-response-section.model.ts deleted file mode 100644 index e6809bef..00000000 --- a/src/app/core/models/templates/sections/tester-response-section.model.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { extractStringBetween } from '../../../../shared/lib/string-utils'; -import { TesterResponse } from '../../tester-response.model'; -import { Section, SectionalDependency } from './section.model'; - -// match format e.g. ## :question: Issue Title -const matchTitle = '#{2} *:question: *([\\w ]+)'; -// match format e.g. Team Chose severity.Low \r\n Originally (or Team Chose) severity.High \r\n -const matchDescription = '(Team Chose.*[\\r\\n]* *Originally.*|Team Chose.*[\\r\\n]*)'; -// match format e.g. - [x] (or - [ ]) **Reason for disagreement:** disagreement explanation -const matchDisagreement = '(- \\[x? ?\\] I disagree)[\\r\\n]*\\*\\*Reason *for *disagreement:\\*\\* *([\\s\\S]*?)'; -const matchLinebreak = '[\\n\\r]-{19}'; - -export class TesterResponseSection extends Section { - testerResponses: TesterResponse[] = []; - testerDisagree: boolean; - teamChosenSeverity?: string; - teamChosenType?: string; - - ISSUE_SEVERITY_DISPUTE = 'Issue severity'; - ISSUE_TYPE_DISPUTE = 'Issue type'; - TEAM_RESPONSE_DESCRIPTION_TYPE_VALUE_PREFIX = '[`type.'; - TEAM_RESPONSE_DESCRIPTION_SEVERITY_VALUE_PREFIX = '[`severity.'; - TEAM_RESPONSE_DESCRIPTION_VALUE_SUFFIX = '`]'; - - constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { - super(sectionalDependency, unprocessedContent); - if (!this.parseError) { - let matches; - const regex: RegExp = new RegExp([matchTitle, matchDescription, matchDisagreement].join('[\\r\\n]*') + matchLinebreak, 'gi'); - while ((matches = regex.exec(this.content))) { - if (matches) { - const [_, title, description, disagreeCheckbox, reasonForDisagreement] = matches; - - if (this.isSeverityDispute(title)) { - this.teamChosenSeverity = this.parseTeamChosenSeverity(description); - } else if (this.isTypeDispute(title)) { - this.teamChosenType = this.parseTeamChosenType(description); - } - - const disagreeCheckboxValue = this.parseCheckboxValue(disagreeCheckbox); - if (disagreeCheckboxValue) { - this.testerDisagree = true; // on any disagree, overall disagree with team response - } - - this.testerResponses.push( - new TesterResponse( - title, - description, - this.parseCheckboxDescription(disagreeCheckbox), - disagreeCheckboxValue, - reasonForDisagreement.trim() - ) - ); - } - } - } - } - - isSeverityDispute(title: string): boolean { - return title.trim() === this.ISSUE_SEVERITY_DISPUTE; - } - - isTypeDispute(title: string): boolean { - return title.trim() === this.ISSUE_TYPE_DISPUTE; - } - - getTeamChosenType(): string { - return this.teamChosenType; - } - - getTeamChosenSeverity(): string { - return this.teamChosenSeverity; - } - - getTesterDisagree(): boolean { - return this.testerDisagree; - } - - parseTeamChosenSeverity(description: string): string { - return extractStringBetween( - description, - this.TEAM_RESPONSE_DESCRIPTION_SEVERITY_VALUE_PREFIX, - this.TEAM_RESPONSE_DESCRIPTION_VALUE_SUFFIX - ); - } - - parseTeamChosenType(description: string): string { - return extractStringBetween(description, this.TEAM_RESPONSE_DESCRIPTION_TYPE_VALUE_PREFIX, this.TEAM_RESPONSE_DESCRIPTION_VALUE_SUFFIX); - } - - parseCheckboxValue(checkboxString: string): boolean { - return checkboxString.charAt(3) === 'x'; // checkboxString in the format of - [x] or - [ ] - } - - parseCheckboxDescription(checkboxString: string): string { - return checkboxString.substring(6).trim(); // checkboxString has a fixed 5 characters at the start before the description - } - - toString(): string { - let toString = ''; - toString += `${this.header.toString()}\n`; - for (const response of this.testerResponses) { - toString += `${response.toString()}\n`; - } - return toString; - } -} diff --git a/src/app/core/models/templates/team-accepted-template.model.ts b/src/app/core/models/templates/team-accepted-template.model.ts deleted file mode 100644 index b208fd39..00000000 --- a/src/app/core/models/templates/team-accepted-template.model.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { GithubComment } from '../github/github-comment.model'; -import { Header, Template } from './template.model'; - -const { endOfInput, sequenceOf, startOfInput, str } = require('arcsecond'); - -export const TeamAcceptedMessage = 'Your response not required for this bug as the team has accepted the bug as it is.'; -export const TeamAcceptedHeader = { teamAccepted: new Header(TeamAcceptedMessage, 0) }; - -const TeamAcceptedParser = sequenceOf([startOfInput, str(TeamAcceptedMessage), endOfInput]); - -export class TeamAcceptedTemplate extends Template { - teamAccepted?: boolean; - - constructor(githubComments: GithubComment[]) { - super(TeamAcceptedParser, Object.values(TeamAcceptedHeader)); - - this.findConformingComment(githubComments); - - if (this.parseFailure) { - return; - } - - this.teamAccepted = true; - } -} diff --git a/src/app/core/models/templates/team-response-template.model.ts b/src/app/core/models/templates/team-response-template.model.ts deleted file mode 100644 index 6f74aa0f..00000000 --- a/src/app/core/models/templates/team-response-template.model.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { IssueComment } from '../comment.model'; -import { GithubComment } from '../github/github-comment.model'; -import { buildTeamResponseSectionParser } from './sections/common-parsers.model'; -import { DuplicateOfSection } from './sections/duplicate-of-section.model'; -import { Section } from './sections/section.model'; -import { Header, Template } from './template.model'; - -const { choice, coroutine, digits, str, whitespace } = require('arcsecond'); - -export const TeamResponseHeaders = { - teamResponse: new Header("Team's Response", 1), - duplicateOf: new Header('Duplicate status \\(if any\\):', 2) -}; - -interface TeamResponseParseResult { - teamResponse: string; - issueNumber: number; -} - -const DUPLICATE_OF_HEADER = '## Duplicate status (if any):'; - -const TeamResponseSectionParser = buildTeamResponseSectionParser(DUPLICATE_OF_HEADER); - -const DuplicateNumberParser = coroutine(function* () { - yield str('Duplicate of #'); // parse and ignore - const issueNumber = yield digits; // parse and store duplicate issue number - return parseInt(issueNumber, 10); // issueNumber is a string, radix added to pass linting -}); - -export const TeamResponseParser = coroutine(function* () { - const teamResponse = yield TeamResponseSectionParser; - - yield str(DUPLICATE_OF_HEADER); - yield whitespace; - const issueNumber = yield choice([ - // either parse duplicate issue number or '--' if no duplicates - DuplicateNumberParser, - str('--') - ]).map((num) => (num === '--' ? null : num)); - - const result: TeamResponseParseResult = { - teamResponse: teamResponse, - issueNumber: issueNumber - }; - return result; -}); - -export class TeamResponseTemplate extends Template { - teamResponse: Section; - duplicateOf: DuplicateOfSection; - comment: IssueComment; - - constructor(githubComments: GithubComment[]) { - super(TeamResponseParser, Object.values(TeamResponseHeaders)); - - const templateConformingComment = this.findConformingComment(githubComments); - - if (this.parseFailure) { - return; - } - - this.comment = { - ...templateConformingComment, - description: templateConformingComment.body, - createdAt: templateConformingComment.created_at, - updatedAt: templateConformingComment.updated_at - }; - const commentsContent: string = templateConformingComment.body; - this.teamResponse = this.parseTeamResponse(commentsContent); - this.duplicateOf = this.parseDuplicateOf(commentsContent); - } - - parseTeamResponse(toParse: string): Section { - return new Section(this.getSectionalDependency(TeamResponseHeaders.teamResponse), toParse); - } - - parseDuplicateOf(toParse: string): DuplicateOfSection { - return new DuplicateOfSection(this.getSectionalDependency(TeamResponseHeaders.duplicateOf), toParse); - } -} diff --git a/src/app/core/models/templates/template.model.ts b/src/app/core/models/templates/template.model.ts deleted file mode 100644 index d6d349b3..00000000 --- a/src/app/core/models/templates/template.model.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { GithubComment } from '../github/github-comment.model'; -import { SectionalDependency } from './sections/section.model'; - -export abstract class Template { - headers: Header[]; - parser; - parseFailure: boolean; - - protected constructor(parser, headers: Header[]) { - this.parser = parser; - this.headers = headers; - } - - getSectionalDependency(header: Header): SectionalDependency { - const otherHeaders = this.headers.filter((e) => !e.equals(header)); - return { - sectionHeader: header, - remainingTemplateHeaders: otherHeaders - }; - } - - test(toTest: string): boolean { - return !this.parser.run(toTest).isError; - } - - /** - * Finds a comment that conforms to the template - */ - findConformingComment(githubComments: GithubComment[]): GithubComment { - const templateConformingComment = githubComments.find((githubComment) => this.test(githubComment.body)); - if (templateConformingComment === undefined) { - this.parseFailure = true; - } - return templateConformingComment; - } -} - -export class Header { - name: string; - headerHash: string; - prefix?: string; - - constructor(name, headerSize, prefix: string = '') { - this.name = name; - this.headerHash = '#'.repeat(headerSize); - this.prefix = prefix; - } - - toString(): string { - const prefix = this.prefix !== '' ? this.prefix + ' ' : ''; - const headerHashPrefix = this.headerHash !== '' ? this.headerHash + ' ' : ''; - return `${headerHashPrefix}${prefix}${this.name}`; - } - - equals(section: Header): boolean { - return this.name === section.name; - } -} diff --git a/src/app/core/models/templates/tester-response-template.model.ts b/src/app/core/models/templates/tester-response-template.model.ts deleted file mode 100644 index fc0e9018..00000000 --- a/src/app/core/models/templates/tester-response-template.model.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { IssueComment } from '../comment.model'; -import { GithubComment } from '../github/github-comment.model'; -import { TesterResponse } from '../tester-response.model'; -import { buildTeamResponseSectionParser } from './sections/common-parsers.model'; -import { Section } from './sections/section.model'; -import { TesterResponseSectionParser } from './sections/tester-response-section-parser.model'; -import { TesterResponseSection } from './sections/tester-response-section.model'; -import { Header, Template } from './template.model'; - -const { coroutine, many1, str, whitespace } = require('arcsecond'); - -export const TesterResponseHeaders = { - teamResponse: new Header("Team's Response", 1), - testerResponses: new Header('Items for the Tester to Verify', 1) -}; - -interface TesterResponseParseResult { - teamResponse: string; - testerResponses: TesterResponse[]; - testerDisagree: boolean; - teamChosenSeverity: string; - teamChosenType: string; -} - -const TESTER_RESPONSES_HEADER = '# Items for the Tester to Verify'; -const DISAGREE_CHECKBOX_DESCRIPTION = 'I disagree'; - -const TeamResponseSectionParser = buildTeamResponseSectionParser(TESTER_RESPONSES_HEADER); - -export const TesterResponseParser = coroutine(function* () { - const teamResponse = yield TeamResponseSectionParser; - - // parse tester responses from comment - yield str(TESTER_RESPONSES_HEADER); - yield whitespace; - const responses = yield many1(TesterResponseSectionParser); - - // build array of TesterResponse - let testerDisagree = false; - let teamChosenSeverity: string; - let teamChosenType: string; - const testerResponses: TesterResponse[] = []; - - for (const response of responses) { - if (response.disagreeCheckboxValue) { - testerDisagree = true; - } - - if (response.title === 'severity') { - teamChosenSeverity = response.teamChose; - } else if (response.title === 'type') { - teamChosenType = response.teamChose; - } - - testerResponses.push( - new TesterResponse( - 'Issue ' + response.title, - response.description, - DISAGREE_CHECKBOX_DESCRIPTION, - response.disagreeCheckboxValue, - response.reasonForDisagreement - ) - ); - } - - const result: TesterResponseParseResult = { - teamResponse: teamResponse, - testerResponses: testerResponses, - testerDisagree: testerDisagree, - teamChosenSeverity: teamChosenSeverity, - teamChosenType: teamChosenType - }; - return result; -}); - -export class TesterResponseTemplate extends Template { - teamResponse: Section; - testerResponse: TesterResponseSection; - testerDisagree: boolean; - comment: IssueComment; - teamChosenSeverity?: string; - teamChosenType?: string; - - constructor(githubComments: GithubComment[]) { - super(TesterResponseParser, Object.values(TesterResponseHeaders)); - - const templateConformingComment = this.findConformingComment(githubComments); - - if (this.parseFailure) { - return; - } - - this.comment = { - ...templateConformingComment, - description: templateConformingComment.body - }; - this.teamResponse = this.parseTeamResponse(this.comment.description); - this.testerResponse = this.parseTesterResponse(this.comment.description); - this.testerDisagree = this.testerResponse.getTesterDisagree(); - this.teamChosenSeverity = this.testerResponse.getTeamChosenSeverity(); - this.teamChosenType = this.testerResponse.getTeamChosenType(); - } - - parseTeamResponse(toParse: string): Section { - return new Section(this.getSectionalDependency(TesterResponseHeaders.teamResponse), toParse); - } - - parseTesterResponse(toParse: string): TesterResponseSection { - return new TesterResponseSection(this.getSectionalDependency(TesterResponseHeaders.testerResponses), toParse); - } -} diff --git a/src/app/core/models/templates/tutor-moderation-issue-template.model.ts b/src/app/core/models/templates/tutor-moderation-issue-template.model.ts deleted file mode 100644 index c5539437..00000000 --- a/src/app/core/models/templates/tutor-moderation-issue-template.model.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { GithubIssue } from '../github/github-issue.model'; -import { IssueDispute } from '../issue-dispute.model'; -import { buildTeamResponseSectionParser } from './sections/common-parsers.model'; -import { IssueDisputeSectionParser } from './sections/issue-dispute-section-parser.model'; -import { IssueDisputeSection } from './sections/issue-dispute-section.model'; -import { Section } from './sections/section.model'; -import { Header, Template } from './template.model'; - -const { coroutine, everyCharUntil, many1, str, whitespace } = require('arcsecond'); - -const tutorModerationIssueDescriptionHeaders = { - description: new Header('Issue Description', 1), - teamResponse: new Header("Team's Response", 1), - disputes: new Header('Disputes', 1) -}; - -interface TutorModerationIssueParseResult { - description: string; - teamResponse: string; - issueDisputes: IssueDispute[]; -} - -const DESCRIPTION_HEADER = '# Issue Description'; -const TEAM_RESPONSE_HEADER = "# Team's Response"; -const DISPUTES_HEADER = '# Disputes'; - -const TeamResponseSectionParser = buildTeamResponseSectionParser(DISPUTES_HEADER); - -export const TutorModerationIssueParser = coroutine(function* () { - yield str(DESCRIPTION_HEADER); - yield whitespace; - const description = yield everyCharUntil(str(TEAM_RESPONSE_HEADER)); - - const teamResponse = yield TeamResponseSectionParser; - - // parse disputes - yield str(DISPUTES_HEADER); - yield whitespace; - const disputes = yield many1(IssueDisputeSectionParser); - - const result: TutorModerationIssueParseResult = { - description: description.trim(), - teamResponse: teamResponse, - issueDisputes: disputes - }; - return result; -}); - -export class TutorModerationIssueTemplate extends Template { - description: Section; - teamResponse: Section; - dispute: IssueDisputeSection; - - constructor(githubIssue: GithubIssue) { - super(TutorModerationIssueParser, Object.values(tutorModerationIssueDescriptionHeaders)); - - const issueContent = githubIssue.body; - this.description = this.parseDescription(issueContent); - this.teamResponse = this.parseTeamResponse(issueContent); - this.dispute = this.parseDisputes(issueContent); - } - - parseDescription(toParse: string): Section { - return new Section(this.getSectionalDependency(tutorModerationIssueDescriptionHeaders.description), toParse); - } - - parseTeamResponse(toParse: string): Section { - return new Section(this.getSectionalDependency(tutorModerationIssueDescriptionHeaders.teamResponse), toParse); - } - - parseDisputes(toParse: string): IssueDisputeSection { - return new IssueDisputeSection(this.getSectionalDependency(tutorModerationIssueDescriptionHeaders.disputes), toParse); - } -} diff --git a/src/app/core/models/templates/tutor-moderation-todo-template.model.ts b/src/app/core/models/templates/tutor-moderation-todo-template.model.ts deleted file mode 100644 index 81e3669d..00000000 --- a/src/app/core/models/templates/tutor-moderation-todo-template.model.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { IssueComment } from '../comment.model'; -import { GithubComment } from '../github/github-comment.model'; -import { IssueDispute } from '../issue-dispute.model'; -import { ModerationSectionParser } from './sections/moderation-section-parser.model'; -import { ModerationSection } from './sections/moderation-section.model'; -import { Header, Template } from './template.model'; - -const { coroutine, many1, str, whitespace } = require('arcsecond'); - -const tutorModerationTodoHeaders = { - todo: new Header('Tutor Moderation', 1) -}; - -interface TutorModerationTodoParseResult { - disputesToResolve: IssueDispute[]; -} - -const TODO_HEADER = '# Tutor Moderation'; - -export const TutorModerationTodoParser = coroutine(function* () { - yield str(TODO_HEADER); - yield whitespace; - - const tutorResponses = yield many1(ModerationSectionParser); - - const result: TutorModerationTodoParseResult = { - disputesToResolve: tutorResponses - }; - return result; -}); - -export class TutorModerationTodoTemplate extends Template { - moderation: ModerationSection; - comment: IssueComment; - - constructor(githubComments: GithubComment[]) { - super(TutorModerationTodoParser, Object.values(tutorModerationTodoHeaders)); - - const templateConformingComment = this.findConformingComment(githubComments); - - if (this.parseFailure) { - return; - } - - this.comment = { - ...templateConformingComment, - description: templateConformingComment.body - }; - this.moderation = this.parseModeration(this.comment.description); - } - - parseModeration(toParse: string): ModerationSection { - return new ModerationSection(this.getSectionalDependency(tutorModerationTodoHeaders.todo), toParse); - } -} diff --git a/src/app/core/models/tester-response.model.ts b/src/app/core/models/tester-response.model.ts deleted file mode 100644 index 3f761b45..00000000 --- a/src/app/core/models/tester-response.model.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Checkbox } from './checkbox.model'; - -export class TesterResponse { - readonly TITLE_PREFIX = '## :question: '; - readonly DISAGREEMENT_PREFIX = '**Reason for disagreement:** '; - readonly INITIAL_RESPONSE = '[replace this with your explanation]'; - readonly LINE_BREAK = '-------------------\n'; - title: string; // e.g Issue Severity - description: string; // e.g Team chose `Low`. Originally `High`. - disagreeCheckbox: Checkbox; // e.g - [x] I disagree - reasonForDisagreement: string; - - constructor(title: string, description: string, checkboxDescription: string, isChecked: boolean, reasonForDiagreement: string) { - this.title = title; - this.description = description; - this.disagreeCheckbox = new Checkbox(checkboxDescription, isChecked); - this.reasonForDisagreement = reasonForDiagreement; - } - - toString(): string { - let toString = ''; - toString += this.TITLE_PREFIX + this.title + '\n\n'; - toString += this.description + '\n\n'; - toString += this.disagreeCheckbox.toString() + '\n\n'; - toString += this.DISAGREEMENT_PREFIX + this.reasonForDisagreement + '\n\n'; - toString += this.LINE_BREAK; - return toString; - } - - isDisagree(): boolean { - return this.disagreeCheckbox.isChecked; - } - - compareTo(anotherResponse: TesterResponse): number { - if (this.isDisagree() === anotherResponse.isDisagree()) { - return this.reasonForDisagreement.localeCompare(anotherResponse.reasonForDisagreement); - } - return this.isDisagree() ? 1 : -1; - } - - getTitleInMarkDown(): string { - return `## ${this.title}`; - } - - getDisagreementWithoutDefaultResponse(): string { - return this.reasonForDisagreement.replace(this.INITIAL_RESPONSE, ' '); - } - - setDisagree(isDisagree: boolean) { - this.disagreeCheckbox.setChecked(isDisagree); - } - - setReasonForDisagreement(reason: string) { - this.reasonForDisagreement = reason; - } -} diff --git a/src/app/core/models/timeline-item.model.ts b/src/app/core/models/timeline-item.model.ts deleted file mode 100644 index 9613a374..00000000 --- a/src/app/core/models/timeline-item.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type TimelineTime = { - starting_time: number; - ending_time: number; - display?: string; // circle/rect - id?: number; -}; - -export type TimelineItem = { - times: TimelineTime[]; - label?: string; - icon?: string; // path to image -}; diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index 761b118f..3ab0ec02 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -2,14 +2,17 @@ import { Injectable } from '@angular/core'; import { NgZone } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { Router } from '@angular/router'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, from, Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; import { AppConfig } from '../../../environments/environment'; import { generateSessionId } from '../../shared/lib/session'; import { uuid } from '../../shared/lib/uuid'; -import { ElectronService } from './electron.service'; +import { Phase } from '../models/phase.model'; +import { ErrorHandlingService } from './error-handling.service'; import { GithubService } from './github.service'; import { GithubEventService } from './githubevent.service'; import { IssueService } from './issue.service'; +import { LabelService } from './label.service'; import { LoggingService } from './logging.service'; import { PhaseService } from './phase.service'; import { UserService } from './user.service'; @@ -38,17 +41,18 @@ export class AuthService { ENABLE_POPUP_MESSAGE = 'Please enable pop-ups in your browser'; constructor( - private electronService: ElectronService, private router: Router, private ngZone: NgZone, private githubService: GithubService, private userService: UserService, private issueService: IssueService, + private labelService: LabelService, private phaseService: PhaseService, private githubEventService: GithubEventService, private titleService: Title, + private errorHandlingService: ErrorHandlingService, private logger: LoggingService - ) {} + ) {} /** * Will store the OAuth token. @@ -69,6 +73,7 @@ export class AuthService { this.githubService.reset(); this.userService.reset(); this.issueService.reset(true); + this.labelService.reset(); this.phaseService.reset(); this.githubEventService.reset(); this.logger.reset(); @@ -123,20 +128,45 @@ export class AuthService { startOAuthProcess() { this.logger.info('AuthService: Starting authentication'); // Available OAuth scopes https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes - const githubRepoPermission = 'public_repo'; + const githubRepoPermission = 'repo'; this.changeAuthState(AuthState.AwaitingAuthentication); - if (this.electronService.isElectron()) { - this.electronService.sendIpcMessage('github-oauth', githubRepoPermission); - } else { - this.generateStateString(); - this.redirectToOAuthPage( - encodeURI( - `${AppConfig.githubUrl}/login/oauth/authorize?client_id=${AppConfig.clientId}&scope=${githubRepoPermission},read:user&state=${this.state}` - ) + this.generateStateString(); + this.redirectToOAuthPage( + encodeURI( + `${AppConfig.githubUrl}/login/oauth/authorize?client_id=${AppConfig.clientId}&scope=${githubRepoPermission},read:user&state=${this.state}` + ) + ); + this.logger.info(`AuthService: Redirecting for Github authentication`); + } + + /** + * Handles the clean up required after authentication and setting up of repository is completed. + */ + handleSetRepoSuccess() { + this.setTitleWithPhaseDetail(); + this.router.navigateByUrl(Phase.issuesViewer); + } + + /** + * Setup repository after authentication. + */ + setRepo(): Observable { + return from(this.phaseService.initializeCurrentRepository()) + .pipe( + map(() => { + if (!this.phaseService.currentRepo) { + return false; + } + this.githubEventService.setLatestChangeEvent(); + this.handleSetRepoSuccess(); + return true; + }), + catchError((error) => { + this.errorHandlingService.handleError(error); + return of(false); + }) ); - this.logger.info(`AuthService: Redirecting for Github authentication`); - } } /** diff --git a/src/app/core/services/dialog.service.ts b/src/app/core/services/dialog.service.ts index aec616a4..c02396f0 100644 --- a/src/app/core/services/dialog.service.ts +++ b/src/app/core/services/dialog.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { MatDialog } from '@angular/material'; +import { MatDialog } from '@angular/material/dialog'; import { LabelDefinitionPopupComponent } from '../../shared/label-definition-popup/label-definition-popup.component'; import { RepoChangeFormComponent } from '../../shared/repo-change-form/repo-change-form.component'; import { UserConfirmationComponent } from '../guards/user-confirmation/user-confirmation.component'; diff --git a/src/app/core/services/electron.service.ts b/src/app/core/services/electron.service.ts deleted file mode 100644 index 53ebc931..00000000 --- a/src/app/core/services/electron.service.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ipcRenderer } from 'electron'; - -declare var window: Window; -declare global { - interface Window { - process: any; - require: any; - } -} - -@Injectable({ - providedIn: 'root' -}) - -/** - * Responsible for setting up the electron application environment and the - * respective event listeners and handlers. - */ -export class ElectronService { - ipcRenderer: typeof ipcRenderer; - - constructor() { - if (this.isElectron()) { - this.ipcRenderer = window.require('electron').ipcRenderer; - } - } - - isElectron(): boolean { - return window && window.process && window.process.type; - } - - clearCookies() { - if (this.isElectron()) { - this.ipcRenderer.invoke('clear-storage'); - } - } - - registerIpcListener(channel: string, callback: (...params) => void) { - if (this.isElectron()) { - this.ipcRenderer.on(channel, callback); - } - } - - sendIpcMessage(channel: string, ...messages: any) { - if (this.isElectron()) { - this.ipcRenderer.send(channel, messages); - } - } - - removeIpcListeners(channel: string) { - if (this.isElectron()) { - this.ipcRenderer.removeAllListeners(channel); - } - } - - openLink(address: string) { - if (this.isElectron()) { - this.ipcRenderer.invoke('open-link', address); - } else { - window.open(address); - } - } -} diff --git a/src/app/core/services/error-handling.service.ts b/src/app/core/services/error-handling.service.ts index 50475477..c2329c66 100644 --- a/src/app/core/services/error-handling.service.ts +++ b/src/app/core/services/error-handling.service.ts @@ -1,6 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; import { ErrorHandler, Injectable } from '@angular/core'; -import { MatSnackBar } from '@angular/material'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { RequestError } from '@octokit/request-error'; import { FormErrorComponent } from '../../shared/error-toasters/form-error/form-error.component'; import { GeneralMessageErrorComponent } from '../../shared/error-toasters/general-message-error/general-message-error.component'; diff --git a/src/app/core/services/factories/factory.auth.service.ts b/src/app/core/services/factories/factory.auth.service.ts index d56003b7..21476b48 100644 --- a/src/app/core/services/factories/factory.auth.service.ts +++ b/src/app/core/services/factories/factory.auth.service.ts @@ -3,25 +3,27 @@ import { Title } from '@angular/platform-browser'; import { Router } from '@angular/router'; // import { AppConfig } from '../../../../environments/environment'; import { AuthService } from '../auth.service'; -import { ElectronService } from '../electron.service'; +import { ErrorHandlingService } from '../error-handling.service'; import { GithubService } from '../github.service'; import { GithubEventService } from '../githubevent.service'; import { IssueService } from '../issue.service'; +import { LabelService } from '../label.service'; import { LoggingService } from '../logging.service'; // import { MockAuthService } from '../mocks/mock.auth.service'; import { PhaseService } from '../phase.service'; import { UserService } from '../user.service'; export function AuthServiceFactory( - electronService: ElectronService, router: Router, ngZone: NgZone, githubService: GithubService, userService: UserService, issueService: IssueService, + labelService: LabelService, phaseService: PhaseService, githubEventService: GithubEventService, titleService: Title, + errorHandlingService: ErrorHandlingService, logger: LoggingService ) { // TODO: Write Mocks @@ -32,6 +34,7 @@ export function AuthServiceFactory( // githubService, // userService, // issueService, + // labelService, // phaseService, // githubEventService, // titleService, @@ -40,15 +43,16 @@ export function AuthServiceFactory( // } console.log(logger); return new AuthService( - electronService, router, ngZone, githubService, userService, issueService, + labelService, phaseService, githubEventService, titleService, + errorHandlingService, logger - ); + ); } diff --git a/src/app/core/services/factories/factory.github.service.ts b/src/app/core/services/factories/factory.github.service.ts index f72354c6..7a276a0f 100644 --- a/src/app/core/services/factories/factory.github.service.ts +++ b/src/app/core/services/factories/factory.github.service.ts @@ -1,15 +1,14 @@ import { Apollo } from 'apollo-angular'; // import { AppConfig } from '../../../../environments/environment'; -import { ElectronService } from '../electron.service'; import { ErrorHandlingService } from '../error-handling.service'; import { GithubService } from '../github.service'; import { LoggingService } from '../logging.service'; // import { MockGithubService } from '../mocks/mock.github.service'; -export function GithubServiceFactory(handling: ErrorHandlingService, apollo: Apollo, electron: ElectronService, logger: LoggingService) { +export function GithubServiceFactory(handling: ErrorHandlingService, apollo: Apollo, logger: LoggingService) { // TODO: Write Mocks // if (AppConfig.test) { // return new MockGithubService(); // } - return new GithubService(handling, apollo, electron, logger); + return new GithubService(handling, apollo, logger); } diff --git a/src/app/core/services/factories/factory.issue.service.ts b/src/app/core/services/factories/factory.issue.service.ts index f4d1daf3..da4a6439 100644 --- a/src/app/core/services/factories/factory.issue.service.ts +++ b/src/app/core/services/factories/factory.issue.service.ts @@ -1,20 +1,14 @@ // import { AppConfig } from '../../../../environments/environment'; -import { ElectronService } from '../electron.service'; import { GithubService } from '../github.service'; import { IssueService } from '../issue.service'; // import { MockIssueService } from '../mocks/mock.issue.service'; import { PhaseService } from '../phase.service'; import { UserService } from '../user.service'; -export function IssueServiceFactory( - githubService: GithubService, - userService: UserService, - phaseService: PhaseService, - electronService: ElectronService -) { +export function IssueServiceFactory(githubService: GithubService, userService: UserService, phaseService: PhaseService) { // TODO: Write Mocks // if (AppConfig.test) { // return new MockIssueService(githubService, phaseService, dataService); // } - return new IssueService(githubService, userService, phaseService, electronService); + return new IssueService(githubService, userService, phaseService); } diff --git a/src/app/core/services/github.service.ts b/src/app/core/services/github.service.ts index 34f83759..1480ac21 100644 --- a/src/app/core/services/github.service.ts +++ b/src/app/core/services/github.service.ts @@ -3,25 +3,21 @@ import { Injectable } from '@angular/core'; import { Apollo, QueryRef } from 'apollo-angular'; import { ApolloQueryResult } from 'apollo-client'; import { DocumentNode } from 'graphql'; -import { forkJoin, from, Observable, of, throwError, zip } from 'rxjs'; +import { BehaviorSubject, forkJoin, from, merge, Observable, of, throwError } from 'rxjs'; import { catchError, filter, flatMap, map, throwIfEmpty } from 'rxjs/operators'; import { FetchIssue, FetchIssueQuery, FetchIssues, - FetchIssuesByTeam, - FetchIssuesByTeamQuery, FetchIssuesQuery, FetchPullRequests, FetchPullRequestsQuery } from '../../../../graphql/graphql-types'; import { AppConfig } from '../../../environments/environment'; import { getNumberOfPages } from '../../shared/lib/github-paginator-parser'; -import { IssueComment } from '../models/comment.model'; import { GithubUser } from '../models/github-user.model'; import { IssueLastModifiedManagerModel } from '../models/github/cache-manager/issue-last-modified-manager.model'; import { IssuesCacheManager } from '../models/github/cache-manager/issues-cache-manager.model'; -import { GithubComment } from '../models/github/github-comment.model'; import { GithubEvent } from '../models/github/github-event.model'; import { GithubGraphqlIssue } from '../models/github/github-graphql.issue'; import { GithubGraphqlIssueOrPr } from '../models/github/github-graphql.issue-or-pr'; @@ -30,7 +26,6 @@ import { GithubIssue } from '../models/github/github-issue.model'; import { GithubResponse } from '../models/github/github-response.model'; import { GithubRelease } from '../models/github/github.release'; import { SessionData } from '../models/session.model'; -import { ElectronService } from './electron.service'; import { ERRORCODE_NOT_FOUND, ErrorHandlingService } from './error-handling.service'; import { LoggingService } from './logging.service'; @@ -67,14 +62,9 @@ export class GithubService { private issuesCacheManager = new IssuesCacheManager(); private issuesLastModifiedManager = new IssueLastModifiedManagerModel(); - private issueQueryRefs = new Map>(); + private issueQueryRefs = new Map>(); - constructor( - private errorHandlingService: ErrorHandlingService, - private apollo: Apollo, - private electronService: ElectronService, - private logger: LoggingService - ) {} + constructor(private errorHandlingService: ErrorHandlingService, private apollo: Apollo, private logger: LoggingService) {} storeOAuthAccessToken(accessToken: string) { octokit = new Octokit({ @@ -112,37 +102,6 @@ export class GithubService { ORG_NAME = phaseRepoOwner; } - /** - * Fetches an array of filtered GitHubIssues using GraphQL query for a given team. - * - * @param tutorial - The tutorial that the team belongs to. - * @param team - The team's designated name. - * @param issuesFilter - The issue filter. - * @returns An observable array of filtered GithubIssues - */ - fetchIssuesGraphqlByTeam(tutorial: string, team: string, issuesFilter: RestGithubIssueFilter): Observable> { - const graphqlFilter = issuesFilter.convertToGraphqlFilter(); - return this.toFetchIssues(issuesFilter).pipe( - filter((toFetch) => toFetch), - flatMap(() => { - return this.fetchGraphqlList( - FetchIssuesByTeam, - { - owner: ORG_NAME, - name: REPO, - filter: { - ...graphqlFilter, - labels: [...(graphqlFilter.labels ? graphqlFilter.labels : []), team] - }, - tutorial - }, - (result) => result.data.repository.label.issues.edges, - GithubGraphqlIssue - ); - }) - ); - } - /** * Fetches an array of filtered GitHubIssues using GraphQL query. * In WATcher, this includes pull requests. @@ -179,7 +138,7 @@ export class GithubService { ); // Concatenate both streams together. - return zip(issueObs, prObs).pipe(map((x) => x[0].concat(x[1]))); + return merge(issueObs, prObs); } /** @@ -189,6 +148,8 @@ export class GithubService { * @returns Observable that returns true if there are pages that do not exist in the cache model. */ private toFetchIssues(filter: RestGithubIssueFilter): Observable { + const pageFetchLimit = 100; + let responseInFirstPage: GithubResponse; return this.getIssuesAPICall(filter, 1).pipe( map((response: GithubResponse) => { @@ -197,6 +158,9 @@ export class GithubService { }), flatMap((numOfPages: number) => { const apiCalls: Observable>[] = []; + if (numOfPages > pageFetchLimit) { + throw new Error(`Repository has too many pages (${numOfPages}), not supported.`); + } for (let i = 2; i <= numOfPages; i++) { apiCalls.push(this.getIssuesAPICall(filter, i)); } @@ -226,20 +190,10 @@ export class GithubService { catchError((err) => { return of(false); }), - catchError((err) => throwError('Failed to fetch repo data.')) + catchError((err) => throwError('Failed to fetch repository data.')) ); } - /** - * Creates a repository in for the authenticated user location. - * @param name - Name of Repo to create. - * @return Observable - That returns true if the repository has been successfully - * created. - */ - createRepository(name: string): void { - octokit.repos.createForAuthenticatedUser({ name: name }); - } - /** * Fetches information about an issue using GraphQL. * @@ -329,24 +283,6 @@ export class GithubService { ); } - /** - * Creates a label in the current repository. - * @param formattedLabelName - name of new label. - * @param labelColor - colour of new label. - */ - createLabel(formattedLabelName: string, labelColor: string): void { - octokit.issues.createLabel({ owner: ORG_NAME, repo: REPO, name: formattedLabelName, color: labelColor }); - } - - /** - * Updates a label's information in the current repository. - * @param labelName - name of existing label - * @param labelColor - new color to be assigned to existing label. - */ - updateLabel(labelName: string, labelColor: string): void { - octokit.issues.updateLabel({ owner: ORG_NAME, repo: REPO, name: labelName, current_name: labelName, color: labelColor }); - } - /** * Checks if the given list of users are allowed to be assigned to an issue. * @param assignees - GitHub usernames to be checked @@ -379,76 +315,7 @@ export class GithubService { map((response) => { return response['data']; }), - catchError((err) => throwError('Failed to fetch assignable users for repo.')) - ); - } - - closeIssue(id: number): Observable { - return from(octokit.issues.update({ owner: ORG_NAME, repo: REPO, issue_number: id, state: 'closed' })).pipe( - map((response: GithubResponse) => { - this.issuesLastModifiedManager.set(id, response.headers['last-modified']); - return new GithubIssue(response.data); - }) - ); - } - - createIssue(title: string, description: string, labels: string[]): Observable { - return from(octokit.issues.create({ owner: ORG_NAME, repo: REPO, title: title, body: description, labels: labels })).pipe( - map((response: GithubResponse) => { - return new GithubIssue(response.data); - }) - ); - } - - createIssueComment(issueId: number, description: string): Observable { - return from(octokit.issues.createComment({ owner: ORG_NAME, repo: REPO, issue_number: issueId, body: description })).pipe( - map((response: GithubResponse) => { - return response.data; - }) - ); - } - - updateIssue(id: number, title: string, description: string, labels: string[], assignees?: string[]): Observable { - return from( - octokit.issues.update({ - owner: ORG_NAME, - repo: REPO, - issue_number: id, - title: title, - body: description, - labels: labels, - assignees: assignees - }) - ).pipe( - map((response: GithubResponse) => { - this.issuesLastModifiedManager.set(id, response.headers['last-modified']); - return new GithubIssue(response.data); - }), - catchError((err) => { - return throwError(err); - }) - ); - } - - updateIssueComment(issueComment: IssueComment): Observable { - return from( - octokit.issues.updateComment({ owner: ORG_NAME, repo: REPO, comment_id: issueComment.id, body: issueComment.description }) - ).pipe( - map((response: GithubResponse) => { - return response.data; - }) - ); - } - - uploadFile(filename: string, base64String: string): Observable { - return from( - octokit.repos.createOrUpdateFile({ - owner: ORG_NAME, - repo: REPO, - path: `files/${filename}`, - message: 'upload file', - content: base64String - }) + catchError((err) => throwError('Failed to fetch assignable users for repository')) ); } @@ -457,7 +324,7 @@ export class GithubService { map((response) => { return response['data']; }), - catchError((err) => throwError('Failed to fetch events for repo.')) + catchError((err) => throwError('Failed to fetch issue events for repository')) ); } @@ -468,7 +335,7 @@ export class GithubService { repo: REPO, page: pageNumber }) - ).pipe(catchError((err) => throwError('Failed to fetch events for repo.'))); + ).pipe(catchError((err) => throwError('Failed to fetch activity events for repository'))); } /** @@ -550,7 +417,7 @@ export class GithubService { viewIssueInBrowser(id: number, event: Event) { if (id) { - this.electronService.openLink('https://github.com/'.concat(this.getRepoURL()).concat('/issues/').concat(String(id))); + window.open('https://github.com/'.concat(this.getRepoURL()).concat('/issues/').concat(String(id))); } else { this.errorHandlingService.handleError(new Error(UNABLE_TO_OPEN_IN_BROWSER)); } @@ -621,8 +488,8 @@ export class GithubService { pluckEdges: (results: ApolloQueryResult) => Array, Model: new (data) => M ): Observable> { - return from(this.withPagination(pluckEdges)(query, variables)).pipe( - map((results: Array>) => { + return this.withPagination(pluckEdges, query, variables, false).pipe( + map((results: ApolloQueryResult[]) => { const issues = results.reduce((accumulated, current) => accumulated.concat(pluckEdges(current)), []); return issues.map((issue) => new Model(issue.node)); }), @@ -633,32 +500,57 @@ export class GithubService { } /** - * Returns an async function that will accept a GraphQL query that requests for paginated items. - * Said function will recursively query for all subsequent pages until a page that has less than 100 items is found, - * then return all queried pages in an array. + * Returns an observable that will continually emit the currently accumulated results, until a page that has less + * than 100 items is found, after which it performs a final emit with the full results array, and completes. + * + * If `shouldAccumulate` is false, the observable will emit only the latest result, it will still complete on the + * same condition. * * @callback pluckEdges - A function that returns a list of edges in a ApolloQueryResult. - * @returns an async function that accepts a GraphQL query for paginated data and any additional variables to that query + * @params query - The query to be performed. + * @params variables - The variables for the query. + * @params shouldAccumulate - Whether the observable should accumulate the results. + * @returns an observable */ - private withPagination(pluckEdges: (results: ApolloQueryResult) => Array) { - return async (query: DocumentNode, variables: { [key: string]: any } = {}): Promise>> => { - const maxResultsCount = 100; - const cursor = variables.cursor || null; - const graphqlQuery = this.apollo.watchQuery({ query, variables: { ...variables, cursor } }); - return graphqlQuery.refetch().then(async (results: ApolloQueryResult) => { + private withPagination( + pluckEdges: (results: ApolloQueryResult) => Array, + query: DocumentNode, + variables: { [key: string]: any } = {}, + shouldAccumulate: boolean = true + ): Observable[]> { + const maxResultsCount = 100; + const apollo = this.apollo; + + let accumulatedResults: ApolloQueryResult[] = []; + const behaviorSubject: BehaviorSubject[]> = new BehaviorSubject(accumulatedResults); + + async function queryWith(cursor: string): Promise { + const graphqlQuery = apollo.watchQuery({ query, variables: { ...variables, cursor } }); + + await graphqlQuery.refetch().then(async (results: ApolloQueryResult) => { const intermediate = Array.isArray(results) ? results : [results]; const edges = pluckEdges(results); const nextCursor = edges.length === 0 ? null : edges[edges.length - 1].cursor; + if (shouldAccumulate) { + accumulatedResults = accumulatedResults.concat(intermediate); + behaviorSubject.next(accumulatedResults); + } else { + behaviorSubject.next(intermediate); + } if (edges.length < maxResultsCount || !nextCursor) { - return intermediate; + // No more queries to perform. + behaviorSubject.complete(); + return; } - const nextResults = await this.withPagination(pluckEdges)(query, { - ...variables, - cursor: nextCursor - }); - return intermediate.concat(nextResults); + + // Use a chain of await to ensure that all recursive queries are completed before `complete` is called. + await queryWith(nextCursor); }); - }; + } + + queryWith(null); + + return behaviorSubject.asObservable(); } } diff --git a/src/app/core/services/issue.service.ts b/src/app/core/services/issue.service.ts index 1722fcbd..4f001bef 100644 --- a/src/app/core/services/issue.service.ts +++ b/src/app/core/services/issue.service.ts @@ -1,15 +1,10 @@ import { Injectable } from '@angular/core'; -import { BehaviorSubject, EMPTY, forkJoin, Observable, of, Subscription, throwError, timer } from 'rxjs'; -import { catchError, exhaustMap, finalize, flatMap, map } from 'rxjs/operators'; -import { IssueComment } from '../models/comment.model'; -import { GithubComment } from '../models/github/github-comment.model'; +import { BehaviorSubject, Observable, of, Subscription, throwError, timer } from 'rxjs'; +import { catchError, exhaustMap, finalize, map } from 'rxjs/operators'; import RestGithubIssueFilter from '../models/github/github-issue-filter.model'; import { GithubIssue } from '../models/github/github-issue.model'; -import { HiddenData } from '../models/hidden-data.model'; -import { Issue, Issues, IssuesFilter, STATUS } from '../models/issue.model'; +import { Issue, Issues, IssuesFilter } from '../models/issue.model'; import { Phase } from '../models/phase.model'; -import { appVersion } from './application.service'; -import { ElectronService } from './electron.service'; import { GithubService } from './github.service'; import { PhaseService } from './phase.service'; import { UserService } from './user.service'; @@ -34,12 +29,7 @@ export class IssueService { /** Whether the IssueService is downloading the data from Github*/ public isLoading = new BehaviorSubject(false); - constructor( - private githubService: GithubService, - private userService: UserService, - private phaseService: PhaseService, - private electronService: ElectronService - ) { + constructor(private githubService: GithubService, private userService: UserService, private phaseService: PhaseService) { this.issues$ = new BehaviorSubject(new Array()); } @@ -53,9 +43,7 @@ export class IssueService { .pipe( exhaustMap(() => { return this.reloadAllIssues().pipe( - catchError(() => { - return EMPTY; - }), + catchError((err) => throwError(err)), finalize(() => this.isLoading.next(false)) ); }) @@ -71,28 +59,6 @@ export class IssueService { } } - /** - * Will constantly poll and update the application's state's with the updated issue. - * - * @param issueId - The issue's id to poll for. - */ - pollIssue(issueId: number): Observable { - return timer(0, IssueService.POLL_INTERVAL).pipe( - exhaustMap(() => { - return this.githubService.fetchIssueGraphql(issueId).pipe( - map((response) => { - const issue = this.createIssueModel(response); - this.updateLocalStore(issue); - return issue; - }), - catchError((err) => { - return this.getIssue(issueId); - }) - ); - }) - ); - } - reloadAllIssues() { return this.initializeData(); } @@ -108,7 +74,7 @@ export class IssueService { getLatestIssue(id: number): Observable { return this.githubService.fetchIssueGraphql(id).pipe( map((response: GithubIssue) => { - this.createAndSaveIssueModel(response); + this.createAndSaveIssueModels([response]); return this.issues[id]; }), catchError((err) => { @@ -117,157 +83,21 @@ export class IssueService { ); } - createIssue(title: string, description: string, severity: string, type: string): Observable { - const labelsArray = [this.createLabel('severity', severity), this.createLabel('type', type)]; - const clientType = this.electronService.isElectron() ? 'Desktop' : 'Web'; - const hiddenData = new Map([ - ['session', this.sessionId], - ['Version', `${clientType} v${appVersion}`] - ]); - const issueDescription = HiddenData.embedDataIntoString(description, hiddenData); - return this.githubService - .createIssue(title, issueDescription, labelsArray) - .pipe(map((response: GithubIssue) => this.createIssueModel(response))); - } - - updateIssueWithAssigneeCheck(issue: Issue): Observable { - const assignees = issue.assignees; - return this.githubService.areUsersAssignable(assignees).pipe(flatMap(() => this.updateIssue(issue))); - } - - updateIssue(issue: Issue): Observable { - const assignees = issue.assignees; - return this.githubService - .updateIssue(issue.id, issue.title, this.createGithubIssueDescription(issue), this.createLabelsForIssue(issue), assignees) - .pipe( - map((response: GithubIssue) => { - response.comments = issue.githubComments; - return this.createIssueModel(response); - }) - ); - } - - updateIssueWithComment(issue: Issue, issueComment: IssueComment): Observable { - return this.githubService.updateIssueComment(issueComment).pipe( - flatMap((updatedComment: GithubComment) => { - issue.githubComments = [updatedComment, ...issue.githubComments.filter((c) => c.id !== updatedComment.id)]; - return this.updateIssue(issue); - }) - ); - } - - updateTesterResponse(issue: Issue, issueComment: IssueComment): Observable { - const isTesterResponseExist = this.issues[issue.id].testerResponses; - const commentApiToCall = isTesterResponseExist - ? this.githubService.updateIssueComment(issueComment) - : this.githubService.createIssueComment(issue.id, issueComment.description); - - const issueClone = issue.clone(this.phaseService.currentPhase); - issueClone.status = STATUS.Done; - - return forkJoin([commentApiToCall, this.updateIssue(issueClone)]).pipe( - map((responses) => { - const [githubComment, issue] = responses; - issue.updateTesterResponse(githubComment); - return issue; - }) - ); - } - - updateTutorResponse(issue: Issue, issueComment: IssueComment): Observable { - return forkJoin([this.githubService.updateIssueComment(issueComment), this.updateIssue(issue)]).pipe( - map((responses) => { - const [githubComment, issue] = responses; - issue.updateDispute(githubComment); - return issue; - }) - ); - } - - createTeamResponse(issue: Issue): Observable { - const teamResponse = issue.createGithubTeamResponse(); - return this.githubService.areUsersAssignable(issue.assignees || []).pipe( - flatMap(() => - this.githubService.createIssueComment(issue.id, teamResponse).pipe( - flatMap((githubComment: GithubComment) => { - issue.githubComments = [githubComment, ...issue.githubComments.filter((c) => c.id !== githubComment.id)]; - return this.updateIssue(issue); - }) - ) - ), - catchError((err) => throwError(err)) - ); - } - - createTutorResponse(issue: Issue, response: string): Observable { - return forkJoin([this.githubService.createIssueComment(issue.id, response), this.updateIssue(issue)]).pipe( - map((responses) => { - const [githubComment, issue] = responses; - issue.updateDispute(githubComment); - return issue; - }) - ); - } - /** - * This function will create a github representation of issue's description. Given the issue model, it will piece together the different - * attributes to create the github's description. + * This function will update the issue's state of the application. This function needs to be called whenever a issue is added/updated. * + * @params issuesToUpdate - An array of issues to update the state of the application with. */ - private createGithubIssueDescription(issue: Issue): string { - return issue.createGithubIssueDescription(); - } - - deleteIssue(id: number): Observable { - return this.githubService.closeIssue(id).pipe( - map((response: GithubIssue) => { - const deletedIssue = this.createIssueModel(response); - this.deleteFromLocalStore(deletedIssue); - return deletedIssue; - }) - ); - } - - /** - * This function will update the issue's state of the application. This function needs to be called whenever a issue is deleted. - */ - deleteFromLocalStore(issueToDelete: Issue) { - const { [issueToDelete.id]: issueToRemove, ...withoutIssueToRemove } = this.issues; - this.issues = withoutIssueToRemove; - this.issues$.next(Object.values(this.issues)); - } + private updateLocalStore(issuesToUpdate: Issue[]) { + const newIssues = { ...this.issues }; + issuesToUpdate.forEach((issue) => { + newIssues[issue.id] = issue; + }); + this.issues = newIssues; - /** - * This function will update the issue's state of the application. This function needs to be called whenever a issue is added/updated. - */ - updateLocalStore(issueToUpdate: Issue) { - this.issues = { - ...this.issues, - [issueToUpdate.id]: issueToUpdate - }; this.issues$.next(Object.values(this.issues)); } - /** - * Check whether the issue has been responded in the phase 2/3. - */ - hasTeamResponse(issueId: number): boolean { - return !!this.issues[issueId].teamResponse; - } - - /** - * Obtain an observable containing an array of issues that are duplicates of the parentIssue. - */ - getDuplicateIssuesFor(parentIssue: Issue): Observable { - return this.issues$.pipe( - map((issues) => { - return issues.filter((issue) => { - return issue.duplicateOf === parentIssue.id; - }); - }) - ); - } - reset(resetSessionId: boolean) { if (resetSessionId) { this.sessionId = undefined; @@ -277,86 +107,73 @@ export class IssueService { this.issues$.next(new Array()); this.stopPollIssues(); - this.isLoading.complete(); - this.isLoading = new BehaviorSubject(false); } private initializeData(): Observable { - const issuesAPICallsByFilter: Array>> = []; + let issuesAPICallsByFilter: Observable>; switch (IssuesFilter[this.phaseService.currentPhase][this.userService.currentUser.role]) { case 'FILTER_BY_CREATOR': - issuesAPICallsByFilter.push( - this.githubService.fetchIssuesGraphql(new RestGithubIssueFilter({ creator: this.userService.currentUser.loginId })) + issuesAPICallsByFilter = this.githubService.fetchIssuesGraphql( + new RestGithubIssueFilter({ creator: this.userService.currentUser.loginId }) ); break; - case 'FILTER_BY_TEAM': // Only student has this filter - issuesAPICallsByFilter.push( - this.githubService.fetchIssuesGraphqlByTeam( - this.createLabel('tutorial', this.userService.currentUser.team.tutorialClassId), - this.createLabel('team', this.userService.currentUser.team.teamId), - new RestGithubIssueFilter({}) - ) - ); - break; - case 'FILTER_BY_TEAM_ASSIGNED': // Only for Tutors and Admins - const allocatedTeams = this.userService.currentUser.allocatedTeams; - allocatedTeams.forEach((team) => { - issuesAPICallsByFilter.push( - this.githubService.fetchIssuesGraphqlByTeam( - this.createLabel('tutorial', team.tutorialClassId), - this.createLabel('team', team.teamId), - new RestGithubIssueFilter({}) - ) - ); - }); - break; case 'NO_FILTER': - issuesAPICallsByFilter.push(this.githubService.fetchIssuesGraphql(new RestGithubIssueFilter({}))); + issuesAPICallsByFilter = this.githubService.fetchIssuesGraphql(new RestGithubIssueFilter({})); break; case 'NO_ACCESS': default: return of([]); } - // const issuesAPICallsByFilter = filters.map(filter => this.githubService.fetchIssuesGraphql(filter)); - return forkJoin(issuesAPICallsByFilter).pipe( - map((issuesByFilter: [][]) => { - const fetchedIssueIds: Array = []; - - // Take each issue and put it in next in issues$ - for (const issues of issuesByFilter) { - for (const issue of issues) { - fetchedIssueIds.push(this.createIssueModel(issue).id); - this.createAndSaveIssueModel(issue); - } + const fetchedIssueIds: number[] = []; + + return issuesAPICallsByFilter.pipe( + map((githubIssues: GithubIssue[]) => { + const issues = this.createAndSaveIssueModels(githubIssues); + for (const issue of issues) { + fetchedIssueIds.push(issue.id); } - const outdatedIssueIds: Array = this.getOutdatedIssueIds(fetchedIssueIds); + const outdatedIssueIds: number[] = this.getOutdatedIssueIds(fetchedIssueIds); this.deleteIssuesFromLocalStore(outdatedIssueIds); + if (this.issues === undefined) { + return []; + } return Object.values(this.issues); }) ); } - private createAndSaveIssueModel(githubIssue: GithubIssue): boolean { - const issue = this.createIssueModel(githubIssue); - this.updateLocalStore(issue); - return true; + private createAndSaveIssueModels(githubIssues: GithubIssue[]): Issue[] { + const issues: Issue[] = []; + + for (const githubIssue of githubIssues) { + const issue = this.createIssueModel(githubIssue); + issues.push(issue); + } + this.updateLocalStore(issues); + + return issues; } - private deleteIssuesFromLocalStore(ids: Array): void { - ids.forEach((id: number) => { - this.getIssue(id).subscribe((issue) => this.deleteFromLocalStore(issue)); - }); + private deleteIssuesFromLocalStore(ids: number[]): void { + const withoutIssuesToRemove = { ...this.issues }; + for (const id of ids) { + delete withoutIssuesToRemove[id]; + } + + this.issues = withoutIssuesToRemove; + + this.issues$.next(Object.values(this.issues)); } /** * Returns an array of outdated issue ids by comparing the ids of the recently * fetched issues with the current issue ids in the local store */ - private getOutdatedIssueIds(fetchedIssueIds: Array): Array { + private getOutdatedIssueIds(fetchedIssueIds: number[]): number[] { /* Ignore for first fetch or ignore if there is no fetch result @@ -368,7 +185,7 @@ export class IssueService { return []; } - const fetchedIssueIdsSet = new Set(fetchedIssueIds); + const fetchedIssueIdsSet = new Set(fetchedIssueIds); const result = Object.keys(this.issues) .map((x) => +x) @@ -377,54 +194,6 @@ export class IssueService { return result; } - /** - * Given an issue model, create the necessary labels for github. - */ - private createLabelsForIssue(issue: Issue): string[] { - const result = []; - - if (this.phaseService.currentPhase !== Phase.issuesViewer) { - const studentTeam = issue.teamAssigned.id.split('-'); - result.push(this.createLabel('tutorial', `${studentTeam[0]}-${studentTeam[1]}`), this.createLabel('team', studentTeam[2])); - } - - if (issue.severity) { - result.push(this.createLabel('severity', issue.severity)); - } - - if (issue.type) { - result.push(this.createLabel('type', issue.type)); - } - - if (issue.responseTag) { - result.push(this.createLabel('response', issue.responseTag)); - } - - if (issue.duplicated) { - result.push('duplicate'); - } - - if (issue.status) { - result.push(this.createLabel('status', issue.status)); - } - - if (issue.pending) { - if (+issue.pending > 0) { - result.push(this.createLabel('pending', issue.pending)); - } - } - - if (issue.unsure) { - result.push('unsure'); - } - - return result; - } - - private createLabel(prepend: string, value: string) { - return `${prepend}.${value}`; - } - private createIssueModel(githubIssue: GithubIssue): Issue { switch (this.phaseService.currentPhase) { case Phase.issuesViewer: diff --git a/src/app/core/services/label.service.ts b/src/app/core/services/label.service.ts index 77e107e4..edeac7d7 100644 --- a/src/app/core/services/label.service.ts +++ b/src/app/core/services/label.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { Label } from '../models/label.model'; +import { BehaviorSubject, EMPTY, Observable, of, Subscription, timer } from 'rxjs'; +import { catchError, exhaustMap, finalize, map } from 'rxjs/operators'; +import { Label, SimpleLabel } from '../models/label.model'; import { GithubService } from './github.service'; /* The threshold to decide if color is dark or light. @@ -22,10 +22,46 @@ const COLOR_WHITE = 'ffffff'; // Light color for text with dark background * from the GitHub repository for the WATcher application. */ export class LabelService { + static readonly POLL_INTERVAL = 5000; // 5 seconds + labels: Label[]; + simpleLabels: SimpleLabel[]; + + private labelsPollSubscription: Subscription; + private labelsSubject = new BehaviorSubject([]); constructor(private githubService: GithubService) {} + startPollLabels() { + if (this.labelsPollSubscription) { + return; + } + this.labelsPollSubscription = timer(0, LabelService.POLL_INTERVAL) + .pipe( + exhaustMap(() => { + return this.fetchLabels().pipe( + catchError(() => { + return EMPTY; + }) + ); + }) + ) + .subscribe(() => { + this.labelsSubject.next(this.simpleLabels); + }); + } + + stopPollLabels() { + if (this.labelsPollSubscription) { + this.labelsPollSubscription.unsubscribe(); + this.labelsPollSubscription = undefined; + } + } + + connect(): Observable { + return this.labelsSubject.asObservable(); + } + /** * Fetch labels from Github. */ @@ -33,6 +69,8 @@ export class LabelService { return this.githubService.fetchAllLabels().pipe( map((response) => { this.labels = this.parseLabelData(response); + this.simpleLabels = this.labels; + this.labelsSubject.next(this.simpleLabels); return response; }) ); @@ -92,4 +130,10 @@ export class LabelService { return styles; } + + reset() { + this.labels = undefined; + this.simpleLabels = undefined; + this.stopPollLabels(); + } } diff --git a/src/app/core/services/logging.service.ts b/src/app/core/services/logging.service.ts index 1ca220ee..34ecdf83 100644 --- a/src/app/core/services/logging.service.ts +++ b/src/app/core/services/logging.service.ts @@ -1,8 +1,6 @@ import { Injectable } from '@angular/core'; -import { ElectronLog } from 'electron-log'; import { AppConfig } from '../../../environments/environment'; import { downloadAsTextFile } from '../../shared/lib/file-download'; -import { ElectronService } from './electron.service'; @Injectable({ providedIn: 'root' @@ -13,7 +11,7 @@ import { ElectronService } from './electron.service'; * running to ease debugging for WATcher developers and maintainers. */ export class LoggingService { - private logger: ElectronLog | Console; + private logger: Console; private isInSession = false; private readonly LOG_KEY = 'WATcher-Log'; private readonly LOG_FILE_NAME = 'WATcher-log.txt'; @@ -21,13 +19,8 @@ export class LoggingService { public readonly LOG_COUNT_LIMIT = 4; public readonly SESSION_LOG_SEPARATOR = '\n'.repeat(2); // More new-lines added for clarity. - constructor(electronService: ElectronService) { - if (electronService.isElectron()) { - this.logger = window.require('electron-log'); - } else { - this.logger = console; - } - + constructor() { + this.logger = console; this.startSession(); } diff --git a/src/app/core/services/milestone.service.ts b/src/app/core/services/milestone.service.ts index 2de09706..7701f487 100644 --- a/src/app/core/services/milestone.service.ts +++ b/src/app/core/services/milestone.service.ts @@ -14,6 +14,7 @@ import { GithubService } from './github.service'; */ export class MilestoneService { milestones: Milestone[]; + hasNoMilestones: boolean; constructor(private githubService: GithubService) {} @@ -24,6 +25,7 @@ export class MilestoneService { return this.githubService.fetchAllMilestones().pipe( map((response) => { this.milestones = this.parseMilestoneData(response); + this.hasNoMilestones = response.length === 0; return response; }) ); @@ -39,6 +41,10 @@ export class MilestoneService { for (const milestone of milestones) { milestoneData.push(new Milestone(milestone)); } + milestoneData.sort((a: Milestone, b: Milestone) => a.title.localeCompare(b.title)); + + // add default milestone for untracked issues/PRs at the end + milestoneData.push(Milestone.DefaultMilestone); return milestoneData; } } diff --git a/src/app/core/services/phase.service.ts b/src/app/core/services/phase.service.ts index ac4eb3c4..196aa43c 100644 --- a/src/app/core/services/phase.service.ts +++ b/src/app/core/services/phase.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@angular/core'; -import { Observable, Subject } from 'rxjs'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { Phase } from '../models/phase.model'; import { Repo } from '../models/repo.model'; import { SessionData } from '../models/session.model'; import { GithubService } from './github.service'; import { LoggingService } from './logging.service'; +import { RepoUrlCacheService } from './repo-url-cache.service'; export const SESSION_AVALIABILITY_FIX_FAILED = 'Session Availability Fix failed.'; @@ -44,6 +45,9 @@ export class PhaseService { public currentRepo: Repo; // current or main repository of current phase public otherRepos: Repo[]; // more repositories relevant to this phase + repoSetSource = new BehaviorSubject(false); + repoSetState = this.repoSetSource.asObservable(); + /** * Expose an observable to track changes to currentRepo * @@ -52,9 +56,15 @@ export class PhaseService { */ public repoChanged$: Subject = new Subject(); + /** Whether the PhaseService is changing the repository */ + public isChangingRepo = new BehaviorSubject(false); + public sessionData = STARTING_SESSION_DATA; // stores session data for the session - constructor(private githubService: GithubService, public logger: LoggingService) {} + constructor( + private githubService: GithubService, + private repoUrlCacheService: RepoUrlCacheService, + public logger: LoggingService) {} /** * Sets the current main repository and additional repos if any. @@ -73,20 +83,39 @@ export class PhaseService { /** * Changes current respository to a new repository. - * If on Issue Dashboard, add previously visited repositories to otherRepos. * @param repo New current repository */ - changeCurrentRepository(repo: Repo): void { + private changeCurrentRepository(repo: Repo): void { this.logger.info(`PhaseService: Changing current repository to '${repo}'`); if (this.currentPhase === Phase.issuesViewer) { /** Adds past repositories to phase */ - this.otherRepos.push(this.currentRepo); // TODO feature: can be used to provide repo suggestions + (this.otherRepos || []).push(this.currentRepo); } this.setRepository(repo, this.otherRepos); + + this.repoUrlCacheService.cache(repo.toString()); + this.repoChanged$.next(repo); } + /** + * Change repository if a valid repository is provided + * @param repo New repository + */ + async changeRepositoryIfValid(repo: Repo) { + this.isChangingRepo.next(true); + + const isValidRepository = await this.githubService.isRepositoryPresent(repo.owner, repo.name).toPromise(); + if (!isValidRepository) { + this.isChangingRepo.next(false); + throw new Error('Invalid repository name. Please check your organisation and repository name.'); + } + + this.changeCurrentRepository(repo); + this.isChangingRepo.next(false); + } + /** * Returns the full repository array of the current feature. */ @@ -97,16 +126,27 @@ export class PhaseService { /** * Retrieves the repository url from local storage and sets to current repository. */ - initializeCurrentRepository() { - const repo = new Repo(window.localStorage.getItem('org'), window.localStorage.getItem('dataRepo')); + async initializeCurrentRepository() { + const org = window.localStorage.getItem('org'); + const repoName = window.localStorage.getItem('dataRepo'); + this.logger.info(`Phase Service: received initial org (${org}) and initial name (${repoName})`); + let repo: Repo; + if (!org || !repoName) { + repo = Repo.ofEmptyRepo(); + } else { + repo = new Repo(org, repoName); + } + const isValidRepository = await this.githubService.isRepositoryPresent(repo.owner, repo.name).toPromise(); + if (!isValidRepository) { + throw new Error('Invalid repository name. Please check your organisation and repository name.'); + } + this.logger.info(`PhaseService: Repo is ${repo}`); this.setRepository(repo); + this.repoSetSource.next(true); } - /** - * Checks if the necessary repository is available. TODO: Future to use to verify setRepository. - */ - verifySessionAvailability(): Observable { - return this.githubService.isRepositoryPresent(this.currentRepo.owner, this.currentRepo.name); + isRepoSet(): boolean { + return this.repoSetSource.getValue(); } /** diff --git a/src/app/core/services/repo-creator.service.ts b/src/app/core/services/repo-creator.service.ts deleted file mode 100644 index 114536cd..00000000 --- a/src/app/core/services/repo-creator.service.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Injectable } from '@angular/core'; -import { MatDialog, MatDialogRef } from '@angular/material'; -import { Observable, of, pipe, UnaryFunction } from 'rxjs'; -import { flatMap, tap } from 'rxjs/operators'; -import { Phase } from '../models/phase.model'; -import { GithubService } from './github.service'; -import { SessionFixConfirmationComponent } from './session-fix-confirmation/session-fix-confirmation.component'; -import { UserService } from './user.service'; - -export const MISSING_REQUIRED_REPO = 'You cannot proceed without the required repository.'; -export const CURRENT_PHASE_REPO_CLOSED = "Current Phase's Repository has not been opened."; -export const BUG_REPORTING_INVALID_ROLE = "'Bug-Reporting Phase's repository initialisation is only available to Students.'"; - -@Injectable({ - providedIn: 'root' -}) -export class RepoCreatorService { - constructor(private githubService: GithubService, private userService: UserService, private repoCreationConfirmationDialog: MatDialog) {} - - /** - * Prompts user to allow WATcher to create a repo, if repo does not - * exist and current phase is the bug reporting phase. - * @param currentPhase the current phase of the session. - * @param phaseRepo the name of the specified repository. - */ - public requestRepoCreationPermissions( - currentPhase: Phase, - phaseRepo: string - ): UnaryFunction, Observable> { - return pipe( - flatMap((isRepoPresent: boolean) => { - if (!isRepoPresent && currentPhase === Phase.issuesViewer) { - return this.openRepoCreationConfirmation(phaseRepo); - } else { - return of(null); - } - }) - ); - } - - /** - * Launches the SessionFixConfirmation Dialog. - * @param phaseRepo the name of the specified repository. - * @return Observable - Representing user's permission grant. - */ - private openRepoCreationConfirmation(phaseRepo: string): Observable { - const dialogRef: MatDialogRef = this.repoCreationConfirmationDialog.open( - SessionFixConfirmationComponent, - { - data: { user: this.userService.currentUser.loginId, repoName: phaseRepo } - } - ); - return dialogRef.afterClosed(); - } - - /** - * Checks if the current phase and current user role match the given permissions - * for the user to create the phase repository if deemed necessary - * @param currentPhase the current phase of the session. - */ - public verifyRepoCreationPermissions(currentPhase: Phase): UnaryFunction, Observable> { - return pipe( - tap((repoCreationPermission: boolean | null) => { - if (repoCreationPermission === null) { - return; - } - - if (repoCreationPermission === false) { - throw new Error(MISSING_REQUIRED_REPO); - } else if (currentPhase !== Phase.issuesViewer) { - throw new Error(CURRENT_PHASE_REPO_CLOSED); - } - }) - ); - } - - /** - * Attempts to create the repository if permissions have been given to do so. - * @param phaseRepo the name of the specified repository. - * @return - Dummy Observable to give the API sometime to propagate if the creation of the new - * repository is needed since the API Call used here does not return any response. - */ - public attemptRepoCreation(phaseRepo: string): UnaryFunction, Observable> { - return pipe( - flatMap((repoCreationPermission: boolean | null) => { - if (repoCreationPermission === null) { - // No Session Fix Necessary - return of(null); - } else { - this.githubService.createRepository(phaseRepo); - return new Observable((subscriber) => { - setTimeout(() => subscriber.next(true), 1000); - }); - } - }) - ); - } - - /** - * Checks if the specified repository has been created. - * @param phaseOwner the user or organization holding the specified repository. - * @param phaseRepo the name of the specified repository. - */ - public verifyRepoCreation(phaseOwner: string, phaseRepo: string): UnaryFunction, Observable> { - return pipe( - flatMap((isFixAttempted: boolean | null) => { - if (!isFixAttempted) { - // If no fix has been attempted, there is no need to verify fix outcome. - return of(true); - } else { - // Verify that Repository has been created if a fix attempt has occurred. - return this.githubService.isRepositoryPresent(phaseOwner, phaseRepo); - } - }) - ); - } -} diff --git a/src/app/core/services/repo-url-cache.service.ts b/src/app/core/services/repo-url-cache.service.ts new file mode 100644 index 00000000..d786d6cc --- /dev/null +++ b/src/app/core/services/repo-url-cache.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { AbstractControl } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class RepoUrlCacheService { + static readonly KEY_NAME = 'suggestions'; + + suggestions: string[]; + + constructor() { + this.suggestions = JSON.parse(window.localStorage.getItem(RepoUrlCacheService.KEY_NAME)) || []; + } + + cache(repo: string): void { + // Update autofill repository URL suggestions in localStorage + if (!this.suggestions.includes(repo)) { + this.suggestions.push(repo); + window.localStorage.setItem(RepoUrlCacheService.KEY_NAME, JSON.stringify(this.suggestions)); + } + } + + getFilteredSuggestions(control: AbstractControl): Observable { + // Ref: https://v10.material.angular.io/components/autocomplete/overview + return control.valueChanges.pipe( + startWith(''), + map((value) => this.suggestions.filter((suggestion) => suggestion.toLowerCase().includes(value.toLowerCase()))) + ); + } +} diff --git a/src/app/core/services/session-fix-confirmation/session-fix-confirmation.component.ts b/src/app/core/services/session-fix-confirmation/session-fix-confirmation.component.ts index c659a7c6..57057c6f 100644 --- a/src/app/core/services/session-fix-confirmation/session-fix-confirmation.component.ts +++ b/src/app/core/services/session-fix-confirmation/session-fix-confirmation.component.ts @@ -1,5 +1,5 @@ import { Component, Inject, OnInit } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; export interface RepositoryData { user: string; diff --git a/src/app/core/services/upload.service.ts b/src/app/core/services/upload.service.ts deleted file mode 100644 index 07048403..00000000 --- a/src/app/core/services/upload.service.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Injectable } from '@angular/core'; -import { throwError } from 'rxjs'; -import { uuid } from '../../shared/lib/uuid'; -import { GithubService } from './github.service'; - -const SUPPORTED_VIDEO_FILE_TYPES = ['mp4', 'mov']; -export const SUPPORTED_FILE_TYPES = [ - 'gif', - 'jpeg', - 'jpg', - 'png', - 'docx', - 'gz', - 'log', - 'pdf', - 'pptx', - 'txt', - 'xlsx', - 'zip', - ...SUPPORTED_VIDEO_FILE_TYPES -]; -export const FILE_TYPE_SUPPORT_ERROR = "We don't support that file type." + ' Try again with ' + SUPPORTED_FILE_TYPES.join(', ') + '.'; -/** - * Returns an error message string for when file exceeds the defined size limit - * @param fileType Canonical name for file, not to be confused with file extension - * @param size Number of MBs - */ -export const getSizeExceedErrorMsg = (fileType: string, size: number): string => `Oops, ${fileType} is too big. Keep it under ${size}MiB.`; - -@Injectable({ - providedIn: 'root' -}) - -/** - * Responsible for upload of media files to the current phase's repository. - */ -export class UploadService { - constructor(private githubService: GithubService) {} - - uploadFile(base64File: string | ArrayBuffer, userFilename: string) { - let base64String: string; - if (base64File instanceof ArrayBuffer) { - base64String = String.fromCharCode.apply(null, new Uint16Array(base64File)); - } else { - base64String = base64File; - } - const fileType = this.getFileExtension(userFilename); - - if (SUPPORTED_FILE_TYPES.includes(fileType.toLowerCase())) { - base64String = base64String.split(',')[1]; - const onlineFilename = uuid(); - return this.githubService.uploadFile(`${onlineFilename}.${fileType}`, base64String); - } else { - return throwError(FILE_TYPE_SUPPORT_ERROR); - } - } - - getFileExtension(fileName: string): string { - return fileName.split('.').pop(); - } - - isVideoFile(fileName): boolean { - const fileType = this.getFileExtension(fileName); - return SUPPORTED_VIDEO_FILE_TYPES.includes(fileType.toLowerCase()); - } - - isSupportedFileType(fileName): boolean { - const fileType = this.getFileExtension(fileName); - return SUPPORTED_FILE_TYPES.includes(fileType.toLowerCase()); - } -} diff --git a/src/app/issues-viewer/card-view/card-view.component.css b/src/app/issues-viewer/card-view/card-view.component.css index 4ba99afc..8664c87e 100644 --- a/src/app/issues-viewer/card-view/card-view.component.css +++ b/src/app/issues-viewer/card-view/card-view.component.css @@ -6,40 +6,19 @@ margin: 8px 0px 8px 0px; } -.mat-chip-list { - margin: 0; -} - -.mat-chip { - border-radius: 6px; - font-size: 10px; - padding: 11px 7px; - min-height: 16px; - margin: 2px; -} - .mat-card-title { display: flex; align-items: center; font-size: 12px; - text-align: left; + text-align: center; overflow: auto; + word-break: break-word; } .column-header .mat-card-title { font-size: 14px; } -span.octicon { - margin-right: 8px; - display: inline-flex; -} - -span.octicon-milestone { - margin-right: 4px; - display: inline-flex; -} - .mat-card { padding: 10px; } @@ -55,6 +34,8 @@ div.column-header .mat-card-title { div.column-header { justify-content: center; + position: relative; + z-index: 5; } div.column-header .mat-card-header { @@ -63,21 +44,74 @@ div.column-header .mat-card-header { justify-content: center; } -:host ::ng-deep .mat-chip-list-wrapper .mat-standard-chip, -.mat-chip-list-wrapper input.mat-input-element { - margin: 2px; -} - -.border-green { - border-left: 2.4px solid green; -} - -.border-purple { - border-left: 2.4px solid purple; -} - -.mat-card-content { +.scrollable-container { + height: 53vh; + overflow: auto; margin-bottom: 2px; + scrollbar-width: none; + position: relative; +} + +.scrollable-container::-webkit-scrollbar { + display: none; +} + +/* Ref: https://css-scroll-shadows.vercel.app */ +.scrollable-container::before { + pointer-events: none; + content: ''; + position: absolute; + z-index: 2; + height: 6px; + width: 100%; + display: block; + background-image: linear-gradient(to bottom, white 66%, transparent); +} + +.scrollable-container::after { + pointer-events: none; + content: ''; + position: sticky; + z-index: 2; + top: 100%; + height: 6px; + width: 100%; + display: block; + background-image: linear-gradient(to top, white 66%, transparent); +} + +.scrollable-container-wrapper { + position: relative; +} + +.scrollable-container-wrapper::before { + pointer-events: none; + content: ''; + position: absolute; + z-index: 1; + top: 0; + left: 0; + right: 0; + height: 5px; + background-image: radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, 0.5), transparent); +} + +.scrollable-container-wrapper::after { + pointer-events: none; + content: ''; + position: absolute; + z-index: 1; + bottom: 0; + left: 0; + right: 0; + height: 5px; + background-image: radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, 0.5), transparent); +} + +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; } .mat-card-subtitle { diff --git a/src/app/issues-viewer/card-view/card-view.component.html b/src/app/issues-viewer/card-view/card-view.component.html index 4ca369da..872080b8 100644 --- a/src/app/issues-viewer/card-view/card-view.component.html +++ b/src/app/issues-viewer/card-view/card-view.component.html @@ -1,54 +1,32 @@
- +
- {{ this.assignee !== undefined ? this.assignee.login : '' }} + {{ this.assignee !== undefined ? this.assignee.login : 'Unassigned Issues' }}
{{ this.issues.count }}
- diff --git a/src/app/issues-viewer/card-view/card-view.component.ts b/src/app/issues-viewer/card-view/card-view.component.ts index 693d4698..e1a121a8 100644 --- a/src/app/issues-viewer/card-view/card-view.component.ts +++ b/src/app/issues-viewer/card-view/card-view.component.ts @@ -1,12 +1,11 @@ import { AfterViewInit, Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { MatPaginator, MatSort } from '@angular/material'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; import { Observable } from 'rxjs'; import { GithubUser } from '../../core/models/github-user.model'; import { Issue } from '../../core/models/issue.model'; -import { GithubService } from '../../core/services/github.service'; import { IssueService } from '../../core/services/issue.service'; -import { LabelService } from '../../core/services/label.service'; -import { LoggingService } from '../../core/services/logging.service'; +import { FilterableComponent, FilterableSource } from '../../shared/issue-tables/filterableTypes'; import { IssuesDataTable } from '../../shared/issue-tables/IssuesDataTable'; @Component({ @@ -18,7 +17,7 @@ import { IssuesDataTable } from '../../shared/issue-tables/IssuesDataTable'; /** * Displays issues as Cards. */ -export class CardViewComponent implements OnInit, AfterViewInit, OnDestroy { +export class CardViewComponent implements OnInit, AfterViewInit, OnDestroy, FilterableComponent { @Input() headers: string[]; @Input() assignee?: GithubUser = undefined; @Input() filters?: any = undefined; @@ -29,12 +28,7 @@ export class CardViewComponent implements OnInit, AfterViewInit, OnDestroy { issues: IssuesDataTable; issues$: Observable; - constructor( - private githubService: GithubService, - public issueService: IssueService, - public labelService: LabelService, - private logger: LoggingService - ) {} + constructor(public issueService: IssueService) {} ngOnInit() { this.issues = new IssuesDataTable(this.issueService, this.sort, this.paginator, this.headers, this.assignee, this.filters); @@ -53,80 +47,7 @@ export class CardViewComponent implements OnInit, AfterViewInit, OnDestroy { }); } - /** - * Formats the title text to account for those that contain long words. - * @param title - Title of Issue that is to be displayed in the Table Row. - */ - fitTitleText(title: string): string { - // Arbitrary Length of Characters beyond which an overflow occurs. - const MAX_WORD_LENGTH = 43; - const SPLITTER_TEXT = ' '; - const ELLIPSES = '...'; - - return title - .split(SPLITTER_TEXT) - .map((word) => { - if (word.length > MAX_WORD_LENGTH) { - return word.substring(0, MAX_WORD_LENGTH - 5).concat(ELLIPSES); - } - return word; - }) - .join(SPLITTER_TEXT); - } - - /** Opens issue in new window */ - viewIssueInBrowser(id: number, event: Event) { - this.logger.info(`CardViewComponent: Opening Issue ${id} on Github`); - this.githubService.viewIssueInBrowser(id, event); - } - - /** Returns status color for issue */ - getIssueOpenOrCloseColor(issue: Issue) { - return issue.state === 'OPEN' ? 'green' : 'purple'; - } - - /** Returns CSS class for border color */ - getIssueOpenOrCloseColorCSSClass(issue: Issue) { - return issue.state === 'OPEN' ? 'border-green' : 'border-purple'; - } - - /** - * Returns corresponding Github icon identifier for issue to display. - * @param issue Issue to display - * @returns string to create icon - */ - getOcticon(issue: Issue) { - const type = issue.issueOrPr; - const state = issue.state; - - switch (true) { - case type === 'Issue' && state === 'OPEN': { - return 'issue-opened'; - } - case type === 'Issue' && state === 'CLOSED': { - return 'issue-closed'; - } - case type === 'PullRequest' && state === 'OPEN': { - return 'git-pull-request'; - } - case type === 'PullRequest': { - return 'git-merge'; - } - default: { - return 'circle'; // unknown type and state - } - } - } - - /** - * Truncates description to fit in card content. - * @param description - Description of Issue that is to be displayed. - */ - fitDescriptionText(description: string): string { - // Arbitrary Length of Characters beyond which an overflow occurs. - const MAX_CHARACTER_LENGTH = 72; - const ELLIPSES = '...'; - - return description.slice(0, MAX_CHARACTER_LENGTH) + ELLIPSES; + retrieveFilterable(): FilterableSource { + return this.issues; } } diff --git a/src/app/issues-viewer/issues-viewer.component.css b/src/app/issues-viewer/issues-viewer.component.css index 4ced75c2..9c3523f1 100644 --- a/src/app/issues-viewer/issues-viewer.component.css +++ b/src/app/issues-viewer/issues-viewer.component.css @@ -30,3 +30,15 @@ .switch-repo-tile { background-color: lightblue; } + +.wrapper { + display: flex; + overflow-x: auto; + /* white-space: nowrap; */ +} + +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/app/issues-viewer/issues-viewer.component.html b/src/app/issues-viewer/issues-viewer.component.html index cacde4b2..0964a62b 100644 --- a/src/app/issues-viewer/issues-viewer.component.html +++ b/src/app/issues-viewer/issues-viewer.component.html @@ -1,66 +1,20 @@
- - - - - - - - - - +
+ +
- - - -
+ + -
- -
+
+ + +
+
diff --git a/src/app/issues-viewer/issues-viewer.component.ts b/src/app/issues-viewer/issues-viewer.component.ts index 72f1c07a..0e399bcd 100644 --- a/src/app/issues-viewer/issues-viewer.component.ts +++ b/src/app/issues-viewer/issues-viewer.component.ts @@ -1,16 +1,14 @@ import { AfterViewInit, Component, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; -import { MatOption, MatSelect, MatSort } from '@angular/material'; -import { BehaviorSubject, Subscription } from 'rxjs'; +import { BehaviorSubject, of, Subscription } from 'rxjs'; import { GithubUser } from '../core/models/github-user.model'; +import { Repo } from '../core/models/repo.model'; import { GithubService } from '../core/services/github.service'; import { IssueService } from '../core/services/issue.service'; -import { LoggingService } from '../core/services/logging.service'; +import { LabelService } from '../core/services/label.service'; import { MilestoneService } from '../core/services/milestone.service'; import { PhaseService } from '../core/services/phase.service'; import { TABLE_COLUMNS } from '../shared/issue-tables/issue-tables-columns'; -import { DEFAULT_DROPDOWN_FILTER, DropdownFilter } from '../shared/issue-tables/IssuesDataTable'; import { CardViewComponent } from './card-view/card-view.component'; -import { LabelFilterBarComponent } from './label-filter-bar/label-filter-bar.component'; @Component({ selector: 'app-issues-viewer', @@ -23,37 +21,28 @@ export class IssuesViewerComponent implements OnInit, AfterViewInit, OnDestroy { /** Observes for any change in repo*/ repoChangeSubscription: Subscription; + /** Observes for any change in the cardviews */ + viewChange: Subscription; + /** Users to show as columns */ assignees: GithubUser[]; - /** Selected dropdown filter value */ - dropdownFilter: DropdownFilter = DEFAULT_DROPDOWN_FILTER; - - /** Selected label filters, instance passed into LabelChipBar to populate */ - labelFilter$ = new BehaviorSubject([]); - labelFilterSubscription: Subscription; - - /** Selected label to hide */ - hiddenLabels$ = new BehaviorSubject>(new Set()); - hiddenLabelSubscription: Subscription; - @ViewChildren(CardViewComponent) cardViews: QueryList; - /** One MatSort controls all IssueDataTables */ - @ViewChild(MatSort, { static: true }) matSort: MatSort; - - @ViewChild(LabelFilterBarComponent, { static: true }) labelFilterBar: LabelFilterBarComponent; - - @ViewChild('milestoneSelectorRef', { static: false }) milestoneSelectorRef: MatSelect; + views = new BehaviorSubject>(undefined); constructor( public phaseService: PhaseService, public githubService: GithubService, public issueService: IssueService, - public milestoneService: MilestoneService, - private logger: LoggingService + public labelService: LabelService, + public milestoneService: MilestoneService ) { - this.repoChangeSubscription = this.phaseService.repoChanged$.subscribe((newRepo) => this.initialize()); + this.repoChangeSubscription = this.phaseService.repoChanged$.subscribe((newRepo) => { + this.issueService.reset(false); + this.labelService.reset(); + this.initialize(); + }); } ngOnInit() { @@ -61,61 +50,43 @@ export class IssuesViewerComponent implements OnInit, AfterViewInit, OnDestroy { } ngAfterViewInit(): void { - /** Apply dropdown filter when LabelChipBar populates with label filters */ - this.labelFilterSubscription = this.labelFilter$.subscribe((labels) => { - this.dropdownFilter.labels = labels; - this.applyDropdownFilter(); - }); - - this.hiddenLabelSubscription = this.hiddenLabels$.subscribe((labels) => { - this.dropdownFilter.hiddenLabels = labels; - this.applyDropdownFilter(); - }); + this.viewChange = this.cardViews.changes.subscribe((x) => this.views.next(x)); } ngOnDestroy(): void { - this.labelFilterSubscription.unsubscribe(); - this.hiddenLabelSubscription.unsubscribe(); this.repoChangeSubscription.unsubscribe(); - } - - /** - * Signals to IssuesDataTable that a change has occurred in filter. - * @param filterValue - */ - applyFilter(filterValue: string) { - this.cardViews.forEach((v) => (v.issues.filter = filterValue)); - } - - /** - * Signals to IssuesDataTable that a change has occurred in dropdown filter. - */ - applyDropdownFilter() { - this.cardViews.forEach((v) => (v.issues.dropdownFilter = this.dropdownFilter)); + this.viewChange.unsubscribe(); } /** * Fetch and initialize all information from repository to populate Issue Dashboard. */ private initialize() { + this.checkIfValidRepository().subscribe((isValidRepository) => { + if (!isValidRepository) { + throw new Error('Invalid repository name. Please provide repository name in the format Org/Repository.'); + } + }); + // Fetch assignees this.assignees = []; + this.githubService.getUsersAssignable().subscribe((x) => (this.assignees = x)); // Fetch issues this.issueService.reloadAllIssues(); + } + + /** + * Checks if our current repository available on phase service is indeed a valid repository + */ + private checkIfValidRepository() { + const currentRepo = this.phaseService.currentRepo; + + if (Repo.isInvalidRepoName(currentRepo)) { + return of(false); + } - // Fetch labels - this.labelFilterBar.load(); - - // Fetch milestones - this.milestoneService.fetchMilestones().subscribe( - (response) => { - this.logger.debug('IssuesViewerComponent: Fetched milestones from Github'); - this.milestoneSelectorRef.options.forEach((data: MatOption) => data.deselect()); - }, - (err) => {}, - () => {} - ); + return this.githubService.isRepositoryPresent(currentRepo.owner, currentRepo.name); } } diff --git a/src/app/issues-viewer/issues-viewer.module.ts b/src/app/issues-viewer/issues-viewer.module.ts index 931198d6..537960c3 100644 --- a/src/app/issues-viewer/issues-viewer.module.ts +++ b/src/app/issues-viewer/issues-viewer.module.ts @@ -1,13 +1,14 @@ import { NgModule } from '@angular/core'; -import { MarkdownModule } from 'ngx-markdown'; +import { FilterBarModule } from '../shared/filter-bar/filter-bar.module'; +import { IssuesPrCardModule } from '../shared/issue-pr-card/issue-pr-card.module'; import { SharedModule } from '../shared/shared.module'; import { CardViewComponent } from './card-view/card-view.component'; import { IssuesViewerRoutingModule } from './issues-viewer-routing.module'; import { IssuesViewerComponent } from './issues-viewer.component'; -import { LabelFilterBarComponent } from './label-filter-bar/label-filter-bar.component'; @NgModule({ - imports: [IssuesViewerRoutingModule, SharedModule, MarkdownModule.forChild()], - declarations: [IssuesViewerComponent, CardViewComponent, LabelFilterBarComponent] + imports: [FilterBarModule, IssuesViewerRoutingModule, IssuesPrCardModule, SharedModule], + declarations: [IssuesViewerComponent, CardViewComponent], + exports: [IssuesViewerComponent, CardViewComponent] }) export class IssuesViewerModule {} diff --git a/src/app/issues-viewer/label-filter-bar/label-filter-bar.component.css b/src/app/issues-viewer/label-filter-bar/label-filter-bar.component.css deleted file mode 100644 index 361f551a..00000000 --- a/src/app/issues-viewer/label-filter-bar/label-filter-bar.component.css +++ /dev/null @@ -1,55 +0,0 @@ -.popup-container { - padding-top: 0px; - padding-left: 15px; - padding-right: 15px; - flex-direction: column; -} - -.popupmenu { - width: max-content; -} - -.scroll-container { - height: 400px; - width: 100%; - overflow-y: auto; - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} - -.flexbox-container { - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; -} - -.scroll-container::-webkit-scrollbar { - display: none; /* Hide scrollbar for Chrome, Safari and Opera */ -} - -.input-field { - width: 100%; -} - -.list-option { - width: 100%; -} - -.mat-chip { - position: inherit; - border-radius: 6px; - font-size: 12px; - padding: 11px 7px; - min-height: 16px; - margin: 0px; - top: 50%; -} - -.hidden { - display: none !important; -} - -mat-list-option { - width: max-content; -} diff --git a/src/app/issues-viewer/label-filter-bar/label-filter-bar.component.html b/src/app/issues-viewer/label-filter-bar/label-filter-bar.component.html deleted file mode 100644 index 7bfcafae..00000000 --- a/src/app/issues-viewer/label-filter-bar/label-filter-bar.component.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - diff --git a/src/app/issues-viewer/label-filter-bar/label-filter-bar.module.ts b/src/app/issues-viewer/label-filter-bar/label-filter-bar.module.ts deleted file mode 100644 index 76d8a2bc..00000000 --- a/src/app/issues-viewer/label-filter-bar/label-filter-bar.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { SharedModule } from '../../shared/shared.module'; -import { LabelFilterBarComponent } from './label-filter-bar.component'; - -@NgModule({ - declarations: [LabelFilterBarComponent], - imports: [CommonModule, SharedModule], - exports: [LabelFilterBarComponent] -}) -export class LabelFilterBarModule {} diff --git a/src/app/shared/error-toasters/error-toaster.module.ts b/src/app/shared/error-toasters/error-toaster.module.ts index 88b8266d..01b8efd5 100644 --- a/src/app/shared/error-toasters/error-toaster.module.ts +++ b/src/app/shared/error-toasters/error-toaster.module.ts @@ -9,7 +9,6 @@ import { ToasterComponent } from './toaster/toaster.component'; @NgModule({ imports: [CommonModule, MaterialModule], declarations: [GeneralMessageErrorComponent, FormErrorComponent, InvalidCredentialsErrorComponent, ToasterComponent], - exports: [GeneralMessageErrorComponent, FormErrorComponent, InvalidCredentialsErrorComponent], - entryComponents: [GeneralMessageErrorComponent, FormErrorComponent, InvalidCredentialsErrorComponent] + exports: [GeneralMessageErrorComponent, FormErrorComponent, InvalidCredentialsErrorComponent] }) export class ErrorToasterModule {} diff --git a/src/app/shared/error-toasters/form-error/form-error.component.ts b/src/app/shared/error-toasters/form-error/form-error.component.ts index 443c379e..50ede3e5 100644 --- a/src/app/shared/error-toasters/form-error/form-error.component.ts +++ b/src/app/shared/error-toasters/form-error/form-error.component.ts @@ -1,5 +1,5 @@ import { Component, Inject } from '@angular/core'; -import { MAT_SNACK_BAR_DATA } from '@angular/material'; +import { MAT_SNACK_BAR_DATA } from '@angular/material/snack-bar'; @Component({ selector: 'app-form-error', diff --git a/src/app/shared/error-toasters/general-message-error/general-message-error.component.ts b/src/app/shared/error-toasters/general-message-error/general-message-error.component.ts index cb9a37fe..2fe4d6e1 100644 --- a/src/app/shared/error-toasters/general-message-error/general-message-error.component.ts +++ b/src/app/shared/error-toasters/general-message-error/general-message-error.component.ts @@ -1,5 +1,5 @@ import { Component, Inject } from '@angular/core'; -import { MAT_SNACK_BAR_DATA } from '@angular/material'; +import { MAT_SNACK_BAR_DATA } from '@angular/material/snack-bar'; @Component({ selector: 'app-general-message-error', diff --git a/src/app/shared/error-toasters/invalid-credentials-error/invalid-credentials-error.component.ts b/src/app/shared/error-toasters/invalid-credentials-error/invalid-credentials-error.component.ts index f7c24d19..798ee950 100644 --- a/src/app/shared/error-toasters/invalid-credentials-error/invalid-credentials-error.component.ts +++ b/src/app/shared/error-toasters/invalid-credentials-error/invalid-credentials-error.component.ts @@ -1,5 +1,5 @@ import { Component, Inject } from '@angular/core'; -import { MAT_SNACK_BAR_DATA, MatSnackBarRef } from '@angular/material'; +import { MatSnackBarRef, MAT_SNACK_BAR_DATA } from '@angular/material/snack-bar'; @Component({ selector: 'app-form-error', diff --git a/src/app/shared/error-toasters/toaster/toaster.component.ts b/src/app/shared/error-toasters/toaster/toaster.component.ts index d353debe..2dd2f118 100644 --- a/src/app/shared/error-toasters/toaster/toaster.component.ts +++ b/src/app/shared/error-toasters/toaster/toaster.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { MatSnackBarRef } from '@angular/material'; +import { MatSnackBarRef } from '@angular/material/snack-bar'; @Component({ selector: 'app-toaster', diff --git a/src/app/shared/filter-bar/filter-bar.component.css b/src/app/shared/filter-bar/filter-bar.component.css new file mode 100644 index 00000000..9a2db63c --- /dev/null +++ b/src/app/shared/filter-bar/filter-bar.component.css @@ -0,0 +1,22 @@ +.dropdown-filters .mat-form-field { + margin: 8px; + font-size: 14px; + max-width: 20%; + width: 25%; /* depends on number of filters*/ +} + +.search-bar { + width: 90%; +} + +.dropdown-filters { + width: 100%; +} + +.label-filter-grid-tile { + width: 100%; +} + +.submit-button { + margin-left: 8px; +} diff --git a/src/app/shared/filter-bar/filter-bar.component.html b/src/app/shared/filter-bar/filter-bar.component.html new file mode 100644 index 00000000..1823f473 --- /dev/null +++ b/src/app/shared/filter-bar/filter-bar.component.html @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + diff --git a/src/app/shared/filter-bar/filter-bar.component.ts b/src/app/shared/filter-bar/filter-bar.component.ts new file mode 100644 index 00000000..b2b1c707 --- /dev/null +++ b/src/app/shared/filter-bar/filter-bar.component.ts @@ -0,0 +1,129 @@ +import { AfterViewInit, Component, Input, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core'; +import { MatSelect } from '@angular/material/select'; +import { MatSort } from '@angular/material/sort'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { LoggingService } from '../../core/services/logging.service'; +import { MilestoneService } from '../../core/services/milestone.service'; +import { PhaseService } from '../../core/services/phase.service'; +import { DEFAULT_DROPDOWN_FILTER, DropdownFilter } from '../issue-tables/dropdownfilter'; +import { FilterableComponent } from '../issue-tables/filterableTypes'; +import { LabelFilterBarComponent } from './label-filter-bar/label-filter-bar.component'; + +/** + * This component is abstracted out filterbar used by both detailed-viewer page + * and Issues-viewer + */ +@Component({ + selector: 'app-filter-bar', + templateUrl: './filter-bar.component.html', + styleUrls: ['./filter-bar.component.css'] +}) +export class FilterBarComponent implements OnInit, AfterViewInit, OnDestroy { + @Input() views$: BehaviorSubject>; + + repoChangeSubscription: Subscription; + + /** Selected dropdown filter value */ + dropdownFilter: DropdownFilter = DEFAULT_DROPDOWN_FILTER; + + /** Selected label filters, instance passed into LabelChipBar to populate */ + labelFilter$ = new BehaviorSubject([]); + labelFilterSubscription: Subscription; + + /** Selected label to hide */ + hiddenLabels$ = new BehaviorSubject>(new Set()); + hiddenLabelSubscription: Subscription; + + /** Milestone subscription */ + milestoneSubscription: Subscription; + + /** One MatSort controls all IssueDataTables */ + @ViewChild(MatSort, { static: true }) matSort: MatSort; + + @ViewChild(LabelFilterBarComponent, { static: true }) labelFilterBar: LabelFilterBarComponent; + + @ViewChild('milestoneSelectorRef', { static: false }) milestoneSelectorRef: MatSelect; + + constructor(public milestoneService: MilestoneService, private phaseService: PhaseService, private logger: LoggingService) { + this.repoChangeSubscription = this.phaseService.repoChanged$.subscribe((newRepo) => this.initialize()); + } + + ngOnInit() { + this.initialize(); + } + + ngAfterViewInit(): void { + /** Apply dropdown filter when LabelChipBar populates with label filters */ + this.labelFilterSubscription = this.labelFilter$.subscribe((labels) => { + this.dropdownFilter.labels = labels; + this.applyDropdownFilter(); + }); + + this.hiddenLabelSubscription = this.hiddenLabels$.subscribe((labels) => { + this.dropdownFilter.hiddenLabels = labels; + this.applyDropdownFilter(); + }); + } + + ngOnDestroy(): void { + this.labelFilterSubscription?.unsubscribe(); + this.hiddenLabelSubscription?.unsubscribe(); + this.milestoneSubscription.unsubscribe(); + this.repoChangeSubscription.unsubscribe(); + } + + /** + * Signals to IssuesDataTable that a change has occurred in filter. + * @param filterValue + */ + applyFilter(filterValue: string) { + this.views$?.value?.forEach((v) => (v.retrieveFilterable().filter = filterValue)); + } + + /** + * Changes type to a valid, default value when an incompatible combination of type and status is encountered. + */ + updateTypePairing() { + if (this.dropdownFilter.status === 'merged') { + this.dropdownFilter.type = 'pullrequest'; + } + } + + /** + * Changes status to a valid, default value when an incompatible combination of type and status is encountered. + */ + updateStatusPairing() { + if (this.dropdownFilter.status === 'merged' && this.dropdownFilter.type === 'issue') { + this.dropdownFilter.status = 'all'; + } + } + + /** + * Signals to IssuesDataTable that a change has occurred in dropdown filter. + */ + applyDropdownFilter() { + this.views$?.value?.forEach((v) => (v.retrieveFilterable().dropdownFilter = this.dropdownFilter)); + } + + /** + * Checks if program is filtering by type issue. + */ + isNotFilterIssue() { + return this.dropdownFilter.type !== 'issue'; + } + + /** + * Fetch and initialize all information from repository to populate Issue Dashboard. + */ + private initialize() { + // Fetch milestones and update dropdown filter + this.milestoneSubscription = this.milestoneService.fetchMilestones().subscribe( + (response) => { + this.logger.debug('IssuesViewerComponent: Fetched milestones from Github'); + this.milestoneService.milestones.forEach((milestone) => this.dropdownFilter.milestones.push(milestone.number)); + }, + (err) => {}, + () => {} + ); + } +} diff --git a/src/app/shared/filter-bar/filter-bar.module.ts b/src/app/shared/filter-bar/filter-bar.module.ts new file mode 100644 index 00000000..fefaa28f --- /dev/null +++ b/src/app/shared/filter-bar/filter-bar.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared.module'; +import { FilterBarComponent } from './filter-bar.component'; +import { LabelFilterBarComponent } from './label-filter-bar/label-filter-bar.component'; + +@NgModule({ + imports: [SharedModule], + declarations: [FilterBarComponent, LabelFilterBarComponent], + exports: [FilterBarComponent] +}) +export class FilterBarModule {} diff --git a/src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.css b/src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.css new file mode 100644 index 00000000..03e92e4b --- /dev/null +++ b/src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.css @@ -0,0 +1,131 @@ +::ng-deep.mat-menu-content:not(:empty) { + /* Override mat-menu-content's non-empty default CSS. */ + padding-top: 0px !important; + padding-bottom: 0px !important; +} + +::ng-deep.mat-menu-panel { + /* Override mat-menu-panel's default CSS. */ + width: 280px; + max-width: none !important; +} + +.popup-container { + flex-direction: column; +} + +.popupmenu { + width: max-content; +} + +.scroll-container { + max-height: 400px; + width: 100%; + overflow-y: auto; + position: relative; + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +/* Ref: https://css-scroll-shadows.vercel.app */ +.scroll-container::before { + pointer-events: none; + content: ''; + position: absolute; + z-index: 2; + height: 7px; + width: 100%; + display: block; + background-image: linear-gradient(to bottom, white 66%, transparent); +} + +.scroll-container::after { + pointer-events: none; + content: ''; + position: sticky; + z-index: 2; + top: 100%; + height: 7px; + width: 100%; + display: block; + background-image: linear-gradient(to top, white 66%, transparent); +} + +.scroll-container-wrapper { + position: relative; +} + +.scroll-container-wrapper::before { + pointer-events: none; + content: ''; + position: absolute; + z-index: 1; + top: 0; + left: 0; + right: 0; + height: 5px; + background-image: radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, 0.7), transparent); +} + +.scroll-container-wrapper::after { + pointer-events: none; + content: ''; + position: absolute; + z-index: 1; + bottom: 0; + left: 0; + right: 0; + height: 5px; + background-image: radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, 0.7), transparent); +} + +.scroll-container::-webkit-scrollbar { + display: none; /* Hide scrollbar for Chrome, Safari and Opera */ +} + +.flexbox-container { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; +} + +.input-field { + width: calc(100% - (2 * 15px)); /* To account for left and right padding. */ + padding: 0 15px; +} + +.list-option { + width: 100%; +} + +.mat-chip { + height: auto; + padding: 5.5px 7px; + line-height: 1.1em; + position: inherit; + border-radius: 6px; + font-size: 12px; + min-height: 16px; + max-height: 42px; + margin: 0px; + top: 50%; +} + +.mat-stroked-button { + font-weight: normal; +} + +.hidden { + display: none !important; +} + +mat-list-option { + width: max-content; +} + +.no-labels { + /* Chosen to look similar to button above. */ + padding: 0 16px; + font-size: 14px; +} diff --git a/src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.html b/src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.html new file mode 100644 index 00000000..3701bc6b --- /dev/null +++ b/src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.html @@ -0,0 +1,51 @@ + + + + + + + diff --git a/src/app/issues-viewer/label-filter-bar/label-filter-bar.component.ts b/src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.ts similarity index 51% rename from src/app/issues-viewer/label-filter-bar/label-filter-bar.component.ts rename to src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.ts index af6a2ab0..991a9d0d 100644 --- a/src/app/issues-viewer/label-filter-bar/label-filter-bar.component.ts +++ b/src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.ts @@ -1,33 +1,46 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { MatListOption } from '@angular/material'; -import { BehaviorSubject } from 'rxjs'; -import { LabelService } from '../../core/services/label.service'; -import { LoggingService } from '../../core/services/logging.service'; - -export type simplifiedLabel = { - name: string; - color: string; -}; +import { AfterViewInit, Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { MatListOption, MatSelectionList } from '@angular/material/list'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { SimpleLabel } from '../../../core/models/label.model'; +import { LabelService } from '../../../core/services/label.service'; +import { LoggingService } from '../../../core/services/logging.service'; @Component({ selector: 'app-label-filter-bar', templateUrl: './label-filter-bar.component.html', styleUrls: ['./label-filter-bar.component.css'] }) -export class LabelFilterBarComponent implements OnInit { +export class LabelFilterBarComponent implements OnInit, AfterViewInit, OnDestroy { @Input() selectedLabels: BehaviorSubject; @Input() hiddenLabels: BehaviorSubject>; + @ViewChild(MatSelectionList) matSelectionList; - allLabels: simplifiedLabel[]; + labels$: Observable; + allLabels: SimpleLabel[]; selectedLabelNames: string[] = []; hiddenLabelNames: Set = new Set(); loaded = false; + labelSubscription: Subscription; + constructor(private labelService: LabelService, private logger: LoggingService) {} ngOnInit() { this.loaded = false; - this.load(); + } + + ngAfterViewInit(): void { + setTimeout(() => { + this.load(); + this.labels$ = this.labelService.connect(); + this.labels$.subscribe((labels) => { + this.allLabels = labels; + }); + }); + } + + ngOnDestroy(): void { + this.labelSubscription?.unsubscribe(); } hide(label: string): void { @@ -63,28 +76,35 @@ export class LabelFilterBarComponent implements OnInit { /** loads in the labels in the repository */ public load() { - this.labelService.fetchLabels().subscribe( + this.labelService.startPollLabels(); + this.labelSubscription = this.labelService.fetchLabels().subscribe( (response) => { this.logger.debug('LabelFilterBarComponent: Fetched labels from Github'); }, (err) => {}, () => { - this.initialize(); + this.loaded = true; } ); } - private initialize() { - this.allLabels = this.labelService.labels.map((label) => { - return { - name: label.getFormattedName(), - color: label.color - }; - }); - this.loaded = true; - } - filter(filter: string, target: string): boolean { return !target.toLowerCase().includes(filter.toLowerCase()); } + + hasLabels(filter: string): boolean { + if (this.allLabels === undefined || this.allLabels.length === 0) { + return false; + } + return this.allLabels.some((label) => !this.filter(filter, label.formattedName)); + } + + updateSelection(): void { + this.selectedLabels.next(this.selectedLabelNames); + } + + removeAllSelection(): void { + this.matSelectionList.deselectAll(); + this.updateSelection(); + } } diff --git a/src/app/shared/issue-pr-card/issue-pr-card-header/issue-pr-card-header.component.css b/src/app/shared/issue-pr-card/issue-pr-card-header/issue-pr-card-header.component.css new file mode 100644 index 00000000..3b64b667 --- /dev/null +++ b/src/app/shared/issue-pr-card/issue-pr-card-header/issue-pr-card-header.component.css @@ -0,0 +1,26 @@ +span.octicon { + margin-right: 8px; + display: inline-flex; +} + +.mat-card-title { + display: flex; + align-items: center; + font-size: 12px; + text-align: left; + overflow: auto; +} + +.column-header .mat-card-title { + font-size: 14px; +} + +.mat-card-header { + display: flex; + align-items: center; + justify-content: center; +} + +:host ::ng-deep .mat-card-header-text { + margin: 2px; +} diff --git a/src/app/shared/issue-pr-card/issue-pr-card-header/issue-pr-card-header.component.html b/src/app/shared/issue-pr-card/issue-pr-card-header/issue-pr-card-header.component.html new file mode 100644 index 00000000..42d297da --- /dev/null +++ b/src/app/shared/issue-pr-card/issue-pr-card-header/issue-pr-card-header.component.html @@ -0,0 +1,6 @@ + + + + #{{ issue.id }}: {{ fitTitleText() }} + + diff --git a/src/app/shared/issue-pr-card/issue-pr-card-header/issue-pr-card-header.component.ts b/src/app/shared/issue-pr-card/issue-pr-card-header/issue-pr-card-header.component.ts new file mode 100644 index 00000000..7b0a8b65 --- /dev/null +++ b/src/app/shared/issue-pr-card/issue-pr-card-header/issue-pr-card-header.component.ts @@ -0,0 +1,81 @@ +import { Component, Input } from '@angular/core'; +import { Issue } from '../../../core/models/issue.model'; + +@Component({ + selector: 'app-issue-pr-card-header', + templateUrl: './issue-pr-card-header.component.html', + styleUrls: ['./issue-pr-card-header.component.css'] +}) +export class IssuePrCardHeaderComponent { + @Input() issue: Issue; + + constructor() {} + + /** + * Returns corresponding Github icon identifier for issue to display. + * @returns string to create icon + */ + getOcticon() { + const type = this.issue.issueOrPr; + const state = this.issue.state; + + switch (true) { + case type === 'Issue' && state === 'OPEN': { + return 'issue-opened'; + } + case type === 'Issue' && state === 'CLOSED': { + return 'issue-closed'; + } + case type === 'PullRequest' && state === 'OPEN': { + if (this.issue.isDraft) { + return 'git-pull-request-draft'; + } + return 'git-pull-request'; + } + case type === 'PullRequest' && state === 'CLOSED': { + return 'git-pull-request-closed'; + } + case type === 'PullRequest' && state === 'MERGED': { + return 'git-merge'; + } + default: { + return 'circle'; // unknown type and state + } + } + } + + /** Returns status color for issue */ + getIssueOpenOrCloseColor() { + if (this.issue.isDraft) { + return 'grey'; + } + if (this.issue.state === 'OPEN') { + return 'green'; + } else if (this.issue.issueOrPr === 'PullRequest' && this.issue.state === 'CLOSED') { + return 'red'; + } else { + return 'purple'; + } + } + + /** + * Formats the title text to account for those that contain long words. + * @param title - Title of Issue that is to be displayed in the Table Row. + */ + fitTitleText(): string { + // Arbitrary Length of Characters beyond which an overflow occurs. + const MAX_WORD_LENGTH = 43; + const SPLITTER_TEXT = ' '; + const ELLIPSES = '...'; + + return this.issue.title + .split(SPLITTER_TEXT) + .map((word) => { + if (word.length > MAX_WORD_LENGTH) { + return word.substring(0, MAX_WORD_LENGTH - 5).concat(ELLIPSES); + } + return word; + }) + .join(SPLITTER_TEXT); + } +} diff --git a/src/app/shared/issue-pr-card/issue-pr-card-labels/issue-pr-card-labels.component.css b/src/app/shared/issue-pr-card/issue-pr-card-labels/issue-pr-card-labels.component.css new file mode 100644 index 00000000..96663271 --- /dev/null +++ b/src/app/shared/issue-pr-card/issue-pr-card-labels/issue-pr-card-labels.component.css @@ -0,0 +1,16 @@ +.mat-chip { + border-radius: 6px; + font-size: 10px; + padding: 11px 7px; + min-height: 16px; + margin: 2px; +} + +:host ::ng-deep .mat-chip-list-wrapper .mat-standard-chip, +.mat-chip-list-wrapper input.mat-input-element { + margin: 2px; +} + +.mat-chip-list { + margin: 0; +} diff --git a/src/app/shared/issue-pr-card/issue-pr-card-labels/issue-pr-card-labels.component.html b/src/app/shared/issue-pr-card/issue-pr-card-labels/issue-pr-card-labels.component.html new file mode 100644 index 00000000..09c4dd00 --- /dev/null +++ b/src/app/shared/issue-pr-card/issue-pr-card-labels/issue-pr-card-labels.component.html @@ -0,0 +1,7 @@ + + + + {{ label.name }} + + + diff --git a/src/app/shared/issue-pr-card/issue-pr-card-labels/issue-pr-card-labels.component.ts b/src/app/shared/issue-pr-card/issue-pr-card-labels/issue-pr-card-labels.component.ts new file mode 100644 index 00000000..8fb551eb --- /dev/null +++ b/src/app/shared/issue-pr-card/issue-pr-card-labels/issue-pr-card-labels.component.ts @@ -0,0 +1,14 @@ +import { Component, Input } from '@angular/core'; +import { Label } from '../../../core/models/label.model'; +import { LabelService } from '../../../core/services/label.service'; + +@Component({ + selector: 'app-issue-pr-card-labels', + templateUrl: './issue-pr-card-labels.component.html', + styleUrls: ['./issue-pr-card-labels.component.css'] +}) +export class IssuePrCardLabelsComponent { + @Input() labels: Label[]; + @Input() labelSet: Set