diff --git a/login/backend/v1/login-api/login_api/endpoints/logout.py b/login/backend/v1/login-api/login_api/endpoints/logout.py index 52407c3a..2f6f9921 100644 --- a/login/backend/v1/login-api/login_api/endpoints/logout.py +++ b/login/backend/v1/login-api/login_api/endpoints/logout.py @@ -14,7 +14,7 @@ class LogoutEndpoint(SeleneEndpoint): def __init__(self): super(LogoutEndpoint, self).__init__() - def put(self): + def get(self): try: self._authenticate() self._logout() @@ -33,5 +33,5 @@ def _logout(self): headers=service_request_headers ) self._check_for_service_errors(auth_service_response) - logout_response = auth_service_response.json() + logout_response = auth_service_response.content.decode() self.response = (logout_response, HTTPStatus.OK) diff --git a/login/frontend/v1/login-ui/src/app/app-routing.module.ts b/login/frontend/v1/login-ui/src/app/app-routing.module.ts new file mode 100644 index 00000000..5f127289 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/app-routing.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { LoginComponent } from "./login/login.component"; +import { LogoutComponent } from "./logout/logout.component"; +// import { PageNotFoundComponent } from "./page-not-found/page-not-found.component"; + +const routes: Routes = [ + { path: 'login', component: LoginComponent }, + { path: 'logout', component: LogoutComponent }, + // { path: '**', component: PageNotFoundComponent } +]; + +@NgModule({ + imports: [ RouterModule.forRoot(routes) ], + exports: [ RouterModule ] +}) +export class AppRoutingModule { +} diff --git a/login/frontend/v1/login-ui/src/app/app.component.html b/login/frontend/v1/login-ui/src/app/app.component.html index 642d4ed7..78635d9c 100644 --- a/login/frontend/v1/login-ui/src/app/app.component.html +++ b/login/frontend/v1/login-ui/src/app/app.component.html @@ -8,24 +8,6 @@ is refactored and moved into Selene. -->
-
-
-
-
- -
- -
+ +
diff --git a/login/frontend/v1/login-ui/src/app/app.component.spec.ts b/login/frontend/v1/login-ui/src/app/app.component.spec.ts deleted file mode 100644 index decc5585..00000000 --- a/login/frontend/v1/login-ui/src/app/app.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { TestBed, async } from '@angular/core/testing'; -import { AppComponent } from './app.component'; -describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ - AppComponent - ], - }).compileComponents(); - })); - it('should create the app', async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app).toBeTruthy(); - })); - it(`should have as title 'app'`, async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app.title).toEqual('app'); - })); - it('should render title in a h1 tag', async(() => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.debugElement.nativeElement; - expect(compiled.querySelector('h1').textContent).toContain('Welcome to frontend!'); - })); -}); diff --git a/login/frontend/v1/login-ui/src/app/app.component.ts b/login/frontend/v1/login-ui/src/app/app.component.ts index 63cf067d..1aeee9af 100644 --- a/login/frontend/v1/login-ui/src/app/app.component.ts +++ b/login/frontend/v1/login-ui/src/app/app.component.ts @@ -16,7 +16,7 @@ export class AppComponent implements OnInit { ngOnInit () { let uriParams = decodeURIComponent(window.location.search); - if (uriParams) { + if (!window.location.pathname && uriParams) { this.socialLoginDataFound = true; window.opener.postMessage(uriParams, window.location.origin); window.close(); diff --git a/login/frontend/v1/login-ui/src/app/app.module.ts b/login/frontend/v1/login-ui/src/app/app.module.ts index f9ba7c1a..52241c4a 100644 --- a/login/frontend/v1/login-ui/src/app/app.module.ts +++ b/login/frontend/v1/login-ui/src/app/app.module.ts @@ -4,15 +4,21 @@ import { FlexLayoutModule } from "@angular/flex-layout"; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; -import { AuthModule } from "./auth/auth.module"; +import { AppRoutingModule } from "./app-routing.module"; +import { BackgroundModule } from "./background/background.module"; +import { LoginModule } from "./login/login.module"; +import { LogoutModule } from "./logout/logout.module"; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, - AuthModule, + BackgroundModule, BrowserAnimationsModule, - FlexLayoutModule + FlexLayoutModule, + LoginModule, + LogoutModule, + AppRoutingModule ], providers: [ ], bootstrap: [ AppComponent ] diff --git a/login/frontend/v1/login-ui/src/app/app.service.ts b/login/frontend/v1/login-ui/src/app/app.service.ts new file mode 100644 index 00000000..c28bd943 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/app.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders} from "@angular/common/http"; + +import { Observable } from 'rxjs'; + +export interface AuthResponse { + expiration: number; + seleneToken: string; + tartarusToken: string; +} + +export interface SocialLoginData { + uuid: string; + accessToken: string; + refreshToken: string; + expiration: string; +} + +const antisocialAuthUrl = '/api/antisocial'; +const facebookAuthUrl = '/api/social/facebook'; +const githubAuthUrl = '/api/social/github'; +const googleAuthUrl = '/api/social/google'; +const generateTokensUrl = 'api/social/tokens'; +const logoutUrl = '/api/logout'; + + +@Injectable() +export class AppService { + private cookieDomain: string = document.domain.replace('login.', ''); + + constructor(private http: HttpClient) { } + + navigateToRedirectURI(delay: number): void { + let redirectURI = localStorage.getItem('redirect'); + localStorage.removeItem('redirect'); + setTimeout(() => { window.location.assign(redirectURI) }, delay); + } + + authorizeAntisocial (username, password): Observable { + let rawCredentials = `${username}:${password}`; + const codedCredentials = btoa(rawCredentials); + const httpHeaders = new HttpHeaders( + {"Authorization": "Basic " + codedCredentials} + ); + return this.http.get(antisocialAuthUrl, {headers: httpHeaders}) + } + + authenticateWithFacebook() { + window.location.assign(facebookAuthUrl); + } + + authenticateWithGithub() { + window.location.assign(githubAuthUrl); + } + + authenticateWithGoogle() { + window.location.assign(googleAuthUrl); + } + + generateSocialLoginTokens(socialLoginData: any) { + return this.http.post( + generateTokensUrl, + socialLoginData + ); + } + + generateTokenCookies(authResponse: AuthResponse) { + let expirationDate = new Date(authResponse.expiration * 1000); + document.cookie = 'seleneToken=' + authResponse.seleneToken + + '; expires=' + expirationDate.toUTCString() + + '; domain=' + this.cookieDomain; + document.cookie = 'tartarusToken=' + authResponse.tartarusToken + + '; expires=' + expirationDate.toUTCString() + + '; domain=' + this.cookieDomain; + } + + logout(): Observable { + return this.http.get(logoutUrl); + } + + expireTokenCookies(): void { + let expiration = new Date(); + document.cookie = 'seleneToken=""' + + '; expires=' + expiration.toUTCString() + + '; domain=' + this.cookieDomain; + document.cookie = 'tartarusToken=""' + + '; expires=' + expiration.toUTCString() + + '; domain=' + this.cookieDomain; + + } +} diff --git a/login/frontend/v1/login-ui/src/app/auth/auth.component.scss b/login/frontend/v1/login-ui/src/app/auth/auth.component.scss deleted file mode 100644 index 8cb3e4a7..00000000 --- a/login/frontend/v1/login-ui/src/app/auth/auth.component.scss +++ /dev/null @@ -1,75 +0,0 @@ -@import '../../stylesheets/global'; - -button { - @include login-button; -} - -.social { - padding: 20px; - button { - margin-bottom: 15px; - } - fa-icon { - margin-right: 15px; - font-size: 28px; - } - .facebook-button { - background-color: #3b5998; - padding-left: 5px; - } - .github-button { - background-color: #333333; - margin-right: 12px; - padding-left: 5px; - } - .google-button { - background-color: #4285F4; - padding-left: 1px; - img { - margin-right: 10px; - width: 14%; - } - } -} - -button { - @include login-button; -} - -form { - background-color: $mycroft-white; - border-radius: 10px; - padding: 20px; - fa-icon { - color: $mycroft-dark-grey; - margin-right: 15px; - } - mat-checkbox { - color: $mycroft-dark-grey; - } - .forgot-password { - margin-left: 30px; - } - button { - background-color: $mycroft-primary; - margin-top: 30px; - text-align: center; - } - button:hover { - background-color: $mycroft-tertiary-green; - color: $mycroft-secondary; - } - -} - -.mat-body-2 { - color: $mycroft-tertiary-red; - padding: 15px; -} - -.mat-subheading-2 { - color: $mycroft-dark-grey; - margin-bottom: -15px; - margin-top: -15px; - text-align: center; -} diff --git a/login/frontend/v1/login-ui/src/app/auth/auth.component.spec.ts b/login/frontend/v1/login-ui/src/app/auth/auth.component.spec.ts deleted file mode 100644 index 884576c8..00000000 --- a/login/frontend/v1/login-ui/src/app/auth/auth.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AuthComponent } from './auth.component'; - -describe('AuthComponent', () => { - let component: AuthComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ AuthComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(AuthComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/login/frontend/v1/login-ui/src/app/auth/auth.component.ts b/login/frontend/v1/login-ui/src/app/auth/auth.component.ts deleted file mode 100644 index 3912a95b..00000000 --- a/login/frontend/v1/login-ui/src/app/auth/auth.component.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -import { faFacebook, faGithub } from "@fortawesome/free-brands-svg-icons"; -import { faLock, faUser } from "@fortawesome/free-solid-svg-icons"; - -import { AuthResponse, AuthService } from "./auth.service"; - -@Component({ - selector: 'login-authenticate', - templateUrl: './auth.component.html', - styleUrls: ['./auth.component.scss'] -}) -export class AuthComponent implements OnInit { - public facebookIcon = faFacebook; - public githubIcon = faGithub; - public authFailed: boolean; - public password: string; - public passwordIcon = faLock; - public username: string; - public usernameIcon = faUser; - - constructor(private authService: AuthService) { } - - ngOnInit() { } - - authenticateFacebook(): void { - this.authService.authenticateWithFacebook() - } - - authenticateGithub(): void { - this.authService.authenticateWithGithub(); - } - - authenticateGoogle(): void { - this.authService.authenticateWithGoogle(); - } - authorizeUser(): void { - this.authService.authorizeAntisocial(this.username, this.password).subscribe( - (response) => {this.onAuthSuccess(response)}, - (response) => {this.onAuthFailure(response)} - ); - } - - onAuthSuccess(authResponse: AuthResponse) { - this.authFailed = false; - this.authService.generateTokenCookies(authResponse); - window.history.back(); - } - - onAuthFailure(authorizeUserResponse) { - if (authorizeUserResponse.status === 401) { - this.authFailed = true; - } - } -} diff --git a/login/frontend/v1/login-ui/src/app/auth/auth.module.spec.ts b/login/frontend/v1/login-ui/src/app/auth/auth.module.spec.ts deleted file mode 100644 index 561ea04a..00000000 --- a/login/frontend/v1/login-ui/src/app/auth/auth.module.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AuthModule } from './auth.module'; - -describe('AuthModule', () => { - let authenticateModule: AuthModule; - - beforeEach(() => { - authenticateModule = new AuthModule(); - }); - - it('should create an instance', () => { - expect(authenticateModule).toBeTruthy(); - }); -}); diff --git a/login/frontend/v1/login-ui/src/app/auth/auth.service.spec.ts b/login/frontend/v1/login-ui/src/app/auth/auth.service.spec.ts deleted file mode 100644 index f4cc85af..00000000 --- a/login/frontend/v1/login-ui/src/app/auth/auth.service.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TestBed, inject } from '@angular/core/testing'; - -import { AuthService } from './auth.service'; - -describe('AuthenticateService', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [AuthService] - }); - }); - - it('should be created', inject([AuthService], (service: AuthService) => { - expect(service).toBeTruthy(); - })); -}); diff --git a/login/frontend/v1/login-ui/src/app/auth/auth.service.ts b/login/frontend/v1/login-ui/src/app/auth/auth.service.ts deleted file mode 100644 index 3176c70d..00000000 --- a/login/frontend/v1/login-ui/src/app/auth/auth.service.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpHeaders} from "@angular/common/http"; - -import { Observable } from 'rxjs'; -import { isArray } from "util"; - -import { MatSnackBar } from "@angular/material"; - -export interface AuthResponse { - expiration: number; - seleneToken: string; - tartarusToken: string; -} - -export interface SocialLoginData { - uuid: string; - accessToken: string; - refreshToken: string; - expiration: string; -} - -@Injectable() -export class AuthService { - private antisocialAuthUrl = '/api/antisocial'; - private facebookAuthUrl = '/api/social/facebook'; - private githubAuthUrl = '/api/social/github'; - private googleAuthUrl = '/api/social/google'; - private generateTokensUrl = 'api/social/tokens'; - - constructor(private http: HttpClient, public loginSnackbar: MatSnackBar) { - } - - authorizeAntisocial (username, password): Observable { - let rawCredentials = `${username}:${password}`; - const codedCredentials = btoa(rawCredentials); - const httpHeaders = new HttpHeaders( - {"Authorization": "Basic " + codedCredentials} - ); - return this.http.get(this.antisocialAuthUrl, {headers: httpHeaders}) - } - - authenticateWithFacebook() { - window.open(this.facebookAuthUrl); - window.onmessage = (event) => {this.generateSocialLoginTokens(event)}; - } - - authenticateWithGithub() { - window.open(this.githubAuthUrl); - window.onmessage = (event) => {this.generateSocialLoginTokens(event)}; - } - - authenticateWithGoogle() { - window.open(this.googleAuthUrl); - window.onmessage = (event) => {this.generateSocialLoginTokens(event)}; - } - - generateSocialLoginTokens(event: any) { - let socialLoginData = this.parseUriParams(event.data); - if (socialLoginData) { - this.http.post( - this.generateTokensUrl, - socialLoginData - ).subscribe( - (response) => {this.generateTokenCookies(response)} - ); - } - return this.http.post( - this.generateTokensUrl, - socialLoginData - ) - } - - parseUriParams (uriParams: string) { - let socialLoginData: SocialLoginData = null; - - if (uriParams.startsWith('?data=')) { - let parsedUriParams = JSON.parse(uriParams.slice(6)); - if (isArray(parsedUriParams)) { - let socialLoginErrorMsg = 'An account exists for the email ' + - 'address associated with the social network log in ' + - 'attempt. To enable log in using a social network, log ' + - 'in with your username and password and enable the ' + - 'social network in your account preferences.'; - this.loginSnackbar.open( - socialLoginErrorMsg, - null, - {duration: 30000} - ); - } else { - socialLoginData = parsedUriParams; - } - } - - return socialLoginData - } - - generateTokenCookies(authResponse: AuthResponse) { - let expirationDate = new Date(authResponse.expiration * 1000); - let domain = document.domain.replace('login.', ''); - document.cookie = 'seleneToken=' + authResponse.seleneToken + - '; expires=' + expirationDate.toUTCString() + - '; domain=' + domain; - document.cookie = 'tartarusToken=' + authResponse.tartarusToken + - '; expires=' + expirationDate.toUTCString() + - '; domain=' + domain; - } - - -} diff --git a/login/frontend/v1/login-ui/src/app/background/background.component.html b/login/frontend/v1/login-ui/src/app/background/background.component.html new file mode 100644 index 00000000..304e0ba9 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/background/background.component.html @@ -0,0 +1,7 @@ +
+
+
+
+ +
+
diff --git a/login/frontend/v1/login-ui/src/app/background/background.component.scss b/login/frontend/v1/login-ui/src/app/background/background.component.scss new file mode 100644 index 00000000..572dc6fc --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/background/background.component.scss @@ -0,0 +1,30 @@ +@import "../../stylesheets/global"; + +/* Split the screen in half */ +.split { + height: 50%; + left: 0; + overflow-x: hidden; + padding-top: 20px; + position: fixed; + width: 100%; + z-index: -1; +} + +/* Top Half */ +.top { + top: 0; + background-color: $mycroft-primary; +} + +/* Bottom Half */ +.bottom { + bottom: 0; + background-color: #e5e5e5; +} + +img { + margin-bottom: 50px; + margin-top: 50px; + width: 600px; +} diff --git a/login/frontend/v1/login-ui/src/app/background/background.component.ts b/login/frontend/v1/login-ui/src/app/background/background.component.ts new file mode 100644 index 00000000..04942f32 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/background/background.component.ts @@ -0,0 +1,14 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'login-background', + templateUrl: './background.component.html', + styleUrls: ['./background.component.scss'] +}) +export class BackgroundComponent implements OnInit { + + constructor() { } + + ngOnInit() { } + +} diff --git a/login/frontend/v1/login-ui/src/app/background/background.module.ts b/login/frontend/v1/login-ui/src/app/background/background.module.ts new file mode 100644 index 00000000..a1bce3c9 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/background/background.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BackgroundComponent } from "./background.component"; + +@NgModule({ + declarations: [ BackgroundComponent ], + exports: [ BackgroundComponent ], + imports: [ CommonModule ], +}) +export class BackgroundModule { } diff --git a/login/frontend/v1/login-ui/src/app/auth/auth.component.html b/login/frontend/v1/login-ui/src/app/login/antisocial/antisocial.component.html similarity index 63% rename from login/frontend/v1/login-ui/src/app/auth/auth.component.html rename to login/frontend/v1/login-ui/src/app/login/antisocial/antisocial.component.html index 13a4199b..57cdd6e6 100644 --- a/login/frontend/v1/login-ui/src/app/auth/auth.component.html +++ b/login/frontend/v1/login-ui/src/app/login/antisocial/antisocial.component.html @@ -1,18 +1,3 @@ - -
OR
{this.onAuthSuccess(response)}, + (response) => {this.onAuthFailure(response)} + ); + } + + onAuthSuccess(authResponse: AuthResponse): void { + this.authFailed = false; + this.authService.generateTokenCookies(authResponse); + this.authService.navigateToRedirectURI(noDelay); + } + + onAuthFailure(authorizeUserResponse): void { + if (authorizeUserResponse.status === 401) { + this.authFailed = true; + } + } + +} diff --git a/login/frontend/v1/login-ui/src/app/login/login.component.html b/login/frontend/v1/login-ui/src/app/login/login.component.html new file mode 100644 index 00000000..e69ed1b2 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/login/login.component.html @@ -0,0 +1,7 @@ +
+ +
\ No newline at end of file diff --git a/login/frontend/v1/login-ui/src/app/login/login.component.scss b/login/frontend/v1/login-ui/src/app/login/login.component.scss new file mode 100644 index 00000000..a0e0bd98 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/login/login.component.scss @@ -0,0 +1,15 @@ +@import '../../stylesheets/global'; + +.login-options { + background-color: $mycroft-white; + border-radius: 10px; + box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.12); + width: 320px; +} + +.mat-subheading-2 { + color: $mycroft-dark-grey; + margin-bottom: -15px; + margin-top: -15px; + text-align: center; +} diff --git a/login/frontend/v1/login-ui/src/app/login/login.component.ts b/login/frontend/v1/login-ui/src/app/login/login.component.ts new file mode 100644 index 00000000..6558afe1 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/login/login.component.ts @@ -0,0 +1,61 @@ +import { Component, OnInit } from '@angular/core'; +import { MatSnackBar } from "@angular/material"; +import { isArray } from "util"; + +import { AppService } from "../app.service"; +import { SocialLoginData } from "../app.service"; + +const noDelay = 0; + +@Component({ + selector: 'login-authenticate', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'] +}) +export class LoginComponent implements OnInit { + + constructor( + private authService: AppService, + public loginSnackbar: MatSnackBar + ) { } + + ngOnInit() { + let uriQuery = decodeURIComponent(window.location.search); + if (uriQuery.startsWith('?data')) { + this.parseUriQuery(uriQuery) + } else if (uriQuery.startsWith('?redirect')) { + localStorage.setItem( + 'redirect', + decodeURIComponent(window.location.search).slice(10) + ); + } + } + + parseUriQuery (uriQuery: string) { + let socialLoginData: SocialLoginData = null; + let parsedQuery = JSON.parse(uriQuery.slice(6)); + if (isArray(parsedQuery)) { + let firstItem = parsedQuery[0]; + if (firstItem.key === 'duplicated.user.email') { + let socialLoginErrorMsg = 'An account exists for the email ' + + 'address associated with the social network log in ' + + 'attempt. To enable log in using a social network, log ' + + 'in with your username and password and enable the ' + + 'social network in your account preferences.'; + this.loginSnackbar.open( + socialLoginErrorMsg, + null, + {duration: 30000} + ); + } + } else { + socialLoginData = parsedQuery; + this.authService.generateSocialLoginTokens(socialLoginData).subscribe( + (response) => { + this.authService.generateTokenCookies(response); + this.authService.navigateToRedirectURI(noDelay); + } + ); + } + } +} diff --git a/login/frontend/v1/login-ui/src/app/auth/auth.module.ts b/login/frontend/v1/login-ui/src/app/login/login.module.ts similarity index 63% rename from login/frontend/v1/login-ui/src/app/auth/auth.module.ts rename to login/frontend/v1/login-ui/src/app/login/login.module.ts index cce309e6..d67b17a6 100644 --- a/login/frontend/v1/login-ui/src/app/auth/auth.module.ts +++ b/login/frontend/v1/login-ui/src/app/login/login.module.ts @@ -14,12 +14,19 @@ import { import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; -import { AuthComponent } from './auth.component'; -import { AuthService } from "./auth.service"; +import { AntisocialComponent } from './antisocial/antisocial.component'; +import { LoginComponent } from './login.component'; +import { AppService } from "../app.service"; +import { SocialComponent } from './social/social.component'; @NgModule({ - declarations: [ AuthComponent ], - exports: [ AuthComponent ], + declarations: [ + AntisocialComponent, + LoginComponent, + SocialComponent + ], + entryComponents: [ LoginComponent ], + exports: [ LoginComponent ], imports: [ CommonModule, FlexLayoutModule, @@ -33,6 +40,6 @@ import { AuthService } from "./auth.service"; MatInputModule, MatSnackBarModule ], - providers: [ AuthService ] + providers: [ AppService ] }) -export class AuthModule { } +export class LoginModule { } diff --git a/login/frontend/v1/login-ui/src/app/login/social/social.component.html b/login/frontend/v1/login-ui/src/app/login/social/social.component.html new file mode 100644 index 00000000..2f9182b2 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/login/social/social.component.html @@ -0,0 +1,14 @@ + diff --git a/login/frontend/v1/login-ui/src/app/login/social/social.component.scss b/login/frontend/v1/login-ui/src/app/login/social/social.component.scss new file mode 100644 index 00000000..488fcdfa --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/login/social/social.component.scss @@ -0,0 +1,33 @@ +@import '../../../stylesheets/global'; + +button { + @include login-button; +} + +.social { + padding: 20px; + button { + margin-bottom: 15px; + } + fa-icon { + margin-right: 15px; + font-size: 28px; + } + .facebook-button { + background-color: #3b5998; + padding-left: 5px; + } + .github-button { + background-color: #333333; + padding-left: 5px; + } + .google-button { + background-color: #4285F4; + padding-left: 1px; + img { + margin-right: 10px; + width: 14%; + } + } +} + diff --git a/login/frontend/v1/login-ui/src/app/login/social/social.component.ts b/login/frontend/v1/login-ui/src/app/login/social/social.component.ts new file mode 100644 index 00000000..b7aab858 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/login/social/social.component.ts @@ -0,0 +1,33 @@ +import { Component, OnInit } from '@angular/core'; + +import { faFacebook, faGithub } from "@fortawesome/free-brands-svg-icons"; + +import { AppService } from "../../app.service"; + +@Component({ + selector: 'login-social', + templateUrl: './social.component.html', + styleUrls: ['./social.component.scss'] +}) +export class SocialComponent implements OnInit { + public facebookIcon = faFacebook; + public githubIcon = faGithub; + + constructor(private authService: AppService) { } + + ngOnInit() { } + + authenticateFacebook(): void { + this.authService.authenticateWithFacebook() + } + + authenticateGithub(): void { + this.authService.authenticateWithGithub(); + } + + authenticateGoogle(): void { + this.authService.authenticateWithGoogle(); + } + + +} diff --git a/login/frontend/v1/login-ui/src/app/logout/logout.component.html b/login/frontend/v1/login-ui/src/app/logout/logout.component.html new file mode 100644 index 00000000..0e61cc9f --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/logout/logout.component.html @@ -0,0 +1,4 @@ +
+
+ LOGGING OUT +
\ No newline at end of file diff --git a/login/frontend/v1/login-ui/src/app/logout/logout.component.scss b/login/frontend/v1/login-ui/src/app/logout/logout.component.scss new file mode 100644 index 00000000..9382cfb8 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/logout/logout.component.scss @@ -0,0 +1,28 @@ +@import "../../stylesheets/global"; + +// The angular material spinner was limiting in color choices we built our own +@mixin spinner-common { + animation: spin 1s ease-in-out infinite; + border: 2px solid rgba(255,255,255,.3); + border-radius: 50%; + display: inline-block; + height: 25px; + margin-right: 10px; + width: 25px; +} +@keyframes spin { + to { transform: rotate(360deg); } +} + +.mat-h3 { + color: $mycroft-secondary; + font-size: 40px; + margin-top: 50px; + text-align: center; +} + +.logout-spinner { + @include spinner-common; + border-right-color: $mycroft-secondary; + border-top-color: $mycroft-secondary; +} \ No newline at end of file diff --git a/login/frontend/v1/login-ui/src/app/logout/logout.component.ts b/login/frontend/v1/login-ui/src/app/logout/logout.component.ts new file mode 100644 index 00000000..0948beef --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/logout/logout.component.ts @@ -0,0 +1,33 @@ +import { Component, OnInit } from '@angular/core'; + +import { AppService } from "../app.service"; + +const oneSecond = 1000; + +@Component({ + selector: 'login-logout', + templateUrl: './logout.component.html', + styleUrls: ['./logout.component.scss'] +}) +export class LogoutComponent implements OnInit { + constructor(private appService: AppService) { } + + ngOnInit() { + let uriQuery = decodeURIComponent(window.location.search); + if (uriQuery.startsWith('?redirect')) { + localStorage.setItem( + 'redirect', + decodeURIComponent(window.location.search).slice(10) + ); + } + + this.appService.logout().subscribe( + (response) => {this.onLogoutSuccess()}, + ); + } + + onLogoutSuccess(): void { + this.appService.expireTokenCookies(); + this.appService.navigateToRedirectURI(oneSecond); + } +} diff --git a/login/frontend/v1/login-ui/src/app/logout/logout.module.ts b/login/frontend/v1/login-ui/src/app/logout/logout.module.ts new file mode 100644 index 00000000..26286767 --- /dev/null +++ b/login/frontend/v1/login-ui/src/app/logout/logout.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { LogoutComponent } from "./logout.component"; +import { AppService } from "../app.service"; + +@NgModule({ + imports: [ CommonModule ], + declarations: [ LogoutComponent ], + providers: [ AppService ] +}) +export class LogoutModule { } diff --git a/market/backend/v1/market-api/market_api/api.py b/market/backend/v1/market-api/market_api/api.py index bc19f3b6..f58628af 100644 --- a/market/backend/v1/market-api/market_api/api.py +++ b/market/backend/v1/market-api/market_api/api.py @@ -1,20 +1,30 @@ +"""Entry point for the API that supports the Mycroft Marketplace.""" from flask import Flask from flask_restful import Api from .config import get_config_location from market_api.endpoints import ( - SkillSummaryEndpoint, + AvailableSkillsEndpoint, SkillDetailEndpoint, SkillInstallEndpoint, + SkillInstallationsEndpoint, UserEndpoint ) - +# Define the Flask application marketplace = Flask(__name__) marketplace.config.from_object(get_config_location()) +# Define the API and its endpoints. marketplace_api = Api(marketplace) -marketplace_api.add_resource(SkillSummaryEndpoint, '/api/skills') -marketplace_api.add_resource(SkillDetailEndpoint, '/api/skill/') -marketplace_api.add_resource(SkillInstallEndpoint, '/api/install') +marketplace_api.add_resource(AvailableSkillsEndpoint, '/api/skill/available') +marketplace_api.add_resource( + SkillDetailEndpoint, + '/api/skill/detail/' +) +marketplace_api.add_resource(SkillInstallEndpoint, '/api/skill/install') +marketplace_api.add_resource( + SkillInstallationsEndpoint, + '/api/skill/installations' +) marketplace_api.add_resource(UserEndpoint, '/api/user') diff --git a/market/backend/v1/market-api/market_api/config.py b/market/backend/v1/market-api/market_api/config.py index e93beffe..d7ea2422 100644 --- a/market/backend/v1/market-api/market_api/config.py +++ b/market/backend/v1/market-api/market_api/config.py @@ -30,7 +30,7 @@ class ProdConfig(BaseConfig): def get_config_location(): """Determine which config to load based on environment""" environment_configs = dict( - dev='market_api.config.DevelopmentConfig', + dev=DevelopmentConfig, test=TestConfig, prod=ProdConfig ) diff --git a/market/backend/v1/market-api/market_api/endpoints/__init__.py b/market/backend/v1/market-api/market_api/endpoints/__init__.py index 036e1e7c..5360877e 100644 --- a/market/backend/v1/market-api/market_api/endpoints/__init__.py +++ b/market/backend/v1/market-api/market_api/endpoints/__init__.py @@ -1,4 +1,5 @@ +from .available_skills import AvailableSkillsEndpoint from .skill_detail import SkillDetailEndpoint from .skill_install import SkillInstallEndpoint -from .skill_summary import SkillSummaryEndpoint +from .skill_install_status import SkillInstallationsEndpoint from .user import UserEndpoint diff --git a/market/backend/v1/market-api/market_api/endpoints/available_skills.py b/market/backend/v1/market-api/market_api/endpoints/available_skills.py new file mode 100644 index 00000000..4f414fa6 --- /dev/null +++ b/market/backend/v1/market-api/market_api/endpoints/available_skills.py @@ -0,0 +1,107 @@ +"""Endpoint to provide skill summary data to the marketplace.""" +from collections import defaultdict +from http import HTTPStatus +from logging import getLogger +from typing import List + +import requests as service_request + +from selene_util.api import APIError, SeleneEndpoint +from .common import RepositorySkill + +_log = getLogger(__package__) + + +class AvailableSkillsEndpoint(SeleneEndpoint): + authentication_required = False + + def __init__(self): + super(AvailableSkillsEndpoint, self).__init__() + self.available_skills: List[RepositorySkill] = [] + self.response_skills: List[dict] = [] + self.skills_in_manifests = defaultdict(list) + + def get(self): + try: + self._get_available_skills() + except APIError: + pass + else: + self._build_response_data() + self.response = (self.response_skills, HTTPStatus.OK) + + return self.response + + def _get_available_skills(self): + """Retrieve all skills in the skill repository. + + The data is retrieved from a database table that is populated with + the contents of a JSON object in the mycroft-skills-data Github + repository. The JSON object contains metadata about each skill. + """ + skill_service_response = service_request.get( + self.config['SELENE_BASE_URL'] + '/skill/all' + ) + if skill_service_response.status_code != HTTPStatus.OK: + self._check_for_service_errors(skill_service_response) + self.available_skills = [ + RepositorySkill(**skill) for skill in skill_service_response.json() + ] + + def _build_response_data(self): + """Build the data to include in the response.""" + if self.request.query_string: + skills_to_include = self._filter_skills() + else: + skills_to_include = self.available_skills + self._reformat_skills(skills_to_include) + self._sort_skills() + + def _filter_skills(self) -> list: + """If search criteria exist, only return those skills that match.""" + skills_to_include = [] + + query_string = self.request.query_string.decode() + search_term = query_string.lower().split('=')[1] + for skill in self.available_skills: + search_term_match = ( + search_term is None or + search_term in skill.title.lower() or + search_term in skill.description.lower() or + search_term in skill.summary.lower() or + search_term in [c.lower() for c in skill.categories] or + search_term in [t.lower() for t in skill.tags] or + search_term in [t.lower() for t in skill.triggers] + ) + if search_term_match: + skills_to_include.append(skill) + + return skills_to_include + + def _reformat_skills(self, skills_to_include: List[RepositorySkill]): + """Build the response data from the skill service response""" + for skill in skills_to_include: + trigger = None + if skill.triggers: + trigger = skill.triggers[0] + skill_info = dict( + icon=skill.icon, + iconImage=skill.icon_image, + isMycroftMade=skill.is_mycroft_made, + isSystemSkill=skill.is_system_skill, + marketCategory=skill.market_category, + name=skill.skill_name, + summary=skill.summary, + title=skill.title, + trigger=trigger + ) + self.response_skills.append(skill_info) + + def _sort_skills(self): + """Sort the skills in alphabetical order""" + sorted_skills = sorted( + self.response_skills, + key=lambda skill: + skill['title'] + ) + self.response_skills = sorted_skills diff --git a/market/backend/v1/market-api/market_api/endpoints/common.py b/market/backend/v1/market-api/market_api/endpoints/common.py new file mode 100644 index 00000000..8ad3958d --- /dev/null +++ b/market/backend/v1/market-api/market_api/endpoints/common.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass, field +from typing import List + +from markdown import markdown + +DEFAULT_ICON_COLOR = '#6C7A89' +DEFAULT_ICON_NAME = 'comment-alt' +SYSTEM_CATEGORY = 'System' +UNDEFINED_CATEGORY = 'Not Categorized' +VALID_INSTALLATION_VALUES = ('failed', 'installed', 'installing', 'uninstalled') + + +@dataclass +class RepositorySkill(object): + """Represents a single skill defined in the Mycroft Skills repository.""" + branch: str + categories: List[str] + created: str + credits: List[dict] + description: str + icon: dict + id: str + is_mycroft_made: bool = field(init=False) + is_system_skill: bool = field(init=False) + last_update: str + market_category: str = field(init=False) + platforms: List[str] + repository_owner: str + repository_url: str + skill_name: str + summary: str + tags: List[str] + title: str + triggers: List[str] + icon_image: str = field(default=None) + + def __post_init__(self): + self.is_system_skill = False + if 'system' in self.tags: + self.is_system_skill = True + self.market_category = SYSTEM_CATEGORY + elif self.categories: + # a skill may have many categories. the first one in the + # list is considered the "primary" category. This is the + # category the marketplace will use to group the skill. + self.market_category = self.categories[0] + else: + self.market_category = UNDEFINED_CATEGORY + + if not self.icon: + self.icon = dict(icon=DEFAULT_ICON_NAME, color=DEFAULT_ICON_COLOR) + + self.is_mycroft_made = self.credits[0].get('name') == 'Mycroft AI' + self.summary = markdown(self.summary, output_format='html5') diff --git a/market/backend/v1/market-api/market_api/endpoints/skill_detail.py b/market/backend/v1/market-api/market_api/endpoints/skill_detail.py index 34d78e01..a52f98f9 100644 --- a/market/backend/v1/market-api/market_api/endpoints/skill_detail.py +++ b/market/backend/v1/market-api/market_api/endpoints/skill_detail.py @@ -4,44 +4,79 @@ from markdown import markdown import requests as service_request -from selene_util.api import SeleneEndpoint, APIError +from selene_util.api import APIError, SeleneEndpoint +from .common import RepositorySkill class SkillDetailEndpoint(SeleneEndpoint): + """"Supply the data that will populate the skill detail page.""" authentication_required = False def __init__(self): super(SkillDetailEndpoint, self).__init__() - self.skill_id = None + self.skill_name = None self.response_skill = None + self.manifest_skills = [] - def get(self, skill_id): - self.skill_id = skill_id + def get(self, skill_name): + """Process an HTTP GET request""" + self.skill_name = skill_name try: - self._authenticate() - self._get_skill_details() + repository_skill = self._get_skill_details() except APIError: pass else: - self._build_response_data() + self._build_response_data(repository_skill) self.response = (self.response_skill, HTTPStatus.OK) return self.response - def _get_skill_details(self): + def _get_skill_details(self) -> RepositorySkill: """Build the data to include in the response.""" skill_service_response = service_request.get( - self.config['SELENE_BASE_URL'] + '/skill/id/' + self.skill_id + self.config['SELENE_BASE_URL'] + '/skill/name/' + self.skill_name ) self._check_for_service_errors(skill_service_response) - self.response_skill = skill_service_response.json() - def _build_response_data(self): - self.response_skill['description'] = markdown( - self.response_skill['description'], - output_format='html5' + service_response = skill_service_response.json() + repository_skill = RepositorySkill(**service_response) + + return repository_skill + + def _build_response_data(self, repository_skill: RepositorySkill): + """Make some modifications to the response skill for the marketplace""" + self.response_skill = dict( + categories=repository_skill.categories, + credits=repository_skill.credits, + description=markdown( + repository_skill.description, + output_format='html5' + ), + icon=repository_skill.icon, + iconImage=repository_skill.icon_image, + isSystemSkill=repository_skill.is_system_skill, + name=repository_skill.skill_name, + worksOnMarkOne=( + 'all' in repository_skill.platforms or + 'platform_mark1' in repository_skill.platforms + ), + worksOnMarkTwo=( + 'all' in repository_skill.platforms or + 'platform_mark2' in repository_skill.platforms + ), + worksOnPicroft=( + 'all' in repository_skill.platforms or + 'platform_picroft' in repository_skill.platforms + ), + worksOnKDE=( + 'all' in repository_skill.platforms or + 'platform_plasmoid' in repository_skill.platforms + ), + repositoryUrl=repository_skill.repository_url, + summary=markdown( + repository_skill.summary, + output_format='html5' + ), + title=repository_skill.title, + triggers=repository_skill.triggers ) - self.response_skill['summary'] = markdown( - self.response_skill['summary'], - output_format='html5' - ) \ No newline at end of file diff --git a/market/backend/v1/market-api/market_api/endpoints/skill_install.py b/market/backend/v1/market-api/market_api/endpoints/skill_install.py index cc7fbf02..4bad87c7 100644 --- a/market/backend/v1/market-api/market_api/endpoints/skill_install.py +++ b/market/backend/v1/market-api/market_api/endpoints/skill_install.py @@ -16,7 +16,7 @@ class SkillInstallEndpoint(SeleneEndpoint): def __init__(self): super(SkillInstallEndpoint, self).__init__() self.device_uuid: str = None - self.installer_skill_settings: list = [] + self.installer_skill_settings: dict = {} self.installer_update_response = None def put(self): @@ -40,6 +40,9 @@ def _get_installed_skills(self): service_request_headers = { 'Authorization': 'Bearer ' + self.tartarus_token } + service_request_parameters = { + 'disableHide': 'true' + } service_url = ( self.config['TARTARUS_BASE_URL'] + '/user/' + @@ -48,7 +51,8 @@ def _get_installed_skills(self): ) user_service_response = requests.get( service_url, - headers=service_request_headers + headers=service_request_headers, + params=service_request_parameters ) if user_service_response.status_code != HTTPStatus.OK: self._check_for_service_errors(user_service_response) @@ -62,19 +66,33 @@ def _get_installed_skills(self): def _find_installer_skill(self, installed_skills): installer_skill = None - for skill in installed_skills['skills']: - if skill['skill']['name'] == 'Installer': - self.device_uuid = skill['deviceUuid'] - installer_skill = skill['skill'] - break + error_message = ( + 'install failed: installer skill not found' + ) + if "skills" in installed_skills: + for skill in installed_skills['skills']: + if skill['skill']['name'] == 'Installer': + self.device_uuid = skill['deviceUuid'] + installer_skill = skill['skill'] + break + if installer_skill is None: + _log.error(error_message) + self.response = (error_message, HTTPStatus.INTERNAL_SERVER_ERROR) + raise APIError() + else: + _log.error(error_message) + self.response = (error_message, HTTPStatus.INTERNAL_SERVER_ERROR) + raise APIError() return installer_skill def _find_installer_settings(self, installer_skill): for section in installer_skill['skillMetadata']['sections']: for setting in section['fields']: - if setting['type'] != 'label': - self.installer_skill_settings.append(setting) + if setting['name'] == 'to_install': + self.installer_skill_settings['to_install'] = (setting['value'], setting['uuid']) + elif setting['name'] == 'to_remove': + self.installer_skill_settings['to_remove'] = (setting['value'], setting['uuid']) def _apply_update(self): service_url = self.config['TARTARUS_BASE_URL'] + '/skill/field' @@ -97,23 +115,44 @@ def _apply_update(self): def _build_update_request_body(self): install_request_body = [] - for setting in self.installer_skill_settings: - if setting['name'] == 'installer_link': - setting_value = self.request.json['skill_url'] - elif setting['name'] == 'auto_install': - setting_value = True - else: + + action = self.request.json['action'] + section = self.request.json['section'] + skill_name = self.request.json['skill_name'] + + setting_section = self.installer_skill_settings[section] + if setting_section is not None: + try: + block = json.loads(setting_section[0]) + except ValueError: error_message = ( - 'found unexpected setting "{}" in installer skill settings' - ) - _log.error(error_message.format(setting['name'])) - raise ValueError(error_message.format(setting['name'])) - install_request_body.append( - dict( - fieldUuid=setting['uuid'], - deviceUuid=self.device_uuid, - value=setting_value + 'found unexpected section {}: {}' ) + _log.error(error_message.format(action, setting_section[0])) + raise ValueError(error_message.format(action, setting_section[0])) + else: + if action == 'add': + if not any(list(filter(lambda a: a['name'] == skill_name, block))): + block.append({'name': skill_name}) + elif action == 'remove': + block = list(filter(lambda x: x['name'] != skill_name, block)) + else: + error_message = ( + 'found unexpected action{}' + ) + _log.error(error_message.format(action)) + raise ValueError(error_message.format(action)) + else: + error_message = ( + 'found unexpected section {}' ) - + _log.error(error_message.format(section)) + raise ValueError(error_message.format(section)) + install_request_body.append( + dict( + fieldUuid=setting_section[1], + deviceUuid=self.device_uuid, + value=json.dumps(block).replace('"', '\\"') + ) + ) return dict(batch=install_request_body) diff --git a/market/backend/v1/market-api/market_api/endpoints/skill_install_status.py b/market/backend/v1/market-api/market_api/endpoints/skill_install_status.py new file mode 100644 index 00000000..c5e1dcea --- /dev/null +++ b/market/backend/v1/market-api/market_api/endpoints/skill_install_status.py @@ -0,0 +1,190 @@ +from collections import defaultdict +from dataclasses import asdict, dataclass +from http import HTTPStatus +from typing import List + +import requests as service_request + +from selene_util.api import APIError, SeleneEndpoint + +VALID_INSTALLATION_VALUES = ( + 'failed', + 'installed', + 'installing', + 'uninstalling' +) + + +@dataclass +class ManifestSkill(object): + """Represents a single skill on a device's skill manifest. + + Mycroft core keeps a manifest off all skills associated with a device. + This manifest shows the status of each skill as it relates to the device. + """ + failure_message: str + installation: str + name: str + + +class SkillInstallationsEndpoint(SeleneEndpoint): + authentication_required = False + + def __init__(self): + super(SkillInstallationsEndpoint, self).__init__() + self.skills_in_manifests = defaultdict(list) + + def get(self): + try: + self._get_install_statuses() + except APIError: + pass + else: + response_data = self._build_response_data() + self.response = (response_data, HTTPStatus.OK) + + return self.response + + def _get_install_statuses(self): + self._authenticate() + if self.authenticated: + skill_manifests = self._get_skill_manifests() + self._parse_skill_manifests(skill_manifests) + else: + self.response = ( + dict(installStatuses=[], failureReasons=[]), + HTTPStatus.OK + ) + + def _get_skill_manifests(self) -> dict: + """Get the skill manifests from each of a user's devices + + The skill manifests will be used to determine the status of each + skill as it relates to the marketplace. + """ + service_request_headers = { + 'Authorization': 'Bearer ' + self.tartarus_token + } + service_url = ( + self.config['TARTARUS_BASE_URL'] + + '/user/' + + self.user_uuid + + '/skillJson' + ) + response = service_request.get( + service_url, + headers=service_request_headers + ) + + self._check_for_service_errors(response) + + return response.json() + + def _parse_skill_manifests(self, skill_manifests: dict): + for device in skill_manifests.get('devices', []): + for skill in device['skills']: + manifest_skill = ManifestSkill( + failure_message=skill.get('failure_message'), + installation=skill['installation'], + name=skill['name'] + ) + self.skills_in_manifests[manifest_skill.name].append( + manifest_skill + ) + self.skills_in_manifests['mycroft-audio-record'].append( + ManifestSkill( + failure_message='', + installation='installed', + name='mycroft-audio-record' + ) + ) + + def _build_response_data(self) -> dict: + install_statuses = {} + failure_reasons = {} + for skill_name, manifest_skills in self.skills_in_manifests.items(): + skill_aggregator = SkillManifestAggregator(manifest_skills) + skill_aggregator.aggregate_manifest_skills() + if skill_aggregator.aggregate_skill.installation == 'failed': + failure_reasons[skill_name] = ( + skill_aggregator.aggregate_skill.failure_message + ) + install_statuses[skill_name] = ( + skill_aggregator.aggregate_skill.installation + ) + + return dict( + installStatuses=install_statuses, + failureReasons=failure_reasons + ) + + +class SkillManifestAggregator(object): + """Base class containing functionality shared by summary and detail""" + + def __init__(self, manifest_skills: List[ManifestSkill]): + self.manifest_skills = manifest_skills + self.aggregate_skill = ManifestSkill( + **asdict(manifest_skills[0])) + + def aggregate_manifest_skills(self): + """Aggregate skill data on all devices into a single skill. + + Each skill is represented once on the Marketplace, even though it can + be present on multiple devices. + """ + self._validate_install_status() + self._determine_install_status() + if self.aggregate_skill.installation == 'failed': + self._determine_failure_reason() + + def _validate_install_status(self): + for manifest_skill in self.manifest_skills: + if manifest_skill.installation not in VALID_INSTALLATION_VALUES: + raise ValueError( + '"{install_status}" is not a supported value of the ' + 'installation field in the skill manifest'.format( + install_status=manifest_skill.installation + ) + ) + + def _determine_install_status(self): + """Use skill data from all devices to determine install status. + + When a skill is installed via the Marketplace, it is installed to all + devices. The Marketplace will not mark a skill as "installed" until + install is complete on all devices. Until that point, the status will + be "installing". + + If the install fails on any device, the install will be flagged as a + failed install in the Marketplace. + """ + failed = [s.installation == 'failed' for s in + self.manifest_skills] + installing = [ + s.installation == 'installing' for s in self.manifest_skills + ] + uninstalling = [ + s.installation == 'uninstalling' for s in + self.manifest_skills + ] + installed = [ + s.installation == 'installed' for s in self.manifest_skills + ] + if any(failed): + self.aggregate_skill.installation = 'failed' + elif any(installing): + self.aggregate_skill.installation = 'installing' + elif any(uninstalling): + self.aggregate_skill.installation = 'uninstalling' + elif all(installed): + self.aggregate_skill.installation = 'installed' + + def _determine_failure_reason(self): + """When a skill fails to install, determine the reason""" + for manifest_skill in self.manifest_skills: + if manifest_skill.installation == 'failed': + self.aggregate_skill.failure_reason = ( + manifest_skill.failure_message + ) + break diff --git a/market/backend/v1/market-api/market_api/endpoints/skill_summary.py b/market/backend/v1/market-api/market_api/endpoints/skill_summary.py deleted file mode 100644 index 4b60cbd2..00000000 --- a/market/backend/v1/market-api/market_api/endpoints/skill_summary.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Endpoint to provide skill summary data to the marketplace.""" -from collections import defaultdict -from http import HTTPStatus -from logging import getLogger - -from markdown import markdown -import requests as service_request - -from selene_util.api import SeleneEndpoint, APIError - -UNDEFINED = 'Not Categorized' - -_log = getLogger(__package__) - - -class SkillSummaryEndpoint(SeleneEndpoint): - authentication_required = False - - def __init__(self): - super(SkillSummaryEndpoint, self).__init__() - self.available_skills: list = [] - self.installed_skills: list = [] - self.response_skills = defaultdict(list) - - def get(self): - try: - self._authenticate() - self._get_skills() - except APIError: - pass - else: - self._build_response_data() - self.response = (self.response_skills, HTTPStatus.OK) - - return self.response - - def _get_skills(self): - self._get_available_skills() - self._get_installed_skills() - - def _get_available_skills(self): - skill_service_response = service_request.get( - self.config['SELENE_BASE_URL'] + '/skill/all' - ) - if skill_service_response.status_code != HTTPStatus.OK: - self._check_for_service_errors(skill_service_response) - self.available_skills = skill_service_response.json() - - # TODO: this is a temporary measure until skill IDs can be assigned - # the list of installed skills returned by Tartarus are keyed by a value - # that is not guaranteed to be the same as the skill title in the skill - # metadata. a skill ID needs to be defined and propagated. - def _get_installed_skills(self): - """Get the skills a user has already installed on their device(s) - - Installed skills will be marked as such in the marketplace so a user - knows it is already installed. - """ - if self.authenticated: - service_request_headers = { - 'Authorization': 'Bearer ' + self.tartarus_token - } - service_url = ( - self.config['TARTARUS_BASE_URL'] + - '/user/' + - self.user_uuid + - '/skill' - ) - user_service_response = service_request.get( - service_url, - headers=service_request_headers - ) - if user_service_response.status_code != HTTPStatus.OK: - self._check_for_service_errors(user_service_response) - - response_skills = user_service_response.json() - for skill in response_skills.get('skills', []): - self.installed_skills.append(skill['skill']['name']) - - def _build_response_data(self): - """Build the data to include in the response.""" - if self.request.query_string: - skills_to_include = self._filter_skills() - else: - skills_to_include = self.available_skills - self._reformat_skills(skills_to_include) - self._sort_skills() - - def _filter_skills(self) -> list: - skills_to_include = [] - - query_string = self.request.query_string.decode() - search_term = query_string.lower().split('=')[1] - for skill in self.available_skills: - search_term_match = ( - search_term is None or - search_term in skill['title'].lower() or - search_term in skill['description'].lower() or - search_term in skill['summary'].lower() - ) - if skill['categories'] and not search_term_match: - search_term_match = ( - search_term in skill['categories'][0].lower() - ) - for trigger in skill['triggers']: - if search_term in trigger.lower(): - search_term_match = True - if search_term_match: - skills_to_include.append(skill) - - return skills_to_include - - def _reformat_skills(self, skills_to_include: list): - """Build the response data from the skill service response""" - for skill in skills_to_include: - if not skill['icon']: - skill['icon'] = dict(icon='comment-alt', color='#6C7A89') - skill_summary = dict( - credits=skill['credits'], - icon=skill['icon'], - icon_image=skill.get('icon_image'), - id=skill['id'], - installed=skill['title'] in self.installed_skills, - repository_url=skill['repository_url'], - summary=markdown(skill['summary'], output_format='html5'), - title=skill['title'], - triggers=skill['triggers'] - ) - if 'system' in skill['tags']: - skill_category = 'System' - elif skill['categories']: - # a skill may have many categories. the first one in the - # list is considered the "primary" category. This is the - # category the marketplace will use to group the skill. - skill_category = skill['categories'][0] - else: - skill_category = UNDEFINED - self.response_skills[skill_category].append(skill_summary) - - def _sort_skills(self): - """Sort the skills in alphabetical order""" - for skill_category, skills in self.response_skills.items(): - sorted_skills = sorted(skills, key=lambda skill: skill['title']) - self.response_skills[skill_category] = sorted_skills diff --git a/market/backend/v1/market-api/market_api/endpoints/user.py b/market/backend/v1/market-api/market_api/endpoints/user.py index 58e4a1f7..826859dd 100644 --- a/market/backend/v1/market-api/market_api/endpoints/user.py +++ b/market/backend/v1/market-api/market_api/endpoints/user.py @@ -14,6 +14,7 @@ def __init__(self): self.frontend_response = None def get(self): + """Process HTTP GET request for a user.""" try: self._authenticate() self._get_user() @@ -25,6 +26,7 @@ def get(self): return self.response def _get_user(self): + """Call the Tartarus endpoint for retrieving user information.""" service_request_headers = { 'Authorization': 'Bearer ' + self.tartarus_token } @@ -41,5 +43,6 @@ def _get_user(self): self.user = user_service_response.json() def _build_response(self): - response_data = dict(name=self.user['name']) + """Build the response to the user info request.""" + response_data = dict(name=self.user.get('name')) self.response = (response_data, HTTPStatus.OK) diff --git a/market/frontend/v1/market-ui/src/app/header/header.component.ts b/market/frontend/v1/market-ui/src/app/header/header.component.ts index 4570428c..a3f4e981 100644 --- a/market/frontend/v1/market-ui/src/app/header/header.component.ts +++ b/market/frontend/v1/market-ui/src/app/header/header.component.ts @@ -8,6 +8,7 @@ import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons"; +import { InstallService } from "../skills/install.service"; import { LoginService } from "../shared/login.service"; @Component({ @@ -24,7 +25,10 @@ export class HeaderComponent implements OnInit, OnDestroy { public menuButtonIcon = faCaretDown; public userMenuButtonText: string; - constructor(private loginService: LoginService) { } + constructor( + private installService: InstallService, + private loginService: LoginService + ) { } ngOnInit() { this.loginStatus = this.loginService.isLoggedIn.subscribe( @@ -51,18 +55,6 @@ export class HeaderComponent implements OnInit, OnDestroy { } logout() { - this.loginService.logout().subscribe( - (response) => { - let expiration = new Date(); - let domain = document.domain.replace('market.', ''); - document.cookie = 'seleneToken=""' + - '; expires=' + expiration.toUTCString() + - '; domain=' + domain; - document.cookie = 'tartarusToken=""' + - '; expires=' + expiration.toUTCString() + - '; domain=' + domain; - this.loginService.setLoginStatus(); - } - ) + this.loginService.logout(); } } diff --git a/market/frontend/v1/market-ui/src/app/header/header.module.ts b/market/frontend/v1/market-ui/src/app/header/header.module.ts index b372ed2c..3d0ff5d4 100644 --- a/market/frontend/v1/market-ui/src/app/header/header.module.ts +++ b/market/frontend/v1/market-ui/src/app/header/header.module.ts @@ -4,8 +4,9 @@ import { FlexLayoutModule } from "@angular/flex-layout"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; -import { MaterialModule } from "../shared/material.module"; +import { InstallService } from "../skills/install.service"; import { HeaderComponent } from './header.component'; +import { MaterialModule } from "../shared/material.module"; @NgModule({ imports: [ @@ -16,5 +17,6 @@ import { HeaderComponent } from './header.component'; ], declarations: [ HeaderComponent], exports: [ HeaderComponent ], + providers: [ InstallService ] }) export class HeaderModule { } diff --git a/market/frontend/v1/market-ui/src/app/shared/login.service.ts b/market/frontend/v1/market-ui/src/app/shared/login.service.ts index 0b7dea94..098536f1 100644 --- a/market/frontend/v1/market-ui/src/app/shared/login.service.ts +++ b/market/frontend/v1/market-ui/src/app/shared/login.service.ts @@ -1,11 +1,11 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Router } from "@angular/router"; import { Observable } from "rxjs/internal/Observable"; import { Subject } from "rxjs/internal/Subject"; import { environment } from "../../environments/environment"; +const redirectQuery = '?redirect='; export class User { name: string; } @@ -13,29 +13,29 @@ export class User { @Injectable() export class LoginService { public isLoggedIn = new Subject(); - public redirectUrl: string; - private logoutUrl = environment.loginUrl + '/api/logout'; + public loginUrl: string = environment.loginUrl + '/login'; + private logoutUrl = environment.loginUrl + '/logout'; private userUrl = '/api/user'; - constructor(private http: HttpClient, private router: Router) { + constructor(private http: HttpClient) { } getUser(): Observable { return this.http.get(this.userUrl); } - setLoginStatus() { + setLoginStatus(): void { let cookies = document.cookie, seleneTokenExists = cookies.includes('seleneToken'), seleneTokenEmpty = cookies.includes('seleneToken=""'); this.isLoggedIn.next( seleneTokenExists && !seleneTokenEmpty); } - login() { - window.location.assign(environment.loginUrl); + login(): void { + window.location.assign(this.loginUrl + redirectQuery + window.location.href); } - logout(): Observable { - return this.http.get(this.logoutUrl); + logout(): void { + window.location.assign(this.logoutUrl + redirectQuery + window.location.href); } } \ No newline at end of file diff --git a/market/frontend/v1/market-ui/src/app/shared/material.module.ts b/market/frontend/v1/market-ui/src/app/shared/material.module.ts index c7372f93..c77e3af5 100644 --- a/market/frontend/v1/market-ui/src/app/shared/material.module.ts +++ b/market/frontend/v1/market-ui/src/app/shared/material.module.ts @@ -6,6 +6,7 @@ import { MatDividerModule} from "@angular/material"; import { MatFormFieldModule} from "@angular/material/form-field"; import { MatInputModule} from "@angular/material/input"; import { MatMenuModule } from "@angular/material"; +import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; import { MatSelectModule } from "@angular/material/select"; import { MatSnackBarModule } from "@angular/material/snack-bar"; import { MatToolbarModule } from '@angular/material/toolbar'; @@ -21,6 +22,7 @@ import { MatTooltipModule } from "@angular/material/tooltip"; MatFormFieldModule, MatFormFieldModule, MatMenuModule, + MatProgressSpinnerModule, MatSelectModule, MatSnackBarModule, MatToolbarModule, @@ -34,6 +36,7 @@ import { MatTooltipModule } from "@angular/material/tooltip"; MatFormFieldModule, MatInputModule, MatMenuModule, + MatProgressSpinnerModule, MatSelectModule, MatSnackBarModule, MatToolbarModule, diff --git a/market/frontend/v1/market-ui/src/app/skills/install.service.ts b/market/frontend/v1/market-ui/src/app/skills/install.service.ts new file mode 100644 index 00000000..2e6c099a --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/install.service.ts @@ -0,0 +1,217 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, Subject } from "rxjs"; +import { AvailableSkill, SkillDetail } from "./skills.service"; + +// Status values that can be expected in the install status endpoint response. +type InstallStatus = 'failed' | 'installed' | 'installing' | 'uninstalling'; + +export interface SkillInstallStatus { + [key: string]: InstallStatus; +} + +export interface FailureReason { + [key: string]: string; +} + +export interface Installations { + failureReasons: FailureReason; + installStatuses: SkillInstallStatus; +} + +const inProgressStatuses = ['installing', 'uninstalling', 'failed']; +const installStatusUrl = '/api/skill/installations'; +const installerSettingsUrl = '/api/skill/install'; + +@Injectable({ + providedIn: 'root' +}) +export class InstallService { + public failureReasons: FailureReason; + public installStatuses = new Subject(); + public newInstallStatuses: SkillInstallStatus; + private prevInstallStatuses: SkillInstallStatus; + public statusNotifications = new Subject(); + + constructor(private http: HttpClient) { } + + /** Issue API call to get the current state of skill installations */ + getSkillInstallations() { + this.http.get(installStatusUrl).subscribe( + (installations) => { + this.newInstallStatuses = installations.installStatuses; + this.failureReasons = installations.failureReasons; + this.applyInstallStatusChanges(); + this.checkInstallationsInProgress(); + } + ) + } + + /** Emit changes to install statuses */ + applyInstallStatusChanges() { + if (this.prevInstallStatuses) { + Object.keys(this.prevInstallStatuses).forEach( + (skillName) => {this.compareStatuses(skillName);} + ); + } + this.prevInstallStatuses = this.newInstallStatuses; + this.installStatuses.next(this.newInstallStatuses); + } + + /** Compare the new status to the previous status looking for changes + * + * There is a race condition where the skill status on the device may not + * change between the time a user clicks a button in the marketplace and + * the next call of the status endpoint. + * + * For example, there is a period of time between the install button + * on the marketplace being clicked and device(s) retrieving that request. + * If the skill status endpoint is called within this time frame the status + * on the response object will not be "installing". This would result in + * the status reverting to its previous state. + * + * To combat this, we check that skill status changes follow a predefined + * progression before reflecting the status change on the UI. + */ + compareStatuses(skillName: string) { + let prevSkillStatus = this.prevInstallStatuses[skillName]; + let newSkillStatus = this.newInstallStatuses[skillName]; + + switch (prevSkillStatus) { + case ('installing'): { + if (newSkillStatus === 'installed') { + this.statusNotifications.next([skillName, newSkillStatus]); + this.removeFromInstallQueue(skillName).subscribe(); + } else if (newSkillStatus === 'failed') { + this.statusNotifications.next([skillName, 'install failed']); + } else { + this.newInstallStatuses[skillName] = prevSkillStatus; + } + break; + } + case ('uninstalling'): { + if (!newSkillStatus) { + this.statusNotifications.next([skillName, 'uninstalled']); + this.removeFromUninstallQueue(skillName).subscribe(); + } else if (newSkillStatus === 'failed') { + this.statusNotifications.next([skillName, 'uninstall failed']); + } else { + this.newInstallStatuses[skillName] = prevSkillStatus; + } + break; + } + case ('failed'): { + if (!newSkillStatus) { + this.statusNotifications.next([skillName, 'uninstalled']); + } else if (newSkillStatus != 'installed') { + this.statusNotifications.next([skillName, newSkillStatus]); + } else { + this.newInstallStatuses[skillName] = prevSkillStatus; + } + break; + } + } + } + + /*** + * Return the install status for the specified skill. + * + * System skills are treated differently than installed skills because they + * cannot be removed from the device. This function will make the differentiation. + * + * @param skill: single skill object, available or detail + * @param installStatuses: object containing all device skills and their status + */ + getSkillInstallStatus( + skill: AvailableSkill | SkillDetail, + installStatuses: SkillInstallStatus + ) { + let installStatus: string; + + if (skill.isSystemSkill) { + installStatus = 'system'; + } else { + installStatus = installStatuses[skill.name]; + } + + return installStatus; + } + + /** Poll at an interval to check the status of install/uninstall requests + * + * We want to avoid polling if we don't need it. Only poll if waiting for + * the result of a requested install/uninstall. + */ + checkInstallationsInProgress() { + let inProgress = Object.values(this.newInstallStatuses).filter( + (installStatus) => inProgressStatuses.includes(installStatus) + ); + if (inProgress.length > 0) { + setTimeout(() => {this.getSkillInstallations();}, 10000); + } + } + + /** + * Call the API to add a skill to the Installer skill's "to_install" setting. + * + * @param skillName: the skill being installed + */ + addToInstallQueue(skillName: string): Observable { + return this.http.put( + installerSettingsUrl, + { + action: "add", + section: "to_install", + skill_name: skillName + } + ) + } + + /** + * Call the API to add a skill to the Installer skill's "to_remove" setting. + * + * @param skillName: the skill being removed + */ + addToUninstallQueue(skillName: string): Observable { + return this.http.put( + installerSettingsUrl, + { + action: "add", + section: "to_remove", + skill_name: skillName + } + ) + } + + /** + * Call the API to remove a skill to the Installer skill's "to_install" setting. + * + * @param skillName: the skill being installed + */ + removeFromInstallQueue(skillName: string): Observable { + return this.http.put( + installerSettingsUrl, + { + action: "remove", + section: "to_install", + skill_name: skillName + } + ) + } + + /** + * Call the API to remove a skill to the Installer skill's "to_remove" setting. + * + * @param skillName: the skill being removed + */ + removeFromUninstallQueue(skillName: string): Observable { + return this.http.put( + installerSettingsUrl, + { + action: "remove", + section: "to_remove", + skill_name: skillName + } + ) + } +} diff --git a/market/frontend/v1/market-ui/src/app/skills/shared/install-button/install-button.component.html b/market/frontend/v1/market-ui/src/app/skills/shared/install-button/install-button.component.html new file mode 100644 index 00000000..1495b60a --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/shared/install-button/install-button.component.html @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/market/frontend/v1/market-ui/src/app/skills/shared/install-button/install-button.component.scss b/market/frontend/v1/market-ui/src/app/skills/shared/install-button/install-button.component.scss new file mode 100644 index 00000000..413c9d62 --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/shared/install-button/install-button.component.scss @@ -0,0 +1,79 @@ +@import '../../../../stylesheets/global.scss'; + +@mixin install-status { + border-radius: 4px; + letter-spacing: 0.5px; +} + +// The angular material spinner was limiting in color choices we built our own +@mixin spinner-common { + animation: spin 1s ease-in-out infinite; + border: 2px solid rgba(255,255,255,.3); + border-radius: 50%; + display: inline-block; + height: 15px; + margin-right: 10px; + width: 15px; +} +@keyframes spin { + to { transform: rotate(360deg); } +} + +fa-icon { + margin-right: 10px; + opacity: 0.6; +} + +.install-button { + @include install-status; + color: $mycroft-white; +} +.install-button:hover { + @include install-status; + background-color: $mycroft-tertiary-green; + color: $mycroft-secondary; +} + +.installed-button { + @include install-status; +} + +.installing-button { + @include install-status; + background-color: $mycroft-tertiary-green; + color: $mycroft-secondary; + mat-spinner { + float: left; + margin-right: 10px; + margin-top: 7px; + } +} +.installing-spinner { + @include spinner-common; + border-right-color: $mycroft-secondary; + border-top-color: $mycroft-secondary; +} + + +.uninstall-button { + @include install-status; + background-color: $mycroft-dark-grey; + color: $mycroft-white; +} +.uninstall-button:hover { + @include install-status; + border: none; + background-color: #eb5757; + color: $mycroft-white; +} + +.uninstalling-button { + @include install-status; + background-color: #eb5757; + color: $mycroft-white; +} +.uninstalling-spinner { + @include spinner-common; + border-right-color: $mycroft-white; + border-top-color: $mycroft-white; +} diff --git a/market/frontend/v1/market-ui/src/app/skills/shared/install-button/install-button.component.ts b/market/frontend/v1/market-ui/src/app/skills/shared/install-button/install-button.component.ts new file mode 100644 index 00000000..cb1509bd --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/shared/install-button/install-button.component.ts @@ -0,0 +1,145 @@ +/** + * This component does all things install button, which is a lot of things. + */ +import { Component, Input, OnInit } from '@angular/core'; +import { AvailableSkill } from "../../skills.service"; + +import { InstallService } from "../../install.service"; +import { faPlusCircle } from "@fortawesome/free-solid-svg-icons/faPlusCircle"; +import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash"; +import { faLock } from "@fortawesome/free-solid-svg-icons/faLock"; +import { MatSnackBar } from "@angular/material"; + +const fiveSeconds = 5000; +const tenSeconds = 10000; + + +@Component({ + selector: 'skill-install-button', + templateUrl: './install-button.component.html', + styleUrls: ['./install-button.component.scss'] +}) +export class InstallButtonComponent implements OnInit { + public addIcon = faPlusCircle; + @Input() private component: string; + public installButtonStyle: object; + public installStatus: string; + public removeIcon = faTrash; + @Input() public skill: AvailableSkill; + public skillLocked = faLock; + + constructor(private installSnackbar: MatSnackBar, private installService: InstallService) { } + + ngOnInit() { + this.installService.installStatuses.subscribe( + (installStatuses) => { + this.installStatus = this.installService.getSkillInstallStatus( + this.skill, + installStatuses + ) + } + ); + this.applyInstallButtonStyle(); + } + + /** + * Some of the install button style elements are different depending on + * which component it is displayed within. Use the ngStyle directive + * to specify these styles. + */ + applyInstallButtonStyle() { + if (this.component === 'skillDetail') { + this.installButtonStyle = {'width': '140px'} + } else if (this.component === 'skillSummary') { + this.installButtonStyle = {'width': '320px', 'margin-bottom': '15px'} + } + } + + /** + * Install a skill onto one or many devices + */ + install_skill() : void { + this.installService.addToInstallQueue(this.skill.name).subscribe( + (response) => { + this.onInstallSuccess(response) + }, + (response) => { + this.onInstallFailure(response) + } + ); + } + + /** + * Handle the successful install request + * + * This does not indicate that the install of the skill completed, only + * that the request to install a skill succeeded. Change the install + * button to an "installing" state. + * + * @param response + */ + onInstallSuccess(response) : void { + this.installService.newInstallStatuses[this.skill.name] = 'installing'; + this.installService.applyInstallStatusChanges(); + this.installService.checkInstallationsInProgress(); + this.installSnackbar.open( + 'The ' + this.skill.title + ' skill is being added ' + + 'to your devices. Please allow up to two minutes for ' + + 'installation to complete before using the skill.', + null, + {panelClass: 'mycroft-snackbar', duration: tenSeconds} + ); + } + + /** + * Handle the failure to install a skill. + * + * If a user attempts to install a skill without being logged in, show a + * snackbar to notify the user and give them the ability to log in. + * + * @param response - object representing the response from the API call + */ + onInstallFailure(response) : void { + if (response.status === 401) { + this.installSnackbar.open( + 'To install a skill, log in to your account.', + 'LOG IN', + {panelClass: 'mycroft-snackbar', duration: fiveSeconds} + ); + } + } + + /** + * Remove a skill from one or many devices + */ + uninstallSkill() : void { + this.installService.addToUninstallQueue(this.skill.name).subscribe( + (response) => { + this.onUninstallSuccess(response) + }, + ); + } + + /** + * Handle the successful install request + * + * This does not indicate that the install of the skill completed, only + * that the request to install a skill succeeded. Change the install + * button to an "installing" state. + * + * @param response + */ + onUninstallSuccess(response) : void { + this.installService.newInstallStatuses[this.skill.name] = 'uninstalling'; + this.installService.applyInstallStatusChanges(); + this.installService.checkInstallationsInProgress(); + this.installSnackbar.open( + 'The ' + this.skill.title + ' skill is ' + + 'uninstalling. Please allow up to a minute for the skill to be ' + + 'removed from devices.', + null, + {panelClass: 'mycroft-snackbar', duration: tenSeconds} + ); + } +} + diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-body/skill-detail-body.component.html b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-body/skill-detail-body.component.html new file mode 100644 index 00000000..635d5148 --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-body/skill-detail-body.component.html @@ -0,0 +1,57 @@ +
+ + +
+
+
hey mycroft
+
+
+ + {{trigger}} +
+
+
+
+
description
+
+
+
+
credits
+
+ {{credit.name}} +
+
+
+ + +
+
+
supported devices
+
+ + Mark I +
+
+ + Mark II +
+
+ + Picroft +
+
+ + KDE +
+
+
+
supported languages
+
English
+
+
+
category
+
{{skill.categories[0]}}
+
+
+ +
diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-body/skill-detail-body.component.scss b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-body/skill-detail-body.component.scss new file mode 100644 index 00000000..580b1fae --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-body/skill-detail-body.component.scss @@ -0,0 +1,46 @@ +@import '../../../../stylesheets/global'; + +.skill-detail-body { + background-color: $mycroft-white; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + margin-bottom: 50px; + padding-bottom: 3vh; + padding-left: 4vw; + padding-right: 4vw; + padding-top: 3vh; + .mat-subheading-1 { + color: $mycroft-dark-grey; + font-variant: small-caps; + font-weight: 500; + margin-bottom: 5px; + } + .mat-body-1 { + color: $mycroft-secondary; + } + .kde-icon { + height: 40px; + width: 40px; + } + .skill-detail-section { + margin-bottom: 30px; + } + .skill-detail-body-left { + min-width: 340px; + margin-right: 50px; + .skill-trigger { + @include skill-trigger; + @include ellipsis-overflow; + margin-right: 10px; + margin-bottom: 10px; + max-width: 340px; + } + } + .skill-detail-body-right { + margin-right: 20px; + white-space: nowrap; + img { + padding-right: 10px; + } + } +} diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-body/skill-detail-body.component.ts b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-body/skill-detail-body.component.ts new file mode 100644 index 00000000..a801a6e2 --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-body/skill-detail-body.component.ts @@ -0,0 +1,17 @@ +import { Component, Input } from '@angular/core'; + +import { SkillDetail } from "../../skills.service"; +import { faComment } from "@fortawesome/free-solid-svg-icons"; + +@Component({ + selector: 'market-skill-detail-body', + templateUrl: './skill-detail-body.component.html', + styleUrls: ['./skill-detail-body.component.scss'] +}) +export class SkillDetailBodyComponent { + @Input() public skill: SkillDetail; + public triggerIcon = faComment; + + constructor() { } + +} diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-header/skill-detail-header.component.html b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-header/skill-detail-header.component.html new file mode 100644 index 00000000..525edf01 --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-header/skill-detail-header.component.html @@ -0,0 +1,37 @@ + +
+ + +
+ + + + +
+

{{skill.title}}

+
+
+
+ + +
+ + +
+ +
+
+ +
\ No newline at end of file diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-header/skill-detail-header.component.scss b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-header/skill-detail-header.component.scss new file mode 100644 index 00000000..f8d7e911 --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-header/skill-detail-header.component.scss @@ -0,0 +1,48 @@ +@import '../../../../stylesheets/global'; + +.skill-detail-header { + background-color: #f7f9fa; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + padding-bottom: 3vh; + padding-left: 4vw; + padding-right: 4vw; + padding-top: 4vh; + .skill-detail-header-left { + color: $mycroft-secondary; + margin-right: 50px; + min-width: 340px; + fa { + font-size: 70px; + margin-right: 20px; + } + img { + margin-right: 20px; + } + h1 { + font-family: 'Roboto Mono', monospace; + margin-bottom: 10px; + margin-top: 0; + } + } + .skill-detail-header-right { + margin-right: 20px; + .install-button { + @include action-button; + width: 140px; + } + .install-button:hover { + background-color: $mycroft-tertiary-green; + color: $mycroft-secondary; + } + + .github-button { + color: $mycroft-dark-grey; + font-weight: normal; + width: 135px; + fa-icon { + padding-right: 5px; + } + } + } +} diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-header/skill-detail-header.component.ts b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-header/skill-detail-header.component.ts new file mode 100644 index 00000000..19f21ee3 --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail-header/skill-detail-header.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; +import { SkillDetail } from "../../skills.service"; +import { faCodeBranch } from "@fortawesome/free-solid-svg-icons"; + +@Component({ + selector: 'market-skill-detail-header', + templateUrl: './skill-detail-header.component.html', + styleUrls: ['./skill-detail-header.component.scss'] +}) +export class SkillDetailHeaderComponent { + public githubIcon = faCodeBranch; + @Input() public skill: SkillDetail; + + constructor() { } + + navigateToGithubRepo(githubRepoUrl) { + window.open(githubRepoUrl); + } +} diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.html b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.html index c77f9f54..4d25d7ac 100644 --- a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.html +++ b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.html @@ -5,102 +5,6 @@
- -
- - -
- - - - -
-

{{skill.title}}

-
-
-
- - -
-
- -
-
- -
-
- -
- - -
- - -
-
-
hey mycroft
-
-
- - {{trigger}} -
-
-
-
-
description
-
-
-
-
credits
-
- {{credit.name}} -
-
-
- - -
-
-
supported devices
-
- - Mark I -
-
- - Mark II -
-
- - Picroft -
-
- - KDE -
-
-
-
supported languages
-
English
-
-
-
category
-
{{skill.categories[0]}}
-
-
- -
- + +
diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.scss b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.scss index 3c37d3e7..fc14cc74 100644 --- a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.scss +++ b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.scss @@ -15,94 +15,4 @@ @include skill-detail-size; box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.12); border-radius: 10px; - .skill-detail-header { - background-color: #f7f9fa; - border-top-left-radius: 10px; - border-top-right-radius: 10px; - padding-bottom: 3vh; - padding-left: 4vw; - padding-right: 4vw; - padding-top: 4vh; - .skill-detail-header-left { - color: $mycroft-secondary; - margin-right: 50px; - min-width: 340px; - fa { - font-size: 70px; - margin-right: 20px; - } - img { - margin-right: 20px; - } - h1 { - font-family: 'Roboto Mono', monospace; - margin-bottom: 10px; - margin-top: 0; - } - } - .skill-detail-header-right { - margin-right: 20px; - .install-button { - @include action-button; - width: 140px; - } - .install-button:hover { - background-color: $mycroft-tertiary-green; - color: $mycroft-secondary; - } - - .github-button { - color: $mycroft-dark-grey; - font-weight: normal; - width: 135px; - fa-icon { - padding-right: 5px; - } - } - } - } - .skill-detail-body { - background-color: $mycroft-white; - border-bottom-left-radius: 10px; - border-bottom-right-radius: 10px; - margin-bottom: 50px; - padding-bottom: 3vh; - padding-left: 4vw; - padding-right: 4vw; - padding-top: 3vh; - .mat-subheading-1 { - color: $mycroft-dark-grey; - font-variant: small-caps; - font-weight: 500; - margin-bottom: 5px; - } - .mat-body-1 { - color: $mycroft-secondary; - } - .kde-icon { - height: 40px; - width: 40px; - } - .skill-detail-section { - margin-bottom: 30px; - } - .skill-detail-body-left { - min-width: 340px; - margin-right: 50px; - .skill-trigger { - @include skill-trigger; - @include ellipsis-overflow; - margin-right: 10px; - margin-bottom: 10px; - max-width: 340px; - } - } - .skill-detail-body-right { - margin-right: 20px; - white-space: nowrap; - img { - padding-right: 10px; - } - } - } } \ No newline at end of file diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.spec.ts b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.spec.ts deleted file mode 100644 index 84f74fee..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SkillDetailComponent } from './skill-detail.component'; - -describe('SkillDetailComponent', () => { - let component: SkillDetailComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ SkillDetailComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(SkillDetailComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.ts b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.ts index 2c8bef96..1396dc22 100644 --- a/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.ts +++ b/market/frontend/v1/market-ui/src/app/skills/skill-detail/skill-detail.component.ts @@ -1,11 +1,12 @@ import { Component, OnInit } from '@angular/core'; import { Router, ActivatedRoute, ParamMap } from '@angular/router'; import { Observable } from "rxjs/internal/Observable"; -import { switchMap } from "rxjs/operators"; +import { switchMap, tap } from "rxjs/operators"; -import { faArrowLeft, faComment, faCodeBranch } from '@fortawesome/free-solid-svg-icons'; +import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; -import { Skill, SkillsService } from "../skills.service"; +import { InstallService } from "../install.service"; +import { SkillDetail, SkillsService } from "../skills.service"; @Component({ selector: 'marketplace-skill-detail', @@ -14,27 +15,25 @@ import { Skill, SkillsService } from "../skills.service"; }) export class SkillDetailComponent implements OnInit { public backArrow = faArrowLeft; - public githubIcon = faCodeBranch; - public skill$: Observable; - public triggerIcon = faComment; + public skill$: Observable; constructor( + private installService: InstallService, private route: ActivatedRoute, private router: Router, - private service: SkillsService + private skillsService: SkillsService ) { } ngOnInit() { + this.installService.getSkillInstallations(); this.skill$ = this.route.paramMap.pipe( - switchMap((params: ParamMap) => this.service.getSkillById(params.get('id'))) + switchMap( + (params: ParamMap) => this.skillsService.getSkillById(params.get('skillName')) + ), + tap( + () => {this.installService.getSkillInstallations();} + ) ); } - - navigateToGithubRepo(githubRepoUrl) { - window.open(githubRepoUrl); - } - // returnToSummary() { - // this.router.navigate(['/skills']); - // } } diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.html b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.html deleted file mode 100644 index bc340cc3..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.html +++ /dev/null @@ -1,14 +0,0 @@ - -
- -
-
- - - -
-
diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.scss b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.scss deleted file mode 100644 index e9d05c47..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.scss +++ /dev/null @@ -1,23 +0,0 @@ -@import '../../../../stylesheets/global.scss'; - -mat-card-header { - justify-content: center; - margin-bottom: 15px; - .mycroft-icon { - left: 18px; - position: absolute; - top: 18px; - img { - height: 20px; - width: 20px; - } - } - .skill-icon { - //offset the skill icon by the width of the - // mycroft icon to center it on card - margin-left: -15px; - fa-icon { - font-size: 28px; - } - } -} diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.spec.ts b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.spec.ts deleted file mode 100644 index 016f65e4..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SkillCardHeaderComponent } from './skill-card-header.component'; - -describe('SkillCardHeaderComponent', () => { - let component: SkillCardHeaderComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ SkillCardHeaderComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(SkillCardHeaderComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.ts b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.ts deleted file mode 100644 index 33a07972..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card-header/skill-card-header.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Format the header portion of a skill summary card. This includes the icon - * for the skill and a Mycroft logo if the skill is authored by Mycroft AI. - */ -import { Component, Input, OnInit } from '@angular/core'; -import { Skill } from "../../skills.service"; -import { faMicrophoneAlt } from "@fortawesome/free-solid-svg-icons"; - -@Component({ - selector: 'market-skill-card-header', - templateUrl: './skill-card-header.component.html', - styleUrls: ['./skill-card-header.component.scss'] -}) -export class SkillCardHeaderComponent implements OnInit { - @Input() public skill: Skill; - public isMycroftMade: boolean; - - constructor() { } - - /** - * Include the Mycroft AI logo in the card header if Mycroft authored the skill - */ - ngOnInit() { - if (this.skill.credits) { - this.isMycroftMade = this.skill.credits[0]['name'] == 'Mycroft AI'; - } - } -} diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card-header.component.html b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card-header.component.html new file mode 100644 index 00000000..9946b36d --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card-header.component.html @@ -0,0 +1,34 @@ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ + +
+
diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card-header.component.scss b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card-header.component.scss new file mode 100644 index 00000000..71f79f95 --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card-header.component.scss @@ -0,0 +1,20 @@ +@import '../../../../stylesheets/global.scss'; + +.card-header { + margin-bottom: 20px; +} +.mycroft-icon { + width: 20px; + img { + height: 20px; + width: 20px; + } +} +.installed-icon { + width: 20px; + fa-icon { + color: $mycroft-tertiary-green; + font-size: 20px; + } +} + diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card-header.component.ts b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card-header.component.ts new file mode 100644 index 00000000..aaac3356 --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card-header.component.ts @@ -0,0 +1,36 @@ +/** + * Format the header portion of a skill summary card. This includes the icon + * for the skill and a Mycroft logo if the skill is authored by Mycroft AI. + */ +import { Component, Input, OnInit } from '@angular/core'; +import { AvailableSkill } from "../../skills.service"; +import { InstallService } from "../../install.service"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; + +@Component({ + selector: 'market-skill-card-header', + templateUrl: './skill-card-header.component.html', + styleUrls: ['./skill-card-header.component.scss'] +}) +export class SkillCardHeaderComponent implements OnInit { + public isInstalled: boolean; + public installedIcon = faCheckCircle; + @Input() public skill: AvailableSkill; + + constructor(private installService: InstallService) { } + + /** + * Include the Mycroft AI logo in the card header if Mycroft authored the skill + */ + ngOnInit() { + this.installService.installStatuses.subscribe( + (installStatuses) => { + let installStatus = this.installService.getSkillInstallStatus( + this.skill, + installStatuses + ); + this.isInstalled = ['system', 'installed'].includes(installStatus); + } + ); + } +} diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card.component.html b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card.component.html new file mode 100644 index 00000000..dd4221b1 --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card.component.html @@ -0,0 +1,22 @@ + + + +
+ + + {{skill.title ? skill.title : ' '}} + + +
+ + {{skill.trigger}} +
+
+ + +
+ + + + +
diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card.component.scss b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card.component.scss new file mode 100644 index 00000000..d04da80a --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card.component.scss @@ -0,0 +1,44 @@ +@import '../../../../stylesheets/global.scss'; + +@mixin card-width { + width: 320px; +} + +mat-card { + @include card-width; + border-radius: 10px; + cursor: pointer; + margin: 10px; + padding: 18px; + mat-card-title { + @include ellipsis-overflow; + color: $mycroft-secondary; + font-family: 'Roboto Mono', monospace; + font-weight: bold; + padding-bottom: 5px; + text-align: center; + } + mat-card-subtitle { + .skill-trigger { + @include ellipsis-overflow; + @include skill-trigger; + } + } + mat-card-content { + color: $mycroft-secondary; + @include ellipsis-overflow; + text-align: center; + } + mat-card-actions { + margin-left: 0; + margin-bottom: 0; + } +} + +mat-card:hover{ + box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.2); +} + +.login-snackbar { + text-align: center; +} \ No newline at end of file diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card.component.ts b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card.component.ts new file mode 100644 index 00000000..885cbaff --- /dev/null +++ b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-card/skill-card.component.ts @@ -0,0 +1,78 @@ +/** + * Format the header portion of a skill summary card. This includes the icon + * for the skill and a Mycroft logo if the skill is authored by Mycroft AI. + */ +import { Component, Input, OnInit} from '@angular/core'; +import { MatSnackBar } from "@angular/material"; + +import { AvailableSkill } from "../../skills.service"; +import { InstallService } from "../../install.service"; +import { faComment } from "@fortawesome/free-solid-svg-icons"; + +const fiveSeconds = 5000; + +@Component({ + selector: 'market-skill-card', + templateUrl: './skill-card.component.html', + styleUrls: ['./skill-card.component.scss'] +}) +export class SkillCardComponent implements OnInit { + @Input() public skill: AvailableSkill; + public voiceIcon = faComment; + + constructor( + public installSnackbar: MatSnackBar, + private installService: InstallService) { + + } + + ngOnInit() { + this.installService.statusNotifications.subscribe( + (statusChange) => { + this.showStatusNotifications(statusChange); + } + ); + } + + showStatusNotifications(statusChange: string[]) { + let notificationMessage: string; + let [skillName, notificationStatus] = statusChange; + if (this.skill.name === skillName) { + switch (notificationStatus) { + case ('installed'): { + notificationMessage = 'The ' + this.skill.title + ' skill has ' + + 'been added to all your devices.'; + this.showInstallStatusNotification(notificationMessage); + break; + } + case ('uninstalled'): { + notificationMessage = 'The ' + this.skill.title + ' skill has ' + + 'been removed from all your devices.'; + this.showInstallStatusNotification(notificationMessage); + break; + } + case ('install failed'): { + notificationMessage = 'The ' + this.skill.title + ' failed to ' + + 'install to one or more of your devices. Install will be ' + + 'retried until successful'; + this.showInstallStatusNotification(notificationMessage); + break; + } + case ('uninstall failed'): { + notificationMessage = 'The ' + this.skill.title + ' failed to ' + + 'uninstall from one or more of your devices. Uninstall ' + + 'will be retried until successful'; + this.showInstallStatusNotification(notificationMessage) + } + } + } + } + + showInstallStatusNotification(notificationMessage: string) { + this.installSnackbar.open( + notificationMessage, + '', + {panelClass: 'login-snackbar', duration: fiveSeconds} + ) + } +} \ No newline at end of file diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-toolbar/skill-toolbar.component.html b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-search/skill-search.component.html similarity index 57% rename from market/frontend/v1/market-ui/src/app/skills/skill-toolbar/skill-toolbar.component.html rename to market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-search/skill-search.component.html index c5ad012d..154f1e8f 100644 --- a/market/frontend/v1/market-ui/src/app/skills/skill-toolbar/skill-toolbar.component.html +++ b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-search/skill-search.component.html @@ -1,3 +1,4 @@ +
@@ -7,19 +8,9 @@
- - - - - - - - - - - -
+ +
- - - -
\ No newline at end of file + +
+ {{category}} +
+ + + +
+
diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.scss b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.scss index c2d21e51..7ad775c8 100644 --- a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.scss +++ b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.scss @@ -1,55 +1,22 @@ @import '../../../stylesheets/global'; -@mixin card-width { - width: 320px; -} - -mat-card { - @include card-width; - border-radius: 10px; - cursor: pointer; - margin: 10px; - padding: 18px; - mat-card-title { - @include ellipsis-overflow; - color: $mycroft-secondary; - font-family: 'Roboto Mono', monospace; - font-weight: bold; - padding-bottom: 5px; - text-align: center; - } - mat-card-subtitle { - .skill-trigger { - @include ellipsis-overflow; - @include skill-trigger; - } - } - mat-card-content { - color: $mycroft-secondary; - @include ellipsis-overflow; - text-align: center; - } - mat-card-actions { - margin-left: 0; - margin-bottom: 0; - button { - @include action-button; - @include card-width; - margin-bottom: 15px; - } - .installed-button { - background-color: $mycroft-tertiary-green; - fa-icon { - padding-right: 10px; - } - } - .install-button:hover { - background-color: $mycroft-tertiary-green; - color: $mycroft-secondary; +.skill-category { + background-color: $market-background; + mat-toolbar { + background-color: $market-background; + color: $mycroft-dark-grey; + font-size: xx-large; + margin-top: 20px; + padding-left: 10px; + fa-icon { + margin-right: 15px; } } } -mat-card:hover{ - box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.2); +.back-button { + color: $mycroft-dark-grey; + margin-left: 20px; + width: 100px; } + diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.spec.ts b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.spec.ts deleted file mode 100644 index a6dba93d..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SkillSummaryComponent } from './skill-summary.component'; - -describe('SkillSummaryComponent', () => { - let component: SkillSummaryComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ SkillSummaryComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(SkillSummaryComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.ts b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.ts index 08d9f72a..c108069d 100644 --- a/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.ts +++ b/market/frontend/v1/market-ui/src/app/skills/skill-summary/skill-summary.component.ts @@ -1,9 +1,7 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { MatSnackBar } from "@angular/material"; +import { Component, OnInit } from '@angular/core'; -import { faCheck, faComment } from '@fortawesome/free-solid-svg-icons'; - -import { SkillsService, Skill } from "../skills.service"; +import { SkillsService, AvailableSkill } from "../skills.service"; +import { InstallService } from "../install.service"; @Component({ selector: 'market-skill-summary', @@ -11,68 +9,41 @@ import { SkillsService, Skill } from "../skills.service"; styleUrls: ['./skill-summary.component.scss'], }) export class SkillSummaryComponent implements OnInit { - public installedIcon = faCheck; - @Input() public skills: Skill[]; - public voiceIcon = faComment; - private skillInstalling: Skill; + public skillCategories: string[]; + public availableSkills: AvailableSkill[]; + - constructor(public loginSnackbar: MatSnackBar, private skillsService: SkillsService) { } + constructor( + private installService: InstallService, + private skillsService: SkillsService, + ) { } - ngOnInit() { } + ngOnInit() { + this.getAvailableSkills(); + } - /** - * Install a skill onto one or many devices - * - * @param {Skill} skill - */ - install_skill(skill: Skill) : void { - this.skillInstalling = skill; - this.skillsService.installSkill(skill).subscribe( - (response) => { - this.onInstallSuccess(response) - }, - (response) => { - this.onInstallFailure(response) + /** Issue and API call to retrieve all the available skills. */ + getAvailableSkills(): void { + this.skillsService.getAvailableSkills().subscribe( + (skills) => { + this.availableSkills = skills; + this.skillCategories = this.skillsService.getSkillCategories(); + this.installService.getSkillInstallations(); } - ); + ) } - /** - * Handle the successful install attempt - * - * This does not indicate that the install of the skill completed, only - * that the request to install a skill succeeded. Change the install - * button to an "installing" state. - * - * @param response - */ - onInstallSuccess(response) : void { - this.loginSnackbar.open( - 'The ' + this.skillInstalling.title + ' skill is ' + - 'installing. Please allow up to two minutes for installation' + - 'to complete before using the skill. Only one skill can be ' + - 'installed at a time so please wait before selecting another' + - 'skill to install', - null, - {panelClass: 'mycroft-snackbar', duration:20000} + /** Skills are displayed by category; this function will do the filtering */ + filterSkillsByCategory(category: string): AvailableSkill[] { + return this.availableSkills.filter( + (skill) => skill.marketCategory === category ); } - /** - * Handle the failure to install a skill. - * - * If a user attempts to install a skill without being logged in, show a - * snackbar to notify the user and give them the ability to log in. - * - * @param response - object representing the response from the API call - */ - onInstallFailure(response) : void { - if (response.status === 401) { - this.loginSnackbar.open( - 'To install a skill, log in to your account.', - 'LOG IN', - {panelClass: 'mycroft-snackbar', duration: 5000} - ); - } + /** Change the view to display only those matching the search criteria. */ + showSearchResults(searchResults): void { + this.availableSkills = searchResults; + this.skillCategories = this.skillsService.getSkillCategories(); } + } diff --git a/market/frontend/v1/market-ui/src/app/skills/skill-toolbar/skill-toolbar.component.spec.ts b/market/frontend/v1/market-ui/src/app/skills/skill-toolbar/skill-toolbar.component.spec.ts deleted file mode 100644 index 0a148a3c..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skill-toolbar/skill-toolbar.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SkillSearchComponent } from './skill-toolbar.component'; - -describe('SkillSearchComponent', () => { - let component: SkillSearchComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ SkillSearchComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(SkillSearchComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/market/frontend/v1/market-ui/src/app/skills/skills-routing.module.ts b/market/frontend/v1/market-ui/src/app/skills/skills-routing.module.ts index dbb7eabf..6d7402be 100644 --- a/market/frontend/v1/market-ui/src/app/skills/skills-routing.module.ts +++ b/market/frontend/v1/market-ui/src/app/skills/skills-routing.module.ts @@ -1,12 +1,12 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; -import { SkillsComponent } from "./skills.component"; +import { SkillSummaryComponent } from "./skill-summary/skill-summary.component"; import { SkillDetailComponent } from "./skill-detail/skill-detail.component"; const routes: Routes = [ - { path: 'skills', component: SkillsComponent }, - { path: 'skill/:id', component: SkillDetailComponent} + { path: 'skills', component: SkillSummaryComponent }, + { path: 'skill/:skillName', component: SkillDetailComponent} ]; @NgModule({ diff --git a/market/frontend/v1/market-ui/src/app/skills/skills.component.html b/market/frontend/v1/market-ui/src/app/skills/skills.component.html deleted file mode 100644 index 93455db4..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skills.component.html +++ /dev/null @@ -1,7 +0,0 @@ - -
- - {{category}} - - -
\ No newline at end of file diff --git a/market/frontend/v1/market-ui/src/app/skills/skills.component.scss b/market/frontend/v1/market-ui/src/app/skills/skills.component.scss deleted file mode 100644 index 85c7b781..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skills.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -@import '../../stylesheets/global'; - -.skill-category { - background-color: $market-background; - mat-toolbar { - background-color: $market-background; - color: $mycroft-dark-grey; - font-size: xx-large; - margin-top: 20px; - padding-left: 10px; - fa-icon { - margin-right: 15px; - } - } -} diff --git a/market/frontend/v1/market-ui/src/app/skills/skills.component.spec.ts b/market/frontend/v1/market-ui/src/app/skills/skills.component.spec.ts deleted file mode 100644 index 498733d3..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skills.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SkillsComponent } from './skills.component'; - -describe('SkillsComponent', () => { - let component: SkillsComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ SkillsComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(SkillsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/market/frontend/v1/market-ui/src/app/skills/skills.component.ts b/market/frontend/v1/market-ui/src/app/skills/skills.component.ts deleted file mode 100644 index 565a8cc5..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skills.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -import { SkillsService } from './skills.service' - -@Component({ - selector: 'marketplace-skills', - templateUrl: './skills.component.html', - styleUrls: ['./skills.component.scss'] -}) -export class SkillsComponent implements OnInit { - public skillCategories: string[]; - public skills: Object; - - constructor(private skillsService: SkillsService) { } - - ngOnInit() { - this.getAllSkills(); - } - - getAllSkills(): void { - this.skillsService.getAllSkills().subscribe( - (skills) => { - this.skills = skills; - this.get_skill_categories(skills); - } - ) - } - - get_skill_categories(skills): void { - let skillCategories = [], - systemCategoryFound = false; - this.skillCategories = []; - Object.keys(skills).forEach( - categoryName => {skillCategories.push(categoryName);} - ); - skillCategories.sort(); - - // Make the "System" category display last, if it exists - skillCategories.forEach( - categoryName => { - if (categoryName === 'System') { - systemCategoryFound = true; - } else { - this.skillCategories.push(categoryName) - } - } - ); - if (systemCategoryFound) { - this.skillCategories.push('System') - } - } - - showSearchResults(searchResults): void { - this.skills = searchResults; - this.get_skill_categories(searchResults) - } -} diff --git a/market/frontend/v1/market-ui/src/app/skills/skills.module.ts b/market/frontend/v1/market-ui/src/app/skills/skills.module.ts index 20f5ff9e..d6e145e7 100644 --- a/market/frontend/v1/market-ui/src/app/skills/skills.module.ts +++ b/market/frontend/v1/market-ui/src/app/skills/skills.module.ts @@ -6,14 +6,18 @@ import { FormsModule } from "@angular/forms"; import { AngularFontAwesomeModule } from 'angular-font-awesome'; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { InstallButtonComponent } from './shared/install-button/install-button.component'; +import { InstallService } from "./install.service"; import { MaterialModule } from "../shared/material.module"; -import { SkillsComponent } from './skills.component' +import { SkillCardComponent } from './skill-summary/skill-card/skill-card.component'; +import { SkillDetailBodyComponent } from './skill-detail/skill-detail-body/skill-detail-body.component'; +import { SkillCardHeaderComponent } from "./skill-summary/skill-card/skill-card-header.component"; import { SkillDetailComponent } from "./skill-detail/skill-detail.component"; +import { SkillDetailHeaderComponent } from './skill-detail/skill-detail-header/skill-detail-header.component'; +import { SkillSearchComponent} from "./skill-summary/skill-search/skill-search.component"; import { SkillsRoutingModule } from "./skills-routing.module"; -import { SkillToolbarComponent } from "./skill-toolbar/skill-toolbar.component"; import { SkillsService } from "./skills.service"; import { SkillSummaryComponent } from "./skill-summary/skill-summary.component"; -import { SkillCardHeaderComponent } from './skill-summary/skill-card-header/skill-card-header.component'; @NgModule( { @@ -27,15 +31,18 @@ import { SkillCardHeaderComponent } from './skill-summary/skill-card-header/skil SkillsRoutingModule ], declarations: [ + SkillCardComponent, + SkillCardHeaderComponent, SkillDetailComponent, - SkillsComponent, - SkillToolbarComponent, + SkillDetailBodyComponent, + SkillDetailHeaderComponent, + SkillSearchComponent, SkillSummaryComponent, - SkillCardHeaderComponent + InstallButtonComponent ], - exports: [ SkillsComponent, SkillDetailComponent ], + exports: [ SkillSummaryComponent, SkillDetailComponent ], entryComponents: [ SkillDetailComponent ], - providers: [ SkillsService ] + providers: [ InstallService, SkillsService ] } ) export class SkillsModule { } diff --git a/market/frontend/v1/market-ui/src/app/skills/skills.service.spec.ts b/market/frontend/v1/market-ui/src/app/skills/skills.service.spec.ts deleted file mode 100644 index e5a3584d..00000000 --- a/market/frontend/v1/market-ui/src/app/skills/skills.service.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TestBed, inject } from '@angular/core/testing'; - -import { SkillsService } from './skills.service'; - -describe('SkillsService', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [SkillsService] - }); - }); - - it('should be created', inject([SkillsService], (service: SkillsService) => { - expect(service).toBeTruthy(); - })); -}); diff --git a/market/frontend/v1/market-ui/src/app/skills/skills.service.ts b/market/frontend/v1/market-ui/src/app/skills/skills.service.ts index ac84b35c..c18441b9 100644 --- a/market/frontend/v1/market-ui/src/app/skills/skills.service.ts +++ b/market/frontend/v1/market-ui/src/app/skills/skills.service.ts @@ -3,48 +3,113 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { Subject } from "rxjs/internal/Subject"; +import { tap } from "rxjs/operators"; -export class Skill { - id: number; - credits: Object; +export interface AvailableSkill { + icon: Object; + iconImage: string; + isMycroftMade: boolean; + isSystemSkill: boolean; + marketCategory: string; + name: string; + summary: string; + title: string; + trigger: string; +} + +export interface SkillCredits { + name: string; + github_id: string; +} + +export interface SkillDetail { categories: string[]; + credits: SkillCredits[]; description: string; icon: Object; - icon_image: string; - installed: boolean; - title: string; + iconImage: string; + isSystemSkill: boolean; + name: string; + repositoryUrl: string; summary: string; - repository_url: string; + title: string; triggers: string; + worksOnKDE: boolean; + worksOnMarkOne: boolean; + worksOnMarkTwo: boolean; + worksOnPicroft: boolean; } +const availableSkillsUrl = '/api/skill/available'; +const skillUrl = '/api/skill/detail/'; +const searchQuery = '?search='; + @Injectable() export class SkillsService { - private installUrl = '/api/install'; - private skillUrl = '/api/skill/'; - private skillsUrl = '/api/skills'; - private searchQuery = '?search='; + public availableSkills: AvailableSkill[]; public isFiltered = new Subject(); constructor(private http: HttpClient) { } - getAllSkills(): Observable { - return this.http.get(this.skillsUrl) + /** + * API call to retrieve all the skills available to the user + */ + getAvailableSkills(): Observable { + return this.http.get(availableSkillsUrl).pipe( + tap((skills) => {this.availableSkills = skills;}) + ) } - getSkillById(id: string): Observable { - return this.http.get(this.skillUrl + id) + /** + * Loop through the available skills to build a list of distinct categories. + */ + getSkillCategories(): string[] { + let orderedSkillCategories: string[] = [], + skillCategories: string[] = [], + systemCategoryFound: boolean = false; + this.availableSkills.forEach( + (skill) => { + if (!skillCategories.includes(skill.marketCategory)) { + skillCategories.push(skill.marketCategory);} + } + ); + skillCategories.sort(); + + // Make the "System" category display last, if it exists + skillCategories.forEach( + category => { + if (category === 'System') { + systemCategoryFound = true; + } else { + orderedSkillCategories.push(category) + } + } + ); + if (systemCategoryFound) { + orderedSkillCategories.push('System') + } + + return orderedSkillCategories; } - searchSkills(searchTerm: string): Observable { - this.isFiltered.next(!!searchTerm); - return this.http.get(this.skillsUrl + this.searchQuery + searchTerm) + /** + * API call to retrieve detailed information about a specified skill. + * + * @param skillName: name of the skill to retrieve + */ + getSkillById(skillName: string): Observable { + return this.http.get(skillUrl + skillName) } - installSkill(skill: Skill): Observable { - return this.http.put( - this.installUrl, - {skill_url: skill.repository_url} + /** + * API call to retrieve available skills that match the specified search term. + * + * @param searchTerm string used to search skills + */ + searchSkills(searchTerm: string): Observable { + this.isFiltered.next(!!searchTerm); + return this.http.get( + availableSkillsUrl + searchQuery + searchTerm ) } } diff --git a/market/frontend/v1/market-ui/src/stylesheets/base/_mycroft-colors.scss b/market/frontend/v1/market-ui/src/stylesheets/base/_mycroft-colors.scss index aac8a28f..11ff10c2 100644 --- a/market/frontend/v1/market-ui/src/stylesheets/base/_mycroft-colors.scss +++ b/market/frontend/v1/market-ui/src/stylesheets/base/_mycroft-colors.scss @@ -1,13 +1,13 @@ // These are the official Mycroft colors as defined by the design team. $mycroft-primary: #22a7f0; $mycroft-secondary: #2c3e50; -$mycroft-tertiary-blue: #81cfe0; -$mycroft-tertiary-green: #36d7b7; -$mycroft-tertiary-yellow: #c8f7c5; -$mycroft-tertiary-red: #ec644b; -$mycroft-tertiary-orange: #e9d460; +$mycroft-tertiary-blue: #96defe; +$mycroft-tertiary-green: #40dbb0; +$mycroft-tertiary-yellow: #fee255; +$mycroft-tertiary-grey: #5b6984; +$mycroft-tertiary-orange: #fd9e66; $mycroft-white: #ffffff; -$mycroft-black: #000000; +$mycroft-black: #111111; $mycroft-dark-grey: #6c7a89; $mycroft-light-grey: #bdc3c7; $mycroft-blue-grey: #e4f1fe; diff --git a/service/v1/skill/skill_service/api/api.py b/service/v1/skill/skill_service/api/api.py index a2672894..0d44f539 100644 --- a/service/v1/skill/skill_service/api/api.py +++ b/service/v1/skill/skill_service/api/api.py @@ -12,4 +12,4 @@ skill_api = Api(skill) skill_api.add_resource(AllSkillsEndpoint, '/skill/all') -skill_api.add_resource(SkillDetailEndpoint, '/skill/id/') +skill_api.add_resource(SkillDetailEndpoint, '/skill/name/') diff --git a/service/v1/skill/skill_service/api/endpoints/skill_detail.py b/service/v1/skill/skill_service/api/endpoints/skill_detail.py index 1197dd86..95dd6741 100644 --- a/service/v1/skill/skill_service/api/endpoints/skill_detail.py +++ b/service/v1/skill/skill_service/api/endpoints/skill_detail.py @@ -4,14 +4,13 @@ from flask_restful import Resource from .skill_formatter import format_skill_for_response -from ...repository.skill import select_skill_by_id +from ...repository.skill import select_skill_by_name class SkillDetailEndpoint(Resource): - - def get(self, skill_id): + def get(self, skill_name): """Handle HTP GET request for detailed information about a skill.""" - skill = select_skill_by_id(skill_id) + skill = select_skill_by_name(skill_name) response = format_skill_for_response(skill) return response, HTTPStatus.OK diff --git a/service/v1/skill/skill_service/repository/skill.py b/service/v1/skill/skill_service/repository/skill.py index 32fb26cf..b96e0ed4 100644 --- a/service/v1/skill/skill_service/repository/skill.py +++ b/service/v1/skill/skill_service/repository/skill.py @@ -47,14 +47,14 @@ def select_all_skills() -> list: return Skill.objects -def select_skill_by_id(skill_id: str) -> Skill: +def select_skill_by_name(skill_name: str) -> Skill: """ Query the database for a specified skill ID :return: the Skill object with an ID matching the argument """ connect_to_skill_db() - return Skill.objects(id=skill_id).first() + return Skill.objects(skill_name=skill_name).first() def upsert_skill(skill: Skill): diff --git a/shared/selene_util/api.py b/shared/selene_util/api.py index 48a02381..99967009 100644 --- a/shared/selene_util/api.py +++ b/shared/selene_util/api.py @@ -89,5 +89,8 @@ def _check_for_service_errors(self, service_response): if service_response.status_code == HTTPStatus.UNAUTHORIZED: self.response = (error_message, HTTPStatus.UNAUTHORIZED) else: - self.response = (error_message, HTTPStatus.INTERNAL_SERVER_ERROR) + self.response = ( + error_message, + HTTPStatus.INTERNAL_SERVER_ERROR + ) raise APIError()