diff --git a/apps/angular-console-e2e/src/integration/projects.spec.ts b/apps/angular-console-e2e/src/integration/projects.spec.ts index 5ce77942e4..de85744c45 100644 --- a/apps/angular-console-e2e/src/integration/projects.spec.ts +++ b/apps/angular-console-e2e/src/integration/projects.spec.ts @@ -25,15 +25,18 @@ describe('Projects', () => { }); it('checks that hot actions work', () => { - elementContainsText('button', 'Generate Component').click(); - elementContainsText('div.context-title', '@schematics/angular - component'); + cy.contains('angular-console-projects button', 'Component').should( + 'not.exist' + ); + + cy.contains('mat-icon', 'more_horiz') + .first() + .click(); + cy.contains('.cdk-overlay-pane button', 'Component').click(); + cy.contains('div.context-title', '@schematics/angular - component'); cy.get('input[name="project"]').should(($p: any) => { expect($p[0].value).to.equal('proj'); }); - }); - - it('provides navigation to and from command runners', () => { - cy.contains('Generate Component').click(); cy.get('.exit-action').click(); projectNames($p => { @@ -41,6 +44,38 @@ describe('Projects', () => { expect(texts($p)[0]).to.contain('proj'); expect(texts($p)[1]).to.contain('proj-e2e'); }); + cy.contains('angular-console-projects button', 'Component'); + + cy.contains('angular-console-projects button', 'Build') + .first() + .click(); + cy.contains('div.context-title', 'ng build proj'); + cy.get('.exit-action').click(); + cy.contains('angular-console-projects button', 'Serve') + .first() + .click(); + cy.contains('div.context-title', 'ng serve proj'); + cy.get('.exit-action').click(); + cy.contains('angular-console-projects button', 'Extract-i18n') + .first() + .click(); + cy.contains('div.context-title', 'ng run proj:extract-i18n'); + cy.get('.exit-action').click(); + cy.contains('angular-console-projects button', 'Test') + .first() + .click(); + cy.contains('div.context-title', 'ng test proj'); + cy.get('.exit-action').click(); + cy.contains('mat-icon', 'more_horiz') + .first() + .click(); + cy.contains('.cdk-overlay-pane button', 'Lint').click(); + cy.contains('div.context-title', 'ng lint proj'); + cy.get('.exit-action').click(); + + cy.contains('angular-console-projects button', 'Component').should( + 'not.exist' + ); }); it('should pin and unpin projects', () => { cy.get('.favorite-icon.favorited').should('not.exist'); diff --git a/libs/feature-workspaces/src/lib/graphql/update-recent-actions.graphql b/libs/feature-workspaces/src/lib/graphql/update-recent-actions.graphql new file mode 100644 index 0000000000..6ca0b8b1ae --- /dev/null +++ b/libs/feature-workspaces/src/lib/graphql/update-recent-actions.graphql @@ -0,0 +1,16 @@ +mutation SaveRecentAction( + $workspacePath: String! + $projectName: String! + $actionName: String! + $schematicName: String +) { + saveRecentAction( + workspacePath: $workspacePath + projectName: $projectName + actionName: $actionName + schematicName: $schematicName + ) { + actionName + schematicName + } +} diff --git a/libs/feature-workspaces/src/lib/graphql/workspace-schematics.graphql b/libs/feature-workspaces/src/lib/graphql/workspace-schematics.graphql new file mode 100644 index 0000000000..6ceaf1b528 --- /dev/null +++ b/libs/feature-workspaces/src/lib/graphql/workspace-schematics.graphql @@ -0,0 +1,12 @@ +query WorkspaceSchematics($path: String!) { + workspace(path: $path) { + schematicCollections { + name + schematics { + name + description + collection + } + } + } +} diff --git a/libs/feature-workspaces/src/lib/graphql/workspace.graphql b/libs/feature-workspaces/src/lib/graphql/workspace.graphql index 37a6e9aa53..a0c784dd94 100644 --- a/libs/feature-workspaces/src/lib/graphql/workspace.graphql +++ b/libs/feature-workspaces/src/lib/graphql/workspace.graphql @@ -13,6 +13,10 @@ query Workspace($path: String!) { architect { name } + recentActions { + actionName + schematicName + } } } } diff --git a/libs/feature-workspaces/src/lib/projects/projects.component.html b/libs/feature-workspaces/src/lib/projects/projects.component.html index 6cbc8f024a..d8f1e0d077 100644 --- a/libs/feature-workspaces/src/lib/projects/projects.component.html +++ b/libs/feature-workspaces/src/lib/projects/projects.component.html @@ -17,7 +17,7 @@ {{ projectFilterFormControl.value ? 'clear' : 'filter_list' }} - + Projects fxLayoutAlign="end center" > + + + + + + + {{ a.actionDescription }} + + + + + + diff --git a/libs/feature-workspaces/src/lib/projects/projects.component.ts b/libs/feature-workspaces/src/lib/projects/projects.component.ts index fcecdd81d0..74d08b7b6e 100644 --- a/libs/feature-workspaces/src/lib/projects/projects.component.ts +++ b/libs/feature-workspaces/src/lib/projects/projects.component.ts @@ -1,17 +1,41 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Project } from '@angular-console/schema'; import { combineLatest, Observable, of } from 'rxjs'; -import { map, startWith, switchMap, shareReplay, tap } from 'rxjs/operators'; +import { + map, + startWith, + switchMap, + shareReplay, + filter, + catchError, + tap +} from 'rxjs/operators'; import { PROJECTS_POLLING, Settings, CommandRunner, toggleItemInArray } from '@angular-console/utils'; -import { WorkspaceDocsGQL, WorkspaceGQL } from '../generated/graphql'; +import { + WorkspaceDocsGQL, + WorkspaceGQL, + SaveRecentActionGQL, + WorkspaceSchematicsGQL, + WorkspaceSchematics, + Workspace +} from '../generated/graphql'; import { FormControl } from '@angular/forms'; +export interface ProjectAction { + name: string; + actionDescription: string; + schematicName?: string; + link?: (string | { project: string })[]; +} +export interface ProjectActionMap { + [projectName: string]: ProjectAction[]; +} + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'angular-console-projects', @@ -21,10 +45,10 @@ import { FormControl } from '@angular/forms'; export class ProjectsComponent implements OnInit { workspacePath: string; pinnedProjectNames: string[]; - projects$: Observable; - filteredProjects$: Observable; - filteredPinnedProjects$: Observable; - filteredUnpinnedProjects$: Observable; + projects$: Observable; + filteredProjects$: Observable; + filteredPinnedProjects$: Observable; + filteredUnpinnedProjects$: Observable; docs$ = this.settings.showDocs ? this.route.params.pipe( switchMap(p => this.workspaceDocsGQL.fetch({ path: p.path })), @@ -42,41 +66,68 @@ export class ProjectsComponent implements OnInit { shareReplay() ); + recentActions$: Observable; + constructor( readonly settings: Settings, private readonly route: ActivatedRoute, private readonly workspaceGQL: WorkspaceGQL, private readonly workspaceDocsGQL: WorkspaceDocsGQL, - private readonly commandRunner: CommandRunner + private readonly saveRecentActionGQL: SaveRecentActionGQL, + private readonly commandRunner: CommandRunner, + private readonly workspaceSchematicsGQL: WorkspaceSchematicsGQL ) {} ngOnInit() { - this.projects$ = this.route.params.pipe( + const workspace$ = this.route.params.pipe( map(m => m.path), tap(path => { this.workspacePath = path; }), switchMap(path => { - return this.workspaceGQL.watch( - { - path - }, - { - pollInterval: PROJECTS_POLLING - } - ).valueChanges; + return combineLatest( + this.workspaceGQL.watch( + { + path + }, + { + pollInterval: PROJECTS_POLLING + } + ).valueChanges, + this.workspaceSchematicsGQL + .watch({ path }, { pollInterval: PROJECTS_POLLING }) + .valueChanges.pipe( + catchError(() => + of({ + data: { + workspace: { + schematicCollections: [] as WorkspaceSchematics.SchematicCollections[] + } + } + }) + ) + ) + ); }), - map((r: any) => { - const w = r.data.workspace; + filter(([r1, r2]) => Boolean(r1 && r2)), + map(([r1, r2]) => { + return { + workspace: r1.data.workspace, + schematicCollections: r2.data.workspace.schematicCollections + }; + }) + ); + this.projects$ = workspace$.pipe( + map(({ workspace, schematicCollections }) => { const workspaceSettings = this.settings.getWorkspace( this.workspacePath ); this.pinnedProjectNames = (workspaceSettings && workspaceSettings.pinnedProjectNames) || []; - const projects: Project[] = w.projects.map((p: Project) => { + const projects = workspace.projects.map((p: any) => { return { ...p, - actions: this.createActions(p) + actions: this.createActions(p, schematicCollections) }; }); return projects; @@ -97,6 +148,33 @@ export class ProjectsComponent implements OnInit { ) ); + const MAX_RECENT_ACTIONS = 5; + this.recentActions$ = this.projects$.pipe( + map(projects => { + return projects.reduce( + (projectActions, nextProject) => { + const recentActions = nextProject.recentActions + .map(recentAction => + (nextProject as any).actions.find( + (action: ProjectAction) => + action.name === recentAction.actionName && + action.schematicName === recentAction.schematicName + ) + ) + .filter(action => action !== undefined); + projectActions[nextProject.name] = [ + ...recentActions, + ...(nextProject as any).actions.filter( + (action: ProjectAction) => + action.link !== undefined && !recentActions.includes(action) + ) + ].slice(0, MAX_RECENT_ACTIONS); + return projectActions; + }, + {} + ); + }) + ); this.filteredPinnedProjects$ = this.filteredProjects$.pipe( map(projects => projects.filter(project => @@ -113,17 +191,43 @@ export class ProjectsComponent implements OnInit { ); } - private createActions(p: any) { + onActionTriggered( + workspacePath: string, + project: Workspace.Projects, + action: ProjectAction + ) { + this.saveRecentActionGQL + .mutate({ + workspacePath: workspacePath, + projectName: project.name, + schematicName: action.schematicName, + actionName: action.name + }) + .subscribe(); + } + + private createActions( + p: Workspace.Projects, + schematicCollections: WorkspaceSchematics.SchematicCollections[] + ) { return [ - ...createLinkForTask(p, 'serve', 'Serve'), - ...createLinkForTask(p, 'test', 'Test'), - ...createLinkForTask(p, 'build', 'Build'), - ...createLinkForTask(p, 'e2e', 'E2E'), - ...createLinkForCoreSchematic(p, 'component', 'Generate Component') - ] as any[]; + { actionDescription: 'Tasks' }, + ...(p.architect || []) + .map(task => { + if (!task) { + return undefined; + } + return createLinkForTask(p, task.name, task.name); + }) + .filter(isDefinedProjectAction), + ...schematicCollections.reduce((links, collection) => { + links.push(...createLinksForCollection(p, collection)); + return links; + }, []) + ]; } - onPinClick(p: Project) { + onPinClick(p: Workspace.Projects) { this.pinnedProjectNames = toggleItemInArray( this.pinnedProjectNames || [], p.name @@ -134,45 +238,75 @@ export class ProjectsComponent implements OnInit { this.settings.toggleProjectPin(this.workspacePath, p); } - trackByName(p: Project) { + trackByName(p: Workspace.Projects) { return p.name; } } function createLinkForTask( - project: Project, + project: Workspace.Projects, name: string, actionDescription: string ) { - if (project.architect.find(a => a.name === name)) { - return [{ actionDescription, link: ['../tasks', name, project.name] }]; + if ((project.architect || []).find(a => a.name === name)) { + return { actionDescription, name, link: ['../tasks', name, project.name] }; } else { - return []; + return undefined; } } -function createLinkForCoreSchematic( - project: Project, +function createLinksForCollection( + project: Workspace.Projects, + collection: WorkspaceSchematics.SchematicCollections +): ProjectAction[] { + const newLinks = (collection.schematics || []) + .map(schematic => + createLinkForSchematic( + project, + '@schematics/angular', + schematic ? schematic.name : '', + schematic ? schematic.name : '' + ) + ) + .filter(isDefinedProjectAction); + if (newLinks.length > 0) { + newLinks.unshift({ + name: collection.name, + actionDescription: collection.name + }); + } + return newLinks; +} + +function createLinkForSchematic( + project: Workspace.Projects, + schematicName: string, name: string, actionDescription: string -) { +): ProjectAction | undefined { if ( (project.projectType === 'application' || project.projectType === 'library') && - !project.architect.find(a => a.name === 'e2e') + !(project.architect || []).find(a => a.name === 'e2e') ) { - return [ - { - actionDescription, - link: [ - '../generate', - decodeURIComponent('@schematics/angular'), - name, - { project: project.name } - ] - } - ]; + return { + name, + schematicName, + actionDescription, + link: [ + '../generate', + decodeURIComponent(schematicName), + name, + { project: project.name } + ] + }; } else { - return []; + return undefined; } } + +function isDefinedProjectAction( + action: ProjectAction | undefined +): action is ProjectAction { + return action !== undefined; +} diff --git a/libs/schema/src/lib/generated/graphql-types.ts b/libs/schema/src/lib/generated/graphql-types.ts index 150af8b861..5333e36621 100644 --- a/libs/schema/src/lib/generated/graphql-types.ts +++ b/libs/schema/src/lib/generated/graphql-types.ts @@ -162,6 +162,8 @@ export interface Project { projectType: string; architect: Architect[]; + + recentActions: RecentAction[]; } export interface Architect { @@ -182,6 +184,12 @@ export interface ArchitectConfigurations { name: string; } +export interface RecentAction { + actionName: string; + + schematicName?: Maybe; +} + export interface Docs { workspaceDocs: Doc[]; @@ -289,6 +297,8 @@ export interface Mutation { updateSettings: Settings; + saveRecentAction: RecentAction[]; + installNodeJs?: Maybe; openInBrowser?: Maybe; @@ -449,6 +459,15 @@ export interface OpenInEditorMutationArgs { export interface UpdateSettingsMutationArgs { data: string; } +export interface SaveRecentActionMutationArgs { + workspacePath: string; + + projectName: string; + + actionName: string; + + schematicName?: Maybe; +} export interface OpenInBrowserMutationArgs { url: string; } @@ -1094,6 +1113,8 @@ export namespace ProjectResolvers { projectType?: ProjectTypeResolver; architect?: ArchitectResolver; + + recentActions?: RecentActionsResolver; } export type NameResolver = Resolver< @@ -1119,6 +1140,12 @@ export namespace ProjectResolvers { export interface ArchitectArgs { name?: Maybe; } + + export type RecentActionsResolver< + R = any[], + Parent = any, + Context = any + > = Resolver; } export namespace ArchitectResolvers { @@ -1180,6 +1207,25 @@ export namespace ArchitectConfigurationsResolvers { >; } +export namespace RecentActionResolvers { + export interface Resolvers { + actionName?: ActionNameResolver; + + schematicName?: SchematicNameResolver, TypeParent, Context>; + } + + export type ActionNameResolver< + R = string, + Parent = any, + Context = any + > = Resolver; + export type SchematicNameResolver< + R = Maybe, + Parent = any, + Context = any + > = Resolver; +} + export namespace DocsResolvers { export interface Resolvers { workspaceDocs?: WorkspaceDocsResolver; @@ -1508,6 +1554,8 @@ export namespace MutationResolvers { updateSettings?: UpdateSettingsResolver; + saveRecentAction?: SaveRecentActionResolver; + installNodeJs?: InstallNodeJsResolver, TypeParent, Context>; openInBrowser?: OpenInBrowserResolver, TypeParent, Context>; @@ -1653,6 +1701,21 @@ export namespace MutationResolvers { data: string; } + export type SaveRecentActionResolver< + R = any[], + Parent = {}, + Context = any + > = Resolver; + export interface SaveRecentActionArgs { + workspacePath: string; + + projectName: string; + + actionName: string; + + schematicName?: Maybe; + } + export type InstallNodeJsResolver< R = Maybe, Parent = {}, @@ -1856,6 +1919,7 @@ export interface IResolvers { Project?: ProjectResolvers.Resolvers; Architect?: ArchitectResolvers.Resolvers; ArchitectConfigurations?: ArchitectConfigurationsResolvers.Resolvers; + RecentAction?: RecentActionResolvers.Resolvers; Docs?: DocsResolvers.Resolvers; Doc?: DocResolvers.Resolvers; CompletionsTypes?: CompletionsTypesResolvers.Resolvers; diff --git a/libs/server/src/assets/schema.graphql b/libs/server/src/assets/schema.graphql index b39458dffb..c56b41079e 100644 --- a/libs/server/src/assets/schema.graphql +++ b/libs/server/src/assets/schema.graphql @@ -142,6 +142,12 @@ type Mutation { restartCommand(id: String!): RemoveResult openInEditor(editor: String!, path: String!): OpenInEditor updateSettings(data: String!): Settings! + saveRecentAction( + workspacePath: String! + projectName: String! + actionName: String! + schematicName: String + ): [RecentAction!]! installNodeJs: InstallNodeJsStatus openInBrowser(url: String!): OpenInBrowserResult selectDirectory( @@ -173,6 +179,12 @@ type Project { root: String! projectType: String! architect(name: String): [Architect!]! + recentActions: [RecentAction!]! +} + +type RecentAction { + actionName: String! + schematicName: String } type Schematic { diff --git a/libs/server/src/lib/api/read-projects.ts b/libs/server/src/lib/api/read-projects.ts index 2a2bff744e..c8c4478ade 100644 --- a/libs/server/src/lib/api/read-projects.ts +++ b/libs/server/src/lib/api/read-projects.ts @@ -1,15 +1,25 @@ import { normalizeSchema, readJsonFile } from '../utils/utils'; import { Project, Architect } from '@angular-console/schema'; import * as path from 'path'; +import { Store } from '@nrwl/angular-console-enterprise-electron'; +import { readRecentActions } from './read-recent-actions'; -export function readProjects(json: any): Project[] { +export function readProjects( + json: any, + baseDir: string, + store: Store +): Project[] { return Object.entries(json) .map(([key, value]: [string, any]) => { return { name: key, root: value.root, projectType: value.projectType, - architect: readArchitect(key, value.architect) + architect: readArchitect(key, value.architect), + recentActions: readRecentActions( + store, + path.join(baseDir, value.root, key) + ) }; }) .sort(compareProjects); diff --git a/libs/server/src/lib/api/read-recent-actions.ts b/libs/server/src/lib/api/read-recent-actions.ts new file mode 100644 index 0000000000..9481c5921e --- /dev/null +++ b/libs/server/src/lib/api/read-recent-actions.ts @@ -0,0 +1,35 @@ +import { Store } from '@nrwl/angular-console-enterprise-electron'; +import { RecentAction } from '@angular-console/schema'; + +function getRecentActionsKey(projectPath: string): string { + return `recentActions:${projectPath}`; +} + +export function readRecentActions( + store: Store, + projectPath: string +): RecentAction[] { + const actions: any[] = store.get(getRecentActionsKey(projectPath)); + return (actions || []).filter(action => action && !!action.actionName); +} + +export function storeTriggeredAction( + store: Store, + projectPath: string, + actionName: string, + schematicName?: string +) { + const MAX_RECENT_ACTIONS = 5; + const existingActions = readRecentActions(store, projectPath); + store.set( + getRecentActionsKey(projectPath), + [ + { actionName, schematicName }, + ...existingActions.filter( + action => + action.actionName !== actionName || + action.schematicName !== schematicName + ) + ].slice(0, MAX_RECENT_ACTIONS) + ); +} diff --git a/libs/server/src/lib/api/read-settings.ts b/libs/server/src/lib/api/read-settings.ts index bcbda2b1a7..0064cdd700 100644 --- a/libs/server/src/lib/api/read-settings.ts +++ b/libs/server/src/lib/api/read-settings.ts @@ -3,18 +3,20 @@ import { Settings } from '@angular-console/schema'; import { Subject } from 'rxjs'; import { platform } from 'os'; -/* tslint:disable */ export function readSettings(store: Store): Settings { const settings: Settings = store.get('settings') || {}; + // tslint:disable-next-line if (settings.canCollectData === undefined) { settings.canCollectData = store.get('canCollectData', false); } + // tslint:disable-next-line if (settings.recent === undefined) { settings.recent = []; } settings.recent.forEach(t => { + // tslint:disable-next-line if (t.pinnedProjectNames === undefined) { t.pinnedProjectNames = []; } diff --git a/libs/server/src/lib/resolvers/mutation.resolver.ts b/libs/server/src/lib/resolvers/mutation.resolver.ts index e7c5bc33b0..5a2ba125ac 100644 --- a/libs/server/src/lib/resolvers/mutation.resolver.ts +++ b/libs/server/src/lib/resolvers/mutation.resolver.ts @@ -12,6 +12,10 @@ import { SelectDirectory } from '../types'; import { platform } from 'os'; import { FileUtils } from '../utils/file-utils'; import { readJsonFile } from '../utils/utils'; +import { + storeTriggeredAction, + readRecentActions +} from '../api/read-recent-actions'; @Resolver() export class MutationResolver { @@ -290,6 +294,18 @@ export class MutationResolver { return readSettings(this.store); } + @Mutation() + saveRecentAction( + @Args('workspacePath') workspacePath: string, + @Args('projectName') projectName: string, + @Args('actionName') actionName: string, + @Args('schematicName') schematicName: string + ) { + const key = `${workspacePath}/${projectName}`; + storeTriggeredAction(this.store, key, actionName, schematicName); + return readRecentActions(this.store, key); + } + @Mutation() async openDoc(@Args('id') id: string) { const result = await docs.openDoc(id).toPromise(); diff --git a/libs/server/src/lib/resolvers/query.resolver.ts b/libs/server/src/lib/resolvers/query.resolver.ts index 5ab1cc0fb1..c4ed6f9dd8 100644 --- a/libs/server/src/lib/resolvers/query.resolver.ts +++ b/libs/server/src/lib/resolvers/query.resolver.ts @@ -66,7 +66,7 @@ export class QueryResolver { path: p, dependencies: readDependencies(packageJson), extensions: readExtensions(packageJson), - projects: readProjects(angularJson.projects), + projects: readProjects(angularJson.projects, p, this.store), npmScripts: readNpmScripts(p, packageJson), docs: {} as any, schematicCollections: [] as any diff --git a/libs/utils/src/lib/settings.service.ts b/libs/utils/src/lib/settings.service.ts index 48ac4b9d9a..9cd3cd5c06 100644 --- a/libs/utils/src/lib/settings.service.ts +++ b/libs/utils/src/lib/settings.service.ts @@ -7,7 +7,6 @@ import { } from './generated/graphql'; export { Settings as SettingsModels } from './generated/graphql'; -import { Project } from '@angular-console/schema'; export function toggleItemInArray(array: T[], item: T): T[] { return array.includes(item) @@ -62,7 +61,7 @@ export class Settings { this.store({ ...this.settings, recent: [...r, favorite] }); } - toggleProjectPin(path: string, project: Project): void { + toggleProjectPin(path: string, project: { name: string }): void { const workspace = this.getWorkspace(path); if (!workspace) { console.warn('No workspace found at path: ', path);