From ad2205ef36f8affc7061f728c1fa7733cdb89d4a Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 22 Jul 2019 13:40:07 -0400 Subject: [PATCH 01/78] Component to choose a repository owner and name --- ...epositoryHomeSelectionViewQuery.graphql.js | 310 ++++++++++++++++++ ...epositoryHomeSelectionView_user.graphql.js | 192 +++++++++++ lib/views/repository-home-selection-view.js | 178 ++++++++++ .../repository-home-selection-view.test.js | 178 ++++++++++ 4 files changed, 858 insertions(+) create mode 100644 lib/views/__generated__/repositoryHomeSelectionViewQuery.graphql.js create mode 100644 lib/views/__generated__/repositoryHomeSelectionView_user.graphql.js create mode 100644 lib/views/repository-home-selection-view.js create mode 100644 test/views/repository-home-selection-view.test.js diff --git a/lib/views/__generated__/repositoryHomeSelectionViewQuery.graphql.js b/lib/views/__generated__/repositoryHomeSelectionViewQuery.graphql.js new file mode 100644 index 0000000000..a946febcdf --- /dev/null +++ b/lib/views/__generated__/repositoryHomeSelectionViewQuery.graphql.js @@ -0,0 +1,310 @@ +/** + * @flow + * @relayHash 7b497054797ead3f15d4ce610e26e24c + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest } from 'relay-runtime'; +type repositoryHomeSelectionView_user$ref = any; +export type repositoryHomeSelectionViewQueryVariables = {| + id: string, + organizationCount: number, + organizationCursor?: ?string, +|}; +export type repositoryHomeSelectionViewQueryResponse = {| + +node: ?{| + +$fragmentRefs: repositoryHomeSelectionView_user$ref + |} +|}; +export type repositoryHomeSelectionViewQuery = {| + variables: repositoryHomeSelectionViewQueryVariables, + response: repositoryHomeSelectionViewQueryResponse, +|}; +*/ + + +/* +query repositoryHomeSelectionViewQuery( + $id: ID! + $organizationCount: Int! + $organizationCursor: String +) { + node(id: $id) { + __typename + ... on User { + ...repositoryHomeSelectionView_user_12CDS5 + } + id + } +} + +fragment repositoryHomeSelectionView_user_12CDS5 on User { + id + login + avatarUrl(size: 24) + organizations(first: $organizationCount, after: $organizationCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + login + avatarUrl(size: 24) + viewerCanCreateRepositories + __typename + } + } + } +} +*/ + +const node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "kind": "LocalArgument", + "name": "id", + "type": "ID!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "organizationCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "organizationCursor", + "type": "String", + "defaultValue": null + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } +], +v2 = { + "kind": "ScalarField", + "alias": null, + "name": "__typename", + "args": null, + "storageKey": null +}, +v3 = { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null +}, +v4 = { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null +}, +v5 = { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": [ + { + "kind": "Literal", + "name": "size", + "value": 24 + } + ], + "storageKey": "avatarUrl(size:24)" +}, +v6 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "organizationCursor" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "organizationCount" + } +]; +return { + "kind": "Request", + "fragment": { + "kind": "Fragment", + "name": "repositoryHomeSelectionViewQuery", + "type": "Query", + "metadata": null, + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": null, + "plural": false, + "selections": [ + { + "kind": "InlineFragment", + "type": "User", + "selections": [ + { + "kind": "FragmentSpread", + "name": "repositoryHomeSelectionView_user", + "args": [ + { + "kind": "Variable", + "name": "organizationCount", + "variableName": "organizationCount" + }, + { + "kind": "Variable", + "name": "organizationCursor", + "variableName": "organizationCursor" + } + ] + } + ] + } + ] + } + ] + }, + "operation": { + "kind": "Operation", + "name": "repositoryHomeSelectionViewQuery", + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": null, + "plural": false, + "selections": [ + (v2/*: any*/), + (v3/*: any*/), + { + "kind": "InlineFragment", + "type": "User", + "selections": [ + (v4/*: any*/), + (v5/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "organizations", + "storageKey": null, + "args": (v6/*: any*/), + "concreteType": "OrganizationConnection", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "hasNextPage", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "endCursor", + "args": null, + "storageKey": null + } + ] + }, + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "OrganizationEdge", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "cursor", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "Organization", + "plural": false, + "selections": [ + (v3/*: any*/), + (v4/*: any*/), + (v5/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanCreateRepositories", + "args": null, + "storageKey": null + }, + (v2/*: any*/) + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "organizations", + "args": (v6/*: any*/), + "handle": "connection", + "key": "RepositoryHomeSelectionView_organizations", + "filters": null + } + ] + } + ] + } + ] + }, + "params": { + "operationKind": "query", + "name": "repositoryHomeSelectionViewQuery", + "id": null, + "text": "query repositoryHomeSelectionViewQuery(\n $id: ID!\n $organizationCount: Int!\n $organizationCursor: String\n) {\n node(id: $id) {\n __typename\n ... on User {\n ...repositoryHomeSelectionView_user_12CDS5\n }\n id\n }\n}\n\nfragment repositoryHomeSelectionView_user_12CDS5 on User {\n id\n login\n avatarUrl(size: 24)\n organizations(first: $organizationCount, after: $organizationCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n login\n avatarUrl(size: 24)\n viewerCanCreateRepositories\n __typename\n }\n }\n }\n}\n", + "metadata": {} + } +}; +})(); +// prettier-ignore +(node/*: any*/).hash = '67e7843e3ff792e86e979cc948929ea3'; +module.exports = node; diff --git a/lib/views/__generated__/repositoryHomeSelectionView_user.graphql.js b/lib/views/__generated__/repositoryHomeSelectionView_user.graphql.js new file mode 100644 index 0000000000..d94e522482 --- /dev/null +++ b/lib/views/__generated__/repositoryHomeSelectionView_user.graphql.js @@ -0,0 +1,192 @@ +/** + * @flow + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ReaderFragment } from 'relay-runtime'; +import type { FragmentReference } from "relay-runtime"; +declare export opaque type repositoryHomeSelectionView_user$ref: FragmentReference; +declare export opaque type repositoryHomeSelectionView_user$fragmentType: repositoryHomeSelectionView_user$ref; +export type repositoryHomeSelectionView_user = {| + +id: string, + +login: string, + +avatarUrl: any, + +organizations: {| + +pageInfo: {| + +hasNextPage: boolean, + +endCursor: ?string, + |}, + +edges: ?$ReadOnlyArray, + |}, + +$refType: repositoryHomeSelectionView_user$ref, +|}; +export type repositoryHomeSelectionView_user$data = repositoryHomeSelectionView_user; +export type repositoryHomeSelectionView_user$key = { + +$data?: repositoryHomeSelectionView_user$data, + +$fragmentRefs: repositoryHomeSelectionView_user$ref, +}; +*/ + + +const node/*: ReaderFragment*/ = (function(){ +var v0 = { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null +}, +v1 = { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null +}, +v2 = { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": [ + { + "kind": "Literal", + "name": "size", + "value": 24 + } + ], + "storageKey": "avatarUrl(size:24)" +}; +return { + "kind": "Fragment", + "name": "repositoryHomeSelectionView_user", + "type": "User", + "metadata": { + "connection": [ + { + "count": "organizationCount", + "cursor": "organizationCursor", + "direction": "forward", + "path": [ + "organizations" + ] + } + ] + }, + "argumentDefinitions": [ + { + "kind": "LocalArgument", + "name": "organizationCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "organizationCursor", + "type": "String", + "defaultValue": null + } + ], + "selections": [ + (v0/*: any*/), + (v1/*: any*/), + (v2/*: any*/), + { + "kind": "LinkedField", + "alias": "organizations", + "name": "__RepositoryHomeSelectionView_organizations_connection", + "storageKey": null, + "args": null, + "concreteType": "OrganizationConnection", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "hasNextPage", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "endCursor", + "args": null, + "storageKey": null + } + ] + }, + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "OrganizationEdge", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "cursor", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "Organization", + "plural": false, + "selections": [ + (v0/*: any*/), + (v1/*: any*/), + (v2/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanCreateRepositories", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "__typename", + "args": null, + "storageKey": null + } + ] + } + ] + } + ] + } + ] +}; +})(); +// prettier-ignore +(node/*: any*/).hash = '11a1f1d0eac32bff0a3371217c0eede3'; +module.exports = node; diff --git a/lib/views/repository-home-selection-view.js b/lib/views/repository-home-selection-view.js new file mode 100644 index 0000000000..7c2c9821f1 --- /dev/null +++ b/lib/views/repository-home-selection-view.js @@ -0,0 +1,178 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {createPaginationContainer, graphql} from 'react-relay'; +import Select from 'react-select'; + +import AtomTextEditor from '../atom/atom-text-editor'; + +export class BareRepositoryHomeSelectionView extends React.Component { + static propTypes = { + // Relay + relay: PropTypes.shape({ + loadMore: PropTypes.func.isRequired, + }), + user: PropTypes.shape({ + id: PropTypes.string.isRequired, + login: PropTypes.string.isRequired, + avatarUrl: PropTypes.string.isRequired, + organizations: PropTypes.shape({ + edges: PropTypes.arrayOf(PropTypes.shape({ + node: PropTypes.shape({ + id: PropTypes.string.isRequired, + login: PropTypes.string.isRequired, + avatarUrl: PropTypes.string.isRequired, + viewerCanCreateRepositories: PropTypes.bool.isRequired, + }), + })), + }).isRequired, + }), + + // Model + nameBuffer: PropTypes.object.isRequired, + isLoading: PropTypes.bool.isRequired, + selectedOwner: PropTypes.string.isRequired, + + // Selection callback + didChooseOwner: PropTypes.func.isRequired, + } + + render() { + const owners = this.getOwners(); + const currentOwner = owners.find(o => o.id === this.props.selectedOwner) || owners[0]; + + return ( +
+ + HTTPS + + +
+
+ +
+ + + ); + } + + handleProtocolChange = event => { + this.props.didChangeProtocol(event.target.value); + } +} diff --git a/test/views/remote-configuration-view.test.js b/test/views/remote-configuration-view.test.js new file mode 100644 index 0000000000..8503f8efa0 --- /dev/null +++ b/test/views/remote-configuration-view.test.js @@ -0,0 +1,39 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import {TextBuffer} from 'atom'; + +import RemoteConfigurationView from '../../lib/views/remote-configuration-view'; + +describe('RemoteConfigurationView', function() { + function buildApp(override = {}) { + const sourceRemoteBuffer = new TextBuffer(); + return ( + {}} + sourceRemoteBuffer={sourceRemoteBuffer} + {...override} + /> + ); + } + + it('passes models to the appropriate controls', function() { + const sourceRemoteBuffer = new TextBuffer(); + const currentProtocol = 'ssh'; + + const wrapper = shallow(buildApp({currentProtocol, sourceRemoteBuffer})); + assert.strictEqual(wrapper.find('AtomTextEditor').prop('buffer'), sourceRemoteBuffer); + assert.isFalse(wrapper.find('.github-RemoteConfiguration-protocolOption--https input.input-radio').prop('checked')); + assert.isTrue(wrapper.find('.github-RemoteConfiguration-protocolOption--ssh input.input-radio').prop('checked')); + }); + + it('calls a callback when the protocol is changed', function() { + const didChangeProtocol = sinon.spy(); + + const wrapper = shallow(buildApp({didChangeProtocol})); + wrapper.find('.github-RemoteConfiguration-protocolOption--ssh input.input-radio') + .prop('onChange')({target: {value: 'ssh'}}); + + assert.isTrue(didChangeProtocol.calledWith('ssh')); + }); +}); From 2a4929efce6cea37942a193e4ebb866543a532f3 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 24 Jul 2019 09:11:32 -0400 Subject: [PATCH 06/78] wip --- lib/containers/create-dialog-container.js | 7 + lib/controllers/dialogs-controller.js | 8 + .../createRepositoryMutation.graphql.js | 171 ++++++++++++++++++ lib/mutations/create-repository.js | 36 ++++ lib/views/create-dialog-view.js | 141 +++++++++++++++ lib/views/create-dialog.js | 7 + .../create-dialog-container.test.js | 57 ++++++ test/controllers/dialogs-controller.test.js | 4 + test/views/create-dialog-view.test.js | 58 ++++++ test/views/create-dialog.test.js | 42 +++++ 10 files changed, 531 insertions(+) create mode 100644 lib/containers/create-dialog-container.js create mode 100644 lib/mutations/__generated__/createRepositoryMutation.graphql.js create mode 100644 lib/mutations/create-repository.js create mode 100644 lib/views/create-dialog-view.js create mode 100644 lib/views/create-dialog.js create mode 100644 test/containers/create-dialog-container.test.js create mode 100644 test/views/create-dialog-view.test.js create mode 100644 test/views/create-dialog.test.js diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js new file mode 100644 index 0000000000..68df1d2c6f --- /dev/null +++ b/lib/containers/create-dialog-container.js @@ -0,0 +1,7 @@ +import React from 'react'; + +export default class CreateDialogContainer extends React.Component { + render() { + return null; + } +} diff --git a/lib/controllers/dialogs-controller.js b/lib/controllers/dialogs-controller.js index 6c3a2f845f..aac2aadd5d 100644 --- a/lib/controllers/dialogs-controller.js +++ b/lib/controllers/dialogs-controller.js @@ -151,4 +151,12 @@ export const dialogRequests = { commit() { return new DialogRequest('commit'); }, + + create() { + return new DialogRequest('create'); + }, + + publish({localDir}) { + return new DialogRequest('publish', {localDir}); + }, }; diff --git a/lib/mutations/__generated__/createRepositoryMutation.graphql.js b/lib/mutations/__generated__/createRepositoryMutation.graphql.js new file mode 100644 index 0000000000..c86ca20d7b --- /dev/null +++ b/lib/mutations/__generated__/createRepositoryMutation.graphql.js @@ -0,0 +1,171 @@ +/** + * @flow + * @relayHash f8963f231e08ebd4d2cffd1223e19770 + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest } from 'relay-runtime'; +export type RepositoryVisibility = "INTERNAL" | "PRIVATE" | "PUBLIC" | "%future added value"; +export type CreateRepositoryInput = {| + name: string, + ownerId?: ?string, + description?: ?string, + visibility: RepositoryVisibility, + template?: ?boolean, + homepageUrl?: ?any, + hasWikiEnabled?: ?boolean, + hasIssuesEnabled?: ?boolean, + teamId?: ?string, + clientMutationId?: ?string, +|}; +export type createRepositoryMutationVariables = {| + input: CreateRepositoryInput +|}; +export type createRepositoryMutationResponse = {| + +createRepository: ?{| + +repository: ?{| + +sshUrl: any, + +url: any, + |} + |} +|}; +export type createRepositoryMutation = {| + variables: createRepositoryMutationVariables, + response: createRepositoryMutationResponse, +|}; +*/ + + +/* +mutation createRepositoryMutation( + $input: CreateRepositoryInput! +) { + createRepository(input: $input) { + repository { + sshUrl + url + id + } + } +} +*/ + +const node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "kind": "LocalArgument", + "name": "input", + "type": "CreateRepositoryInput!", + "defaultValue": null + } +], +v1 = [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } +], +v2 = { + "kind": "ScalarField", + "alias": null, + "name": "sshUrl", + "args": null, + "storageKey": null +}, +v3 = { + "kind": "ScalarField", + "alias": null, + "name": "url", + "args": null, + "storageKey": null +}; +return { + "kind": "Request", + "fragment": { + "kind": "Fragment", + "name": "createRepositoryMutation", + "type": "Mutation", + "metadata": null, + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "createRepository", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": "CreateRepositoryPayload", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "repository", + "storageKey": null, + "args": null, + "concreteType": "Repository", + "plural": false, + "selections": [ + (v2/*: any*/), + (v3/*: any*/) + ] + } + ] + } + ] + }, + "operation": { + "kind": "Operation", + "name": "createRepositoryMutation", + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "createRepository", + "storageKey": null, + "args": (v1/*: any*/), + "concreteType": "CreateRepositoryPayload", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "repository", + "storageKey": null, + "args": null, + "concreteType": "Repository", + "plural": false, + "selections": [ + (v2/*: any*/), + (v3/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null + } + ] + } + ] + } + ] + }, + "params": { + "operationKind": "mutation", + "name": "createRepositoryMutation", + "id": null, + "text": "mutation createRepositoryMutation(\n $input: CreateRepositoryInput!\n) {\n createRepository(input: $input) {\n repository {\n sshUrl\n url\n id\n }\n }\n}\n", + "metadata": {} + } +}; +})(); +// prettier-ignore +(node/*: any*/).hash = 'e8f154d9f35411a15f77583bb44f7ed5'; +module.exports = node; diff --git a/lib/mutations/create-repository.js b/lib/mutations/create-repository.js new file mode 100644 index 0000000000..857a9b39cf --- /dev/null +++ b/lib/mutations/create-repository.js @@ -0,0 +1,36 @@ +/* istanbul ignore file */ + +import {commitMutation, graphql} from 'react-relay'; + +const mutation = graphql` + mutation createRepositoryMutation($input: CreateRepositoryInput!) { + createRepository(input: $input) { + repository { + sshUrl + url + } + } + } +`; + +export default (environment, {name, ownerID, visibility}) => { + const variables = { + input: { + name, + ownerId: ownerID, + visibility, + }, + }; + + return new Promise((resolve, reject) => { + commitMutation( + environment, + { + mutation, + variables, + onCompleted: resolve, + onError: reject, + }, + ); + }); +}; diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js new file mode 100644 index 0000000000..4bd43d157c --- /dev/null +++ b/lib/views/create-dialog-view.js @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {TextBuffer} from 'atom'; + +import DialogView from './dialog-view'; +import RepositoryHomeSelectionView from './repository-home-selection-view'; +import DirectorySelect from './directory-select'; +import RemoteConfigurationView from './remote-configuration-view'; +import Octicon from '../atom/octicon'; + +const DIALOG_TEXT = { + create: { + heading: 'Create GitHub repository', + hostPath: 'Destination path:', + progressMessage: 'Creating repository...', + acceptText: 'Create', + }, + publish: { + heading: 'Publish GitHub repository', + hostPath: 'Local path:', + progressMessage: 'Publishing repository...', + acceptText: 'Publish', + }, +}; + +export default class CreateDialogView extends React.Component { + static propTypes = { + // Relay properties to pass through + user: PropTypes.object.isRequired, + + // Model + request: PropTypes.shape({ + identifier: PropTypes.oneOf(['create', 'publish']).isRequired, + getParams: PropTypes.func.isRequired, + accept: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired, + }).isRequired, + error: PropTypes.instanceOf(Error), + isLoading: PropTypes.bool.isRequired, + inProgress: PropTypes.bool.isRequired, + + // Atom environment + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + } + + constructor(props) { + super(props); + + this.repoName = new TextBuffer(); + this.localPath = new TextBuffer({text: this.props.request.getParams().localDir || ''}); + this.sourceRemoteName = new TextBuffer(); + + this.state = { + acceptEnabled: false, + selectedVisibility: 'PUBLIC', + selectedProtocol: 'https', + selectedOwner: '', + }; + } + + render() { + const text = DIALOG_TEXT[this.props.request.identifier]; + + return ( + + +

+ + {text.heading} +

+
+ +
+
+ Visibility: + + +
+
+ +
+ + +
+ ); + } + + didChooseOwner = ownerID => new Promise(resolve => this.setState({selectedOwner: ownerID}, resolve)) + + didChangeProtocol = protocol => new Promise(resolve => this.setState({selectedProtocol: protocol}, resolve)) + + didChangeVisibility = event => { + return new Promise(resolve => this.setState({selectedVisibility: event.target.value}, resolve)); + } +} diff --git a/lib/views/create-dialog.js b/lib/views/create-dialog.js new file mode 100644 index 0000000000..440ad36d98 --- /dev/null +++ b/lib/views/create-dialog.js @@ -0,0 +1,7 @@ +import React from 'react'; + +export default class CreateDialog extends React.Component { + render() { + return null; + } +} diff --git a/test/containers/create-dialog-container.test.js b/test/containers/create-dialog-container.test.js new file mode 100644 index 0000000000..277344fe41 --- /dev/null +++ b/test/containers/create-dialog-container.test.js @@ -0,0 +1,57 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import CreateDialogContainer from '../../lib/containers/create-dialog-container'; +import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; +import GithubLoginModel from '../../lib/models/github-login-model'; + +describe('CreateDialogContainer', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const loginModel = new GithubLoginModel(InMemoryStrategy); + + return ( + + ); + } + + it('renders the dialog view in a loading state before the token is provided', async function() { + const loginModel = new GithubLoginModel(InMemoryStrategy); + loginModel.setToken('https://api.github.com', '12345'); + + const wrapper = shallow(buildApp({loginModel})); + + const observer = wrapper.find('ObserveModel'); + assert.strictEqual(observer.prop('model'), loginModel); + assert.strictEqual(await observer.prop('fetchData')(loginModel), '12345'); + + const tokenWrapper = observer.renderProp('children')(null); + assert.isTrue(tokenWrapper.find('CreateDialogView').prop('isLoading')); + }); + + it('renders the dialog view in a loading state before the GraphQL query completes', function() { + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('12345'); + + const query = tokenWrapper.find('QueryRenderer'); + const queryWrapper = query.renderProp('render')({error: null, props: null}); + + assert.isTrue(queryWrapper.find('CreateDialogView').prop('isLoading')); + }); + + it('passes GraphQL errors to the dialog view'); + + it('passes GraphQL query results to the dialog view'); +}); diff --git a/test/controllers/dialogs-controller.test.js b/test/controllers/dialogs-controller.test.js index bd3a04d064..0c2934b9d4 100644 --- a/test/controllers/dialogs-controller.test.js +++ b/test/controllers/dialogs-controller.test.js @@ -214,5 +214,9 @@ describe('DialogsController', function() { req.cancel(); assert.isTrue(cancel.called); }); + + it('passes appropriate props to the CreateDialog when creating'); + + it('passes appropriate props to the CreateDialog when publishing'); }); }); diff --git a/test/views/create-dialog-view.test.js b/test/views/create-dialog-view.test.js new file mode 100644 index 0000000000..2a571bc611 --- /dev/null +++ b/test/views/create-dialog-view.test.js @@ -0,0 +1,58 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import CreateDialogView from '../../lib/views/create-dialog-view'; +import RepositoryHomeSelectionView from '../../lib/views/repository-home-selection-view'; +import {dialogRequests} from '../../lib/controllers/dialogs-controller'; + +describe.only('CreateDialogView', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const request = dialogRequests.create(); + + return ( + + ); + } + + it('customizes dialog text in create mode', function() { + const createRequest = dialogRequests.create(); + const wrapper = shallow(buildApp({request: createRequest})); + + assert.include(wrapper.find('.github-Create-header').text(), 'Create GitHub repository'); + assert.isFalse(wrapper.find('DirectorySelect').prop('disabled')); + assert.strictEqual(wrapper.find('DialogView').prop('acceptText'), 'Create'); + }); + + it('customizes dialog text and disables local directory controls in publish mode', function() { + const publishRequest = dialogRequests.publish({localDir: '/local/directory'}); + const wrapper = shallow(buildApp({request: publishRequest})); + + assert.include(wrapper.find('.github-Create-header').text(), 'Publish GitHub repository'); + assert.isTrue(wrapper.find('DirectorySelect').prop('disabled')); + assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), '/local/directory'); + assert.strictEqual(wrapper.find('DialogView').prop('acceptText'), 'Publish'); + }); + + describe('accept enablement', function() { + it('enabled the accept button when all data is present and non-empty'); + + it('disables the accept button if the repo name is empty'); + + it('disables the accept button if the local path is empty'); + + it('disables the accept button if the source remote name is empty'); + }); +}); diff --git a/test/views/create-dialog.test.js b/test/views/create-dialog.test.js new file mode 100644 index 0000000000..f5d9da5720 --- /dev/null +++ b/test/views/create-dialog.test.js @@ -0,0 +1,42 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import CreateDialog from '../../lib/views/create-dialog'; + +describe('CreateDialog', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + return ; + } + + describe('create mode', function() { + it('shows a header for repository creation'); + + it('displays form controls to configure repository owner and name'); + + it('chooses public or private visibility'); + + it('shows a directory selection control'); + + it('shows advanced controls for clone protocol and source remote name'); + + it('uses "create" text on the accept button'); + }); + + describe('publish mode', function() { + it('shows a header for repository publishing'); + + it('prepopulates and disables the directory selection control'); + + it('uses "publish" text on the accept button'); + }); +}); From 48588ea4d039bc56c90cab28bb7decdfe1cdcad7 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 24 Jul 2019 12:42:31 -0400 Subject: [PATCH 07/78] Config settings to make source remote name and fetch protocol sticky --- package.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 79eccd826e..34233848a2 100644 --- a/package.json +++ b/package.json @@ -197,7 +197,18 @@ "reportCannotLocateWorkspaceError": { "type": "boolean", "default": "false", - "description": "Log an error to the console if a git repository cannot be located for the opened file " + "description": "Log an error to the console if a git repository cannot be located for the opened file" + }, + "sourceRemoteName": { + "type": "string", + "default": "origin", + "description": "Name of the git remote to create when creating a new repository" + }, + "remoteFetchProtocol": { + "type": "string", + "default": "https", + "enum": ["https", "ssh"], + "description": "Transport protocol to prefer when creating a new git remote" } }, "deserializers": { From f192194b4cb211cda8f0b49c030ffbc33c49df2b Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 24 Jul 2019 12:42:50 -0400 Subject: [PATCH 08/78] Rename props in RepositoryHomeSelectionView --- lib/views/repository-home-selection-view.js | 8 ++++---- test/views/repository-home-selection-view.test.js | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/views/repository-home-selection-view.js b/lib/views/repository-home-selection-view.js index 7c2c9821f1..9394910bdb 100644 --- a/lib/views/repository-home-selection-view.js +++ b/lib/views/repository-home-selection-view.js @@ -30,15 +30,15 @@ export class BareRepositoryHomeSelectionView extends React.Component { // Model nameBuffer: PropTypes.object.isRequired, isLoading: PropTypes.bool.isRequired, - selectedOwner: PropTypes.string.isRequired, + selectedOwnerID: PropTypes.string.isRequired, // Selection callback - didChooseOwner: PropTypes.func.isRequired, + didChangeOwnerID: PropTypes.func.isRequired, } render() { const owners = this.getOwners(); - const currentOwner = owners.find(o => o.id === this.props.selectedOwner) || owners[0]; + const currentOwner = owners.find(o => o.id === this.props.selectedOwnerID) || owners[0]; return (
@@ -107,7 +107,7 @@ export class BareRepositoryHomeSelectionView extends React.Component { return owners; } - didChangeOwner = owner => this.props.didChooseOwner(owner.id); + didChangeOwner = owner => this.props.didChangeOwnerID(owner.id); } export default createPaginationContainer(BareRepositoryHomeSelectionView, { diff --git a/test/views/repository-home-selection-view.test.js b/test/views/repository-home-selection-view.test.js index 9259d99949..36f8452203 100644 --- a/test/views/repository-home-selection-view.test.js +++ b/test/views/repository-home-selection-view.test.js @@ -24,8 +24,8 @@ describe('RepositoryHomeSelectionView', function() { {}} + selectedOwnerID={''} + didChangeOwnerID={() => {}} {...override} /> ); @@ -137,7 +137,7 @@ describe('RepositoryHomeSelectionView', function() { }) .build(); - const wrapper = shallow(buildApp({user, selectedOwner: 'user0'})); + const wrapper = shallow(buildApp({user, selectedOwnerID: 'user0'})); assert.deepEqual(wrapper.find('Select').prop('value'), { id: 'user0', @@ -146,7 +146,7 @@ describe('RepositoryHomeSelectionView', function() { disabled: false, }); - wrapper.setProps({selectedOwner: 'org1'}); + wrapper.setProps({selectedOwnerID: 'org1'}); assert.deepEqual(wrapper.find('Select').prop('value'), { id: 'org1', @@ -157,7 +157,7 @@ describe('RepositoryHomeSelectionView', function() { }); it('triggers a callback when a new owner is selected', function() { - const didChooseOwner = sinon.spy(); + const didChangeOwnerID = sinon.spy(); const user = userBuilder(userQuery) .organizations(conn => { @@ -170,9 +170,9 @@ describe('RepositoryHomeSelectionView', function() { .build(); const org = user.organizations.edges[0].node; - const wrapper = shallow(buildApp({user, didChooseOwner})); + const wrapper = shallow(buildApp({user, didChangeOwnerID})); wrapper.find('Select').prop('onChange')(org); - assert.isTrue(didChooseOwner.calledWith('org0')); + assert.isTrue(didChangeOwnerID.calledWith('org0')); }); }); From 74ce7a3a4baebb2263706d27b0bf50000e85804a Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 24 Jul 2019 12:43:26 -0400 Subject: [PATCH 09/78] CreateDialogView for the create/publish dialog with GraphQL results --- .../createDialogView_user.graphql.js | 44 ++++ lib/views/create-dialog-view.js | 154 ++++++++++++-- test/views/create-dialog-view.test.js | 201 +++++++++++++++++- 3 files changed, 376 insertions(+), 23 deletions(-) create mode 100644 lib/views/__generated__/createDialogView_user.graphql.js diff --git a/lib/views/__generated__/createDialogView_user.graphql.js b/lib/views/__generated__/createDialogView_user.graphql.js new file mode 100644 index 0000000000..3bfe61da91 --- /dev/null +++ b/lib/views/__generated__/createDialogView_user.graphql.js @@ -0,0 +1,44 @@ +/** + * @flow + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ReaderFragment } from 'relay-runtime'; +import type { FragmentReference } from "relay-runtime"; +declare export opaque type createDialogView_user$ref: FragmentReference; +declare export opaque type createDialogView_user$fragmentType: createDialogView_user$ref; +export type createDialogView_user = {| + +id: string, + +$refType: createDialogView_user$ref, +|}; +export type createDialogView_user$data = createDialogView_user; +export type createDialogView_user$key = { + +$data?: createDialogView_user$data, + +$fragmentRefs: createDialogView_user$ref, +}; +*/ + + +const node/*: ReaderFragment*/ = { + "kind": "Fragment", + "name": "createDialogView_user", + "type": "User", + "metadata": null, + "argumentDefinitions": [], + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null + } + ] +}; +// prettier-ignore +(node/*: any*/).hash = 'd6eb2fd926e344afb618967c2f2fcae3'; +module.exports = node; diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index 4bd43d157c..e40b41dcaa 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -1,6 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; +import {createFragmentContainer, graphql} from 'react-relay'; import {TextBuffer} from 'atom'; +import {CompositeDisposable} from 'event-kit'; +import path from 'path'; import DialogView from './dialog-view'; import RepositoryHomeSelectionView from './repository-home-selection-view'; @@ -23,10 +26,12 @@ const DIALOG_TEXT = { }, }; -export default class CreateDialogView extends React.Component { +export class BareCreateDialogView extends React.Component { static propTypes = { - // Relay properties to pass through - user: PropTypes.object.isRequired, + // Relay + user: PropTypes.shape({ + id: PropTypes.string.isRequired, + }), // Model request: PropTypes.shape({ @@ -40,6 +45,7 @@ export default class CreateDialogView extends React.Component { inProgress: PropTypes.bool.isRequired, // Atom environment + currentWindow: PropTypes.object.isRequired, workspace: PropTypes.object.isRequired, commands: PropTypes.object.isRequired, config: PropTypes.object.isRequired, @@ -48,15 +54,37 @@ export default class CreateDialogView extends React.Component { constructor(props) { super(props); - this.repoName = new TextBuffer(); - this.localPath = new TextBuffer({text: this.props.request.getParams().localDir || ''}); - this.sourceRemoteName = new TextBuffer(); + const {localDir} = this.props.request.getParams(); + + this.projectHome = this.props.config.get('core.projectHome'); + this.modified = { + repoName: false, + localPath: false, + }; + + this.repoName = new TextBuffer({ + text: localDir ? path.basename(localDir) : '', + }); + this.localPath = new TextBuffer({ + text: localDir || this.projectHome, + }); + this.sourceRemoteName = new TextBuffer({ + text: this.props.config.get('github.sourceRemoteName'), + }); + + this.subs = new CompositeDisposable( + this.repoName.onDidChange(this.didChangeRepoName), + this.localPath.onDidChange(this.didChangeLocalPath), + this.sourceRemoteName.onDidChange(this.didChangeSourceRemoteName), + this.props.config.onDidChange('github.sourceRemoteName', this.readSourceRemoteNameSetting), + this.props.config.onDidChange('github.remoteFetchProtocol', this.readRemoteFetchProtocolSetting), + ); this.state = { - acceptEnabled: false, + acceptEnabled: this.acceptIsEnabled(), selectedVisibility: 'PUBLIC', - selectedProtocol: 'https', - selectedOwner: '', + selectedProtocol: this.props.config.get('github.remoteFetchProtocol'), + selectedOwnerID: this.props.user ? this.props.user.id : '', }; } @@ -66,6 +94,7 @@ export default class CreateDialogView extends React.Component { return (
@@ -116,13 +145,13 @@ export default class CreateDialogView extends React.Component {
@@ -131,11 +160,104 @@ export default class CreateDialogView extends React.Component { ); } - didChooseOwner = ownerID => new Promise(resolve => this.setState({selectedOwner: ownerID}, resolve)) + componentWillUnmount() { + this.subs.dispose(); + } + + didChangeRepoName = () => { + this.modified.repoName = true; + if (!this.modified.localPath) { + if (this.localPath.getText() === this.projectHome) { + this.localPath.setText(path.join(this.projectHome, this.repoName.getText())); + } else { + const dirName = path.dirname(this.localPath.getText()); + this.localPath.setText(path.join(dirName, this.repoName.getText())); + } + this.modified.localPath = false; + } + this.recheckAcceptEnablement(); + } - didChangeProtocol = protocol => new Promise(resolve => this.setState({selectedProtocol: protocol}, resolve)) + didChangeOwnerID = ownerID => new Promise(resolve => this.setState({selectedOwnerID: ownerID}, resolve)) + + didChangeLocalPath = () => { + this.modified.localPath = true; + if (!this.modified.repoName) { + this.repoName.setText(path.basename(this.localPath.getText())); + this.modified.repoName = false; + } + this.recheckAcceptEnablement(); + } didChangeVisibility = event => { return new Promise(resolve => this.setState({selectedVisibility: event.target.value}, resolve)); } + + didChangeSourceRemoteName = () => { + this.writeSourceRemoteNameSetting(); + this.recheckAcceptEnablement(); + } + + didChangeProtocol = async protocol => { + await new Promise(resolve => this.setState({selectedProtocol: protocol}, resolve)); + this.writeRemoteFetchProtocolSetting(protocol); + } + + readSourceRemoteNameSetting = ({newValue}) => { + if (newValue !== this.sourceRemoteName.getText()) { + this.sourceRemoteName.setText(newValue); + } + } + + writeSourceRemoteNameSetting() { + if (this.props.config.get('github.sourceRemoteName') !== this.sourceRemoteName.getText()) { + this.props.config.set('github.sourceRemoteName', this.sourceRemoteName.getText()); + } + } + + readRemoteFetchProtocolSetting = ({newValue}) => { + if (newValue !== this.state.selectedProtocol) { + this.setState({selectedProtocol: newValue}); + } + } + + writeRemoteFetchProtocolSetting(protocol) { + if (this.props.config.get('github.remoteFetchProtocol') !== protocol) { + this.props.config.set('github.remoteFetchProtocol', protocol); + } + } + + acceptIsEnabled() { + return !this.repoName.isEmpty() && !this.localPath.isEmpty() && !this.sourceRemoteName.isEmpty(); + } + + recheckAcceptEnablement() { + const nextEnablement = this.acceptIsEnabled(); + if (nextEnablement !== this.state.acceptEnabled) { + this.setState({acceptEnabled: nextEnablement}); + } + } + + accept = () => { + if (!this.acceptIsEnabled()) { + return Promise.resolve(); + } + + return this.props.request.accept({ + ownerID: this.state.selectedOwnerID, + name: this.repoName.getText(), + visibility: this.state.selectedVisibility, + localPath: this.localPath.getText(), + protocol: this.state.selectedProtocol, + sourceRemoteName: this.sourceRemoteName.getText(), + }); + } } + +export default createFragmentContainer(BareCreateDialogView, { + user: graphql` + fragment createDialogView_user on User { + id + } + `, +}); diff --git a/test/views/create-dialog-view.test.js b/test/views/create-dialog-view.test.js index 2a571bc611..2382c483f9 100644 --- a/test/views/create-dialog-view.test.js +++ b/test/views/create-dialog-view.test.js @@ -1,15 +1,23 @@ import React from 'react'; import {shallow} from 'enzyme'; +import path from 'path'; -import CreateDialogView from '../../lib/views/create-dialog-view'; +import {BareCreateDialogView} from '../../lib/views/create-dialog-view'; import RepositoryHomeSelectionView from '../../lib/views/repository-home-selection-view'; import {dialogRequests} from '../../lib/controllers/dialogs-controller'; +import {userBuilder} from '../builder/graphql/user'; -describe.only('CreateDialogView', function() { +import userQuery from '../../lib/views/__generated__/createDialogView_user.graphql'; + +describe('CreateDialogView', function() { let atomEnv; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); + + atomEnv.config.set('core.projectHome', path.join('/home/me/src')); + atomEnv.config.set('github.sourceRemoteName', 'origin'); + atomEnv.config.set('github.remoteFetchProtocol', 'https'); }); afterEach(function() { @@ -20,13 +28,27 @@ describe.only('CreateDialogView', function() { const request = dialogRequests.create(); return ( - ); } + it('renders in a loading state when no relay data is available', function() { + const wrapper = shallow(buildApp({user: null})); + + assert.isNull(wrapper.find(RepositoryHomeSelectionView).prop('user')); + assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('selectedOwnerID'), ''); + }); + it('customizes dialog text in create mode', function() { const createRequest = dialogRequests.create(); const wrapper = shallow(buildApp({request: createRequest})); @@ -46,13 +68,178 @@ describe.only('CreateDialogView', function() { assert.strictEqual(wrapper.find('DialogView').prop('acceptText'), 'Publish'); }); + it('synchronizes the source remote name from Atom configuration', function() { + const wrapper = shallow(buildApp()); + const buffer = wrapper.find('RemoteConfigurationView').prop('sourceRemoteBuffer'); + assert.strictEqual(buffer.getText(), 'origin'); + + atomEnv.config.set('github.sourceRemoteName', 'upstream'); + assert.strictEqual(buffer.getText(), 'upstream'); + + buffer.setText('home'); + assert.strictEqual(atomEnv.config.get('github.sourceRemoteName'), 'home'); + }); + + it('synchronizes the source protocol from Atom configuration', async function() { + const wrapper = shallow(buildApp()); + assert.strictEqual(wrapper.find('RemoteConfigurationView').prop('currentProtocol'), 'https'); + + atomEnv.config.set('github.remoteFetchProtocol', 'ssh'); + assert.strictEqual(wrapper.find('RemoteConfigurationView').prop('currentProtocol'), 'ssh'); + + await wrapper.find('RemoteConfigurationView').prop('didChangeProtocol')('https'); + assert.strictEqual(atomEnv.config.get('github.remoteFetchProtocol'), 'https'); + }); + + it('begins with the owner ID as the viewer ID', function() { + const user = userBuilder(userQuery) + .id('user0') + .build(); + + const wrapper = shallow(buildApp({user})); + + assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('selectedOwnerID'), 'user0'); + }); + + describe('initial repository name', function() { + it('is empty if the initial local path is unspecified', function() { + const request = dialogRequests.create(); + const wrapper = shallow(buildApp({request})); + assert.isTrue(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').isEmpty()); + }); + + it('is the base name of the initial local path', function() { + const request = dialogRequests.publish({localDir: path.join('/local/directory')}); + const wrapper = shallow(buildApp({request})); + assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').getText(), 'directory'); + }); + }); + + describe('initial local path', function() { + it('is the project home directory if unspecified', function() { + const request = dialogRequests.create(); + const wrapper = shallow(buildApp({request})); + assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/home/me/src')); + }); + + it('is the provided path from the dialog request', function() { + const request = dialogRequests.publish({localDir: path.join('/local/directory')}); + const wrapper = shallow(buildApp({request})); + assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/local/directory')); + }); + }); + + describe('repository name and local path name feedback', function() { + it('matches the repository name to the local path basename when the local path is modified and the repository name is not', function() { + const wrapper = shallow(buildApp()); + assert.isTrue(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').isEmpty()); + + wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/directory')); + assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').getText(), 'directory'); + }); + + it('leaves the repository name unchanged if it has been modified', function() { + const wrapper = shallow(buildApp()); + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('repo-name'); + + wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/directory')); + assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').getText(), 'repo-name'); + }); + + it('matches the local path basename to the repository name when the repository name is modified and the local path is not', function() { + const wrapper = shallow(buildApp()); + assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/home/me/src')); + + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('the-repo'); + assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/home/me/src/the-repo')); + + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('different-name'); + assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/home/me/src/different-name')); + }); + + it('leaves the local path unchanged if it has been modified', function() { + const wrapper = shallow(buildApp()); + wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/some/local/directory')); + + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('the-repo'); + assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/some/local/directory')); + }); + }); + describe('accept enablement', function() { - it('enabled the accept button when all data is present and non-empty'); + it('enabled the accept button when all data is present and non-empty', function() { + const wrapper = shallow(buildApp()); + + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('the-repo'); + wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/path')); + + assert.isTrue(wrapper.find('DialogView').prop('acceptEnabled')); + }); + + it('disables the accept button if the repo name is empty', function() { + const wrapper = shallow(buildApp()); + + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('zzz'); + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText(''); + wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/path')); + + assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); + }); + + it('disables the accept button if the local path is empty', function() { + const wrapper = shallow(buildApp()); + + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('the-repo'); + wrapper.find('DirectorySelect').prop('buffer').setText(''); + + assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); + }); + + it('disables the accept button if the source remote name is empty', function() { + const wrapper = shallow(buildApp()); + + wrapper.find('RemoteConfigurationView').prop('sourceRemoteBuffer').setText(''); + + assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); + }); + }); + + describe('acceptance', function() { + it('does nothing if insufficient data is available', async function() { + const accept = sinon.spy(); + const request = dialogRequests.create(); + request.onAccept(accept); + const wrapper = shallow(buildApp({request})); + + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText(''); + await wrapper.find('DialogView').prop('accept')(); + + assert.isFalse(accept.called); + }); + + it('resolves onAccept with the populated data', async function() { + const accept = sinon.spy(); + const request = dialogRequests.create(); + request.onAccept(accept); + const wrapper = shallow(buildApp({request})); - it('disables the accept button if the repo name is empty'); + wrapper.find(RepositoryHomeSelectionView).prop('didChangeOwnerID')('org-id'); + wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('repo-name'); + wrapper.find('input[value="PRIVATE"]').prop('onChange')({target: {value: 'PRIVATE'}}); + wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/path')); + wrapper.find('RemoteConfigurationView').prop('didChangeProtocol')('ssh'); + wrapper.find('RemoteConfigurationView').prop('sourceRemoteBuffer').setText('upstream'); - it('disables the accept button if the local path is empty'); + await wrapper.find('DialogView').prop('accept')(); - it('disables the accept button if the source remote name is empty'); + assert.isTrue(accept.calledWith({ + ownerID: 'org-id', + name: 'repo-name', + visibility: 'PRIVATE', + localPath: path.join('/local/path'), + protocol: 'ssh', + sourceRemoteName: 'upstream', + })); + }); }); }); From 19daeaa002d28d1a4081c50ee7eb3af9cdad35c9 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 24 Jul 2019 15:37:00 -0400 Subject: [PATCH 10/78] Refactor stateful logic into CreateDialogController --- .../createDialogController_user.graphql.js | 44 ++++ lib/controllers/create-dialog-controller.js | 183 +++++++++++++++ .../createDialogView_user.graphql.js | 44 ---- lib/views/create-dialog-view.js | 184 +++------------ .../create-dialog-controller.test.js | 210 +++++++++++++++++ test/views/create-dialog-view.test.js | 214 ++---------------- 6 files changed, 485 insertions(+), 394 deletions(-) create mode 100644 lib/controllers/__generated__/createDialogController_user.graphql.js create mode 100644 lib/controllers/create-dialog-controller.js delete mode 100644 lib/views/__generated__/createDialogView_user.graphql.js create mode 100644 test/controllers/create-dialog-controller.test.js diff --git a/lib/controllers/__generated__/createDialogController_user.graphql.js b/lib/controllers/__generated__/createDialogController_user.graphql.js new file mode 100644 index 0000000000..5379164de1 --- /dev/null +++ b/lib/controllers/__generated__/createDialogController_user.graphql.js @@ -0,0 +1,44 @@ +/** + * @flow + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ReaderFragment } from 'relay-runtime'; +import type { FragmentReference } from "relay-runtime"; +declare export opaque type createDialogController_user$ref: FragmentReference; +declare export opaque type createDialogController_user$fragmentType: createDialogController_user$ref; +export type createDialogController_user = {| + +id: string, + +$refType: createDialogController_user$ref, +|}; +export type createDialogController_user$data = createDialogController_user; +export type createDialogController_user$key = { + +$data?: createDialogController_user$data, + +$fragmentRefs: createDialogController_user$ref, +}; +*/ + + +const node/*: ReaderFragment*/ = { + "kind": "Fragment", + "name": "createDialogController_user", + "type": "User", + "metadata": null, + "argumentDefinitions": [], + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null + } + ] +}; +// prettier-ignore +(node/*: any*/).hash = '525e9172a481ebccd304f9d2f535a493'; +module.exports = node; diff --git a/lib/controllers/create-dialog-controller.js b/lib/controllers/create-dialog-controller.js new file mode 100644 index 0000000000..c5210d4344 --- /dev/null +++ b/lib/controllers/create-dialog-controller.js @@ -0,0 +1,183 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {createFragmentContainer, graphql} from 'react-relay'; +import {TextBuffer} from 'atom'; +import {CompositeDisposable} from 'event-kit'; +import path from 'path'; + +import CreateDialogView from '../views/create-dialog-view'; + +export class BareCreateDialogController extends React.Component { + static propTypes = { + // Relay + user: PropTypes.shape({ + id: PropTypes.string.isRequired, + }), + + // Model + request: PropTypes.shape({ + getParams: PropTypes.func.isRequired, + accept: PropTypes.func.isRequired, + }).isRequired, + + // Atom environment + config: PropTypes.object.isRequired, + } + + constructor(props) { + super(props); + + const {localDir} = this.props.request.getParams(); + + this.projectHome = this.props.config.get('core.projectHome'); + this.modified = { + repoName: false, + localPath: false, + }; + + this.repoName = new TextBuffer({ + text: localDir ? path.basename(localDir) : '', + }); + this.localPath = new TextBuffer({ + text: localDir || this.projectHome, + }); + this.sourceRemoteName = new TextBuffer({ + text: this.props.config.get('github.sourceRemoteName'), + }); + + this.subs = new CompositeDisposable( + this.repoName.onDidChange(this.didChangeRepoName), + this.localPath.onDidChange(this.didChangeLocalPath), + this.sourceRemoteName.onDidChange(this.didChangeSourceRemoteName), + this.props.config.onDidChange('github.sourceRemoteName', this.readSourceRemoteNameSetting), + this.props.config.onDidChange('github.remoteFetchProtocol', this.readRemoteFetchProtocolSetting), + ); + + this.state = { + acceptEnabled: this.acceptIsEnabled(), + selectedVisibility: 'PUBLIC', + selectedProtocol: this.props.config.get('github.remoteFetchProtocol'), + selectedOwnerID: this.props.user ? this.props.user.id : '', + }; + } + + render() { + return ( + + ); + } + + componentWillUnmount() { + this.subs.dispose(); + } + + didChangeRepoName = () => { + this.modified.repoName = true; + if (!this.modified.localPath) { + if (this.localPath.getText() === this.projectHome) { + this.localPath.setText(path.join(this.projectHome, this.repoName.getText())); + } else { + const dirName = path.dirname(this.localPath.getText()); + this.localPath.setText(path.join(dirName, this.repoName.getText())); + } + this.modified.localPath = false; + } + this.recheckAcceptEnablement(); + } + + didChangeOwnerID = ownerID => new Promise(resolve => this.setState({selectedOwnerID: ownerID}, resolve)) + + didChangeLocalPath = () => { + this.modified.localPath = true; + if (!this.modified.repoName) { + this.repoName.setText(path.basename(this.localPath.getText())); + this.modified.repoName = false; + } + this.recheckAcceptEnablement(); + } + + didChangeVisibility = visibility => { + return new Promise(resolve => this.setState({selectedVisibility: visibility}, resolve)); + } + + didChangeSourceRemoteName = () => { + this.writeSourceRemoteNameSetting(); + this.recheckAcceptEnablement(); + } + + didChangeProtocol = async protocol => { + await new Promise(resolve => this.setState({selectedProtocol: protocol}, resolve)); + this.writeRemoteFetchProtocolSetting(protocol); + } + + readSourceRemoteNameSetting = ({newValue}) => { + if (newValue !== this.sourceRemoteName.getText()) { + this.sourceRemoteName.setText(newValue); + } + } + + writeSourceRemoteNameSetting() { + if (this.props.config.get('github.sourceRemoteName') !== this.sourceRemoteName.getText()) { + this.props.config.set('github.sourceRemoteName', this.sourceRemoteName.getText()); + } + } + + readRemoteFetchProtocolSetting = ({newValue}) => { + if (newValue !== this.state.selectedProtocol) { + this.setState({selectedProtocol: newValue}); + } + } + + writeRemoteFetchProtocolSetting(protocol) { + if (this.props.config.get('github.remoteFetchProtocol') !== protocol) { + this.props.config.set('github.remoteFetchProtocol', protocol); + } + } + + acceptIsEnabled() { + return !this.repoName.isEmpty() && !this.localPath.isEmpty() && !this.sourceRemoteName.isEmpty(); + } + + recheckAcceptEnablement() { + const nextEnablement = this.acceptIsEnabled(); + if (nextEnablement !== this.state.acceptEnabled) { + this.setState({acceptEnabled: nextEnablement}); + } + } + + accept = () => { + if (!this.acceptIsEnabled()) { + return Promise.resolve(); + } + + return this.props.request.accept({ + ownerID: this.state.selectedOwnerID, + name: this.repoName.getText(), + visibility: this.state.selectedVisibility, + localPath: this.localPath.getText(), + protocol: this.state.selectedProtocol, + sourceRemoteName: this.sourceRemoteName.getText(), + }); + } +} + +export default createFragmentContainer(BareCreateDialogController, { + user: graphql` + fragment createDialogController_user on User { + id + } + `, +}); diff --git a/lib/views/__generated__/createDialogView_user.graphql.js b/lib/views/__generated__/createDialogView_user.graphql.js deleted file mode 100644 index 3bfe61da91..0000000000 --- a/lib/views/__generated__/createDialogView_user.graphql.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @flow - */ - -/* eslint-disable */ - -'use strict'; - -/*:: -import type { ReaderFragment } from 'relay-runtime'; -import type { FragmentReference } from "relay-runtime"; -declare export opaque type createDialogView_user$ref: FragmentReference; -declare export opaque type createDialogView_user$fragmentType: createDialogView_user$ref; -export type createDialogView_user = {| - +id: string, - +$refType: createDialogView_user$ref, -|}; -export type createDialogView_user$data = createDialogView_user; -export type createDialogView_user$key = { - +$data?: createDialogView_user$data, - +$fragmentRefs: createDialogView_user$ref, -}; -*/ - - -const node/*: ReaderFragment*/ = { - "kind": "Fragment", - "name": "createDialogView_user", - "type": "User", - "metadata": null, - "argumentDefinitions": [], - "selections": [ - { - "kind": "ScalarField", - "alias": null, - "name": "id", - "args": null, - "storageKey": null - } - ] -}; -// prettier-ignore -(node/*: any*/).hash = 'd6eb2fd926e344afb618967c2f2fcae3'; -module.exports = node; diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index e40b41dcaa..0e37002e48 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -1,9 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {createFragmentContainer, graphql} from 'react-relay'; -import {TextBuffer} from 'atom'; -import {CompositeDisposable} from 'event-kit'; -import path from 'path'; import DialogView from './dialog-view'; import RepositoryHomeSelectionView from './repository-home-selection-view'; @@ -26,23 +22,33 @@ const DIALOG_TEXT = { }, }; -export class BareCreateDialogView extends React.Component { +export default class CreateDialogView extends React.Component { static propTypes = { // Relay - user: PropTypes.shape({ - id: PropTypes.string.isRequired, - }), + user: PropTypes.object, // Model request: PropTypes.shape({ identifier: PropTypes.oneOf(['create', 'publish']).isRequired, getParams: PropTypes.func.isRequired, - accept: PropTypes.func.isRequired, cancel: PropTypes.func.isRequired, }).isRequired, error: PropTypes.instanceOf(Error), isLoading: PropTypes.bool.isRequired, inProgress: PropTypes.bool.isRequired, + selectedOwnerID: PropTypes.string.isRequired, + repoName: PropTypes.object.isRequired, + selectedVisibility: PropTypes.oneOf(['PUBLIC', 'PRIVATE']).isRequired, + localPath: PropTypes.object.isRequired, + sourceRemoteName: PropTypes.object.isRequired, + selectedProtocol: PropTypes.oneOf(['https', 'ssh']).isRequired, + acceptEnabled: PropTypes.bool.isRequired, + + // Change callbacks + didChangeOwnerID: PropTypes.func.isRequired, + didChangeVisibility: PropTypes.func.isRequired, + didChangeProtocol: PropTypes.func.isRequired, + accept: PropTypes.func.isRequired, // Atom environment currentWindow: PropTypes.object.isRequired, @@ -51,52 +57,15 @@ export class BareCreateDialogView extends React.Component { config: PropTypes.object.isRequired, } - constructor(props) { - super(props); - - const {localDir} = this.props.request.getParams(); - - this.projectHome = this.props.config.get('core.projectHome'); - this.modified = { - repoName: false, - localPath: false, - }; - - this.repoName = new TextBuffer({ - text: localDir ? path.basename(localDir) : '', - }); - this.localPath = new TextBuffer({ - text: localDir || this.projectHome, - }); - this.sourceRemoteName = new TextBuffer({ - text: this.props.config.get('github.sourceRemoteName'), - }); - - this.subs = new CompositeDisposable( - this.repoName.onDidChange(this.didChangeRepoName), - this.localPath.onDidChange(this.didChangeLocalPath), - this.sourceRemoteName.onDidChange(this.didChangeSourceRemoteName), - this.props.config.onDidChange('github.sourceRemoteName', this.readSourceRemoteNameSetting), - this.props.config.onDidChange('github.remoteFetchProtocol', this.readRemoteFetchProtocolSetting), - ); - - this.state = { - acceptEnabled: this.acceptIsEnabled(), - selectedVisibility: 'PUBLIC', - selectedProtocol: this.props.config.get('github.remoteFetchProtocol'), - selectedOwnerID: this.props.user ? this.props.user.id : '', - }; - } - render() { const text = DIALOG_TEXT[this.props.request.identifier]; return ( @@ -124,7 +93,7 @@ export class BareCreateDialogView extends React.Component { type="radio" name="visibility" value="PUBLIC" - checked={this.state.selectedVisibility === 'PUBLIC'} + checked={this.props.selectedVisibility === 'PUBLIC'} onChange={this.didChangeVisibility} /> @@ -136,7 +105,7 @@ export class BareCreateDialogView extends React.Component { type="radio" name="visibility" value="PRIVATE" - checked={this.state.selectedVisibility === 'PRIVATE'} + checked={this.props.selectedVisibility === 'PRIVATE'} onChange={this.didChangeVisibility} /> @@ -146,118 +115,19 @@ export class BareCreateDialogView extends React.Component {
); } - componentWillUnmount() { - this.subs.dispose(); - } - - didChangeRepoName = () => { - this.modified.repoName = true; - if (!this.modified.localPath) { - if (this.localPath.getText() === this.projectHome) { - this.localPath.setText(path.join(this.projectHome, this.repoName.getText())); - } else { - const dirName = path.dirname(this.localPath.getText()); - this.localPath.setText(path.join(dirName, this.repoName.getText())); - } - this.modified.localPath = false; - } - this.recheckAcceptEnablement(); - } - - didChangeOwnerID = ownerID => new Promise(resolve => this.setState({selectedOwnerID: ownerID}, resolve)) - - didChangeLocalPath = () => { - this.modified.localPath = true; - if (!this.modified.repoName) { - this.repoName.setText(path.basename(this.localPath.getText())); - this.modified.repoName = false; - } - this.recheckAcceptEnablement(); - } - - didChangeVisibility = event => { - return new Promise(resolve => this.setState({selectedVisibility: event.target.value}, resolve)); - } - - didChangeSourceRemoteName = () => { - this.writeSourceRemoteNameSetting(); - this.recheckAcceptEnablement(); - } - - didChangeProtocol = async protocol => { - await new Promise(resolve => this.setState({selectedProtocol: protocol}, resolve)); - this.writeRemoteFetchProtocolSetting(protocol); - } - - readSourceRemoteNameSetting = ({newValue}) => { - if (newValue !== this.sourceRemoteName.getText()) { - this.sourceRemoteName.setText(newValue); - } - } - - writeSourceRemoteNameSetting() { - if (this.props.config.get('github.sourceRemoteName') !== this.sourceRemoteName.getText()) { - this.props.config.set('github.sourceRemoteName', this.sourceRemoteName.getText()); - } - } - - readRemoteFetchProtocolSetting = ({newValue}) => { - if (newValue !== this.state.selectedProtocol) { - this.setState({selectedProtocol: newValue}); - } - } - - writeRemoteFetchProtocolSetting(protocol) { - if (this.props.config.get('github.remoteFetchProtocol') !== protocol) { - this.props.config.set('github.remoteFetchProtocol', protocol); - } - } - - acceptIsEnabled() { - return !this.repoName.isEmpty() && !this.localPath.isEmpty() && !this.sourceRemoteName.isEmpty(); - } - - recheckAcceptEnablement() { - const nextEnablement = this.acceptIsEnabled(); - if (nextEnablement !== this.state.acceptEnabled) { - this.setState({acceptEnabled: nextEnablement}); - } - } - - accept = () => { - if (!this.acceptIsEnabled()) { - return Promise.resolve(); - } - - return this.props.request.accept({ - ownerID: this.state.selectedOwnerID, - name: this.repoName.getText(), - visibility: this.state.selectedVisibility, - localPath: this.localPath.getText(), - protocol: this.state.selectedProtocol, - sourceRemoteName: this.sourceRemoteName.getText(), - }); - } + didChangeVisibility = event => this.props.didChangeVisibility(event.target.value); } - -export default createFragmentContainer(BareCreateDialogView, { - user: graphql` - fragment createDialogView_user on User { - id - } - `, -}); diff --git a/test/controllers/create-dialog-controller.test.js b/test/controllers/create-dialog-controller.test.js new file mode 100644 index 0000000000..f8517f6860 --- /dev/null +++ b/test/controllers/create-dialog-controller.test.js @@ -0,0 +1,210 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import path from 'path'; + +import {BareCreateDialogController} from '../../lib/controllers/create-dialog-controller'; +import CreateDialogView from '../../lib/views/create-dialog-view'; +import {dialogRequests} from '../../lib/controllers/dialogs-controller'; +import {userBuilder} from '../builder/graphql/user'; +import userQuery from '../../lib/controllers/__generated__/createDialogController_user.graphql'; + +describe('CreateDialogController', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + + atomEnv.config.set('core.projectHome', path.join('/home/me/src')); + atomEnv.config.set('github.sourceRemoteName', 'origin'); + atomEnv.config.set('github.remoteFetchProtocol', 'https'); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + return ( + + ); + } + + it('synchronizes the source remote name from Atom configuration', function() { + const wrapper = shallow(buildApp()); + const buffer = wrapper.find(CreateDialogView).prop('sourceRemoteName'); + assert.strictEqual(buffer.getText(), 'origin'); + + atomEnv.config.set('github.sourceRemoteName', 'upstream'); + assert.strictEqual(buffer.getText(), 'upstream'); + + buffer.setText('home'); + assert.strictEqual(atomEnv.config.get('github.sourceRemoteName'), 'home'); + }); + + it('synchronizes the source protocol from Atom configuration', async function() { + const wrapper = shallow(buildApp()); + assert.strictEqual(wrapper.find(CreateDialogView).prop('selectedProtocol'), 'https'); + + atomEnv.config.set('github.remoteFetchProtocol', 'ssh'); + assert.strictEqual(wrapper.find(CreateDialogView).prop('selectedProtocol'), 'ssh'); + + await wrapper.find(CreateDialogView).prop('didChangeProtocol')('https'); + assert.strictEqual(atomEnv.config.get('github.remoteFetchProtocol'), 'https'); + }); + + it('begins with the owner ID as the viewer ID', function() { + const user = userBuilder(userQuery) + .id('user0') + .build(); + const wrapper = shallow(buildApp({user})); + + assert.strictEqual(wrapper.find(CreateDialogView).prop('selectedOwnerID'), 'user0'); + }); + + describe('initial repository name', function() { + it('is empty if the initial local path is unspecified', function() { + const request = dialogRequests.create(); + const wrapper = shallow(buildApp({request})); + assert.isTrue(wrapper.find(CreateDialogView).prop('repoName').isEmpty()); + }); + + it('is the base name of the initial local path', function() { + const request = dialogRequests.publish({localDir: path.join('/local/directory')}); + const wrapper = shallow(buildApp({request})); + assert.strictEqual(wrapper.find(CreateDialogView).prop('repoName').getText(), 'directory'); + }); + }); + + describe('initial local path', function() { + it('is the project home directory if unspecified', function() { + const request = dialogRequests.create(); + const wrapper = shallow(buildApp({request})); + assert.strictEqual(wrapper.find(CreateDialogView).prop('localPath').getText(), path.join('/home/me/src')); + }); + + it('is the provided path from the dialog request', function() { + const request = dialogRequests.publish({localDir: path.join('/local/directory')}); + const wrapper = shallow(buildApp({request})); + assert.strictEqual(wrapper.find(CreateDialogView).prop('localPath').getText(), path.join('/local/directory')); + }); + }); + + describe('repository name and local path name feedback', function() { + it('matches the repository name to the local path basename when the local path is modified and the repository name is not', function() { + const wrapper = shallow(buildApp()); + assert.isTrue(wrapper.find(CreateDialogView).prop('repoName').isEmpty()); + + wrapper.find(CreateDialogView).prop('localPath').setText(path.join('/local/directory')); + assert.strictEqual(wrapper.find(CreateDialogView).prop('repoName').getText(), 'directory'); + }); + + it('leaves the repository name unchanged if it has been modified', function() { + const wrapper = shallow(buildApp()); + wrapper.find(CreateDialogView).prop('repoName').setText('repo-name'); + + wrapper.find(CreateDialogView).prop('localPath').setText(path.join('/local/directory')); + assert.strictEqual(wrapper.find(CreateDialogView).prop('repoName').getText(), 'repo-name'); + }); + + it('matches the local path basename to the repository name when the repository name is modified and the local path is not', function() { + const wrapper = shallow(buildApp()); + assert.strictEqual(wrapper.find(CreateDialogView).prop('localPath').getText(), path.join('/home/me/src')); + + wrapper.find(CreateDialogView).prop('repoName').setText('the-repo'); + assert.strictEqual(wrapper.find(CreateDialogView).prop('localPath').getText(), path.join('/home/me/src/the-repo')); + + wrapper.find(CreateDialogView).prop('repoName').setText('different-name'); + assert.strictEqual(wrapper.find(CreateDialogView).prop('localPath').getText(), path.join('/home/me/src/different-name')); + }); + + it('leaves the local path unchanged if it has been modified', function() { + const wrapper = shallow(buildApp()); + wrapper.find(CreateDialogView).prop('localPath').setText(path.join('/some/local/directory')); + + wrapper.find(CreateDialogView).prop('repoName').setText('the-repo'); + assert.strictEqual(wrapper.find(CreateDialogView).prop('localPath').getText(), path.join('/some/local/directory')); + }); + }); + + describe('accept enablement', function() { + it('enabled the accept button when all data is present and non-empty', function() { + const wrapper = shallow(buildApp()); + + wrapper.find(CreateDialogView).prop('repoName').setText('the-repo'); + wrapper.find(CreateDialogView).prop('localPath').setText(path.join('/local/path')); + + assert.isTrue(wrapper.find(CreateDialogView).prop('acceptEnabled')); + }); + + it('disables the accept button if the repo name is empty', function() { + const wrapper = shallow(buildApp()); + + wrapper.find(CreateDialogView).prop('repoName').setText('zzz'); + wrapper.find(CreateDialogView).prop('repoName').setText(''); + wrapper.find(CreateDialogView).prop('localPath').setText(path.join('/local/path')); + + assert.isFalse(wrapper.find(CreateDialogView).prop('acceptEnabled')); + }); + + it('disables the accept button if the local path is empty', function() { + const wrapper = shallow(buildApp()); + + wrapper.find(CreateDialogView).prop('repoName').setText('the-repo'); + wrapper.find(CreateDialogView).prop('localPath').setText(''); + + assert.isFalse(wrapper.find(CreateDialogView).prop('acceptEnabled')); + }); + + it('disables the accept button if the source remote name is empty', function() { + const wrapper = shallow(buildApp()); + + wrapper.find(CreateDialogView).prop('sourceRemoteName').setText(''); + + assert.isFalse(wrapper.find(CreateDialogView).prop('acceptEnabled')); + }); + }); + + describe('acceptance', function() { + it('does nothing if insufficient data is available', async function() { + const accept = sinon.spy(); + const request = dialogRequests.create(); + request.onAccept(accept); + const wrapper = shallow(buildApp({request})); + + wrapper.find(CreateDialogView).prop('repoName').setText(''); + await wrapper.find(CreateDialogView).prop('accept')(); + + assert.isFalse(accept.called); + }); + + it('resolves onAccept with the populated data', async function() { + const accept = sinon.spy(); + const request = dialogRequests.create(); + request.onAccept(accept); + const wrapper = shallow(buildApp({request})); + + wrapper.find(CreateDialogView).prop('didChangeOwnerID')('org-id'); + wrapper.find(CreateDialogView).prop('repoName').setText('repo-name'); + wrapper.find(CreateDialogView).prop('didChangeVisibility')('PRIVATE'); + wrapper.find(CreateDialogView).prop('localPath').setText(path.join('/local/path')); + wrapper.find(CreateDialogView).prop('didChangeProtocol')('ssh'); + wrapper.find(CreateDialogView).prop('sourceRemoteName').setText('upstream'); + + await wrapper.find(CreateDialogView).prop('accept')(); + + assert.isTrue(accept.calledWith({ + ownerID: 'org-id', + name: 'repo-name', + visibility: 'PRIVATE', + localPath: path.join('/local/path'), + protocol: 'ssh', + sourceRemoteName: 'upstream', + })); + }); + }); +}); diff --git a/test/views/create-dialog-view.test.js b/test/views/create-dialog-view.test.js index 2382c483f9..a4f2a6da30 100644 --- a/test/views/create-dialog-view.test.js +++ b/test/views/create-dialog-view.test.js @@ -1,23 +1,16 @@ import React from 'react'; import {shallow} from 'enzyme'; -import path from 'path'; +import {TextBuffer} from 'atom'; -import {BareCreateDialogView} from '../../lib/views/create-dialog-view'; +import CreateDialogView from '../../lib/views/create-dialog-view'; import RepositoryHomeSelectionView from '../../lib/views/repository-home-selection-view'; import {dialogRequests} from '../../lib/controllers/dialogs-controller'; -import {userBuilder} from '../builder/graphql/user'; - -import userQuery from '../../lib/views/__generated__/createDialogView_user.graphql'; describe('CreateDialogView', function() { let atomEnv; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); - - atomEnv.config.set('core.projectHome', path.join('/home/me/src')); - atomEnv.config.set('github.sourceRemoteName', 'origin'); - atomEnv.config.set('github.remoteFetchProtocol', 'https'); }); afterEach(function() { @@ -25,14 +18,22 @@ describe('CreateDialogView', function() { }); function buildApp(override = {}) { - const request = dialogRequests.create(); - return ( - {}} + didChangeVisibility={() => {}} + didChangeProtocol={() => {}} + acceptEnabled={true} + accept={() => {}} currentWindow={atomEnv.getCurrentWindow()} workspace={atomEnv.workspace} commands={atomEnv.commands} @@ -43,10 +44,11 @@ describe('CreateDialogView', function() { } it('renders in a loading state when no relay data is available', function() { - const wrapper = shallow(buildApp({user: null})); + const wrapper = shallow(buildApp({user: null, isLoading: true})); - assert.isNull(wrapper.find(RepositoryHomeSelectionView).prop('user')); - assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('selectedOwnerID'), ''); + const homeView = wrapper.find(RepositoryHomeSelectionView); + assert.isNull(homeView.prop('user')); + assert.isTrue(homeView.prop('isLoading')); }); it('customizes dialog text in create mode', function() { @@ -60,186 +62,12 @@ describe('CreateDialogView', function() { it('customizes dialog text and disables local directory controls in publish mode', function() { const publishRequest = dialogRequests.publish({localDir: '/local/directory'}); - const wrapper = shallow(buildApp({request: publishRequest})); + const localPath = new TextBuffer({text: '/local/directory'}); + const wrapper = shallow(buildApp({request: publishRequest, localPath})); assert.include(wrapper.find('.github-Create-header').text(), 'Publish GitHub repository'); assert.isTrue(wrapper.find('DirectorySelect').prop('disabled')); assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), '/local/directory'); assert.strictEqual(wrapper.find('DialogView').prop('acceptText'), 'Publish'); }); - - it('synchronizes the source remote name from Atom configuration', function() { - const wrapper = shallow(buildApp()); - const buffer = wrapper.find('RemoteConfigurationView').prop('sourceRemoteBuffer'); - assert.strictEqual(buffer.getText(), 'origin'); - - atomEnv.config.set('github.sourceRemoteName', 'upstream'); - assert.strictEqual(buffer.getText(), 'upstream'); - - buffer.setText('home'); - assert.strictEqual(atomEnv.config.get('github.sourceRemoteName'), 'home'); - }); - - it('synchronizes the source protocol from Atom configuration', async function() { - const wrapper = shallow(buildApp()); - assert.strictEqual(wrapper.find('RemoteConfigurationView').prop('currentProtocol'), 'https'); - - atomEnv.config.set('github.remoteFetchProtocol', 'ssh'); - assert.strictEqual(wrapper.find('RemoteConfigurationView').prop('currentProtocol'), 'ssh'); - - await wrapper.find('RemoteConfigurationView').prop('didChangeProtocol')('https'); - assert.strictEqual(atomEnv.config.get('github.remoteFetchProtocol'), 'https'); - }); - - it('begins with the owner ID as the viewer ID', function() { - const user = userBuilder(userQuery) - .id('user0') - .build(); - - const wrapper = shallow(buildApp({user})); - - assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('selectedOwnerID'), 'user0'); - }); - - describe('initial repository name', function() { - it('is empty if the initial local path is unspecified', function() { - const request = dialogRequests.create(); - const wrapper = shallow(buildApp({request})); - assert.isTrue(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').isEmpty()); - }); - - it('is the base name of the initial local path', function() { - const request = dialogRequests.publish({localDir: path.join('/local/directory')}); - const wrapper = shallow(buildApp({request})); - assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').getText(), 'directory'); - }); - }); - - describe('initial local path', function() { - it('is the project home directory if unspecified', function() { - const request = dialogRequests.create(); - const wrapper = shallow(buildApp({request})); - assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/home/me/src')); - }); - - it('is the provided path from the dialog request', function() { - const request = dialogRequests.publish({localDir: path.join('/local/directory')}); - const wrapper = shallow(buildApp({request})); - assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/local/directory')); - }); - }); - - describe('repository name and local path name feedback', function() { - it('matches the repository name to the local path basename when the local path is modified and the repository name is not', function() { - const wrapper = shallow(buildApp()); - assert.isTrue(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').isEmpty()); - - wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/directory')); - assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').getText(), 'directory'); - }); - - it('leaves the repository name unchanged if it has been modified', function() { - const wrapper = shallow(buildApp()); - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('repo-name'); - - wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/directory')); - assert.strictEqual(wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').getText(), 'repo-name'); - }); - - it('matches the local path basename to the repository name when the repository name is modified and the local path is not', function() { - const wrapper = shallow(buildApp()); - assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/home/me/src')); - - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('the-repo'); - assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/home/me/src/the-repo')); - - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('different-name'); - assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/home/me/src/different-name')); - }); - - it('leaves the local path unchanged if it has been modified', function() { - const wrapper = shallow(buildApp()); - wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/some/local/directory')); - - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('the-repo'); - assert.strictEqual(wrapper.find('DirectorySelect').prop('buffer').getText(), path.join('/some/local/directory')); - }); - }); - - describe('accept enablement', function() { - it('enabled the accept button when all data is present and non-empty', function() { - const wrapper = shallow(buildApp()); - - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('the-repo'); - wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/path')); - - assert.isTrue(wrapper.find('DialogView').prop('acceptEnabled')); - }); - - it('disables the accept button if the repo name is empty', function() { - const wrapper = shallow(buildApp()); - - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('zzz'); - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText(''); - wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/path')); - - assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); - }); - - it('disables the accept button if the local path is empty', function() { - const wrapper = shallow(buildApp()); - - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('the-repo'); - wrapper.find('DirectorySelect').prop('buffer').setText(''); - - assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); - }); - - it('disables the accept button if the source remote name is empty', function() { - const wrapper = shallow(buildApp()); - - wrapper.find('RemoteConfigurationView').prop('sourceRemoteBuffer').setText(''); - - assert.isFalse(wrapper.find('DialogView').prop('acceptEnabled')); - }); - }); - - describe('acceptance', function() { - it('does nothing if insufficient data is available', async function() { - const accept = sinon.spy(); - const request = dialogRequests.create(); - request.onAccept(accept); - const wrapper = shallow(buildApp({request})); - - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText(''); - await wrapper.find('DialogView').prop('accept')(); - - assert.isFalse(accept.called); - }); - - it('resolves onAccept with the populated data', async function() { - const accept = sinon.spy(); - const request = dialogRequests.create(); - request.onAccept(accept); - const wrapper = shallow(buildApp({request})); - - wrapper.find(RepositoryHomeSelectionView).prop('didChangeOwnerID')('org-id'); - wrapper.find(RepositoryHomeSelectionView).prop('nameBuffer').setText('repo-name'); - wrapper.find('input[value="PRIVATE"]').prop('onChange')({target: {value: 'PRIVATE'}}); - wrapper.find('DirectorySelect').prop('buffer').setText(path.join('/local/path')); - wrapper.find('RemoteConfigurationView').prop('didChangeProtocol')('ssh'); - wrapper.find('RemoteConfigurationView').prop('sourceRemoteBuffer').setText('upstream'); - - await wrapper.find('DialogView').prop('accept')(); - - assert.isTrue(accept.calledWith({ - ownerID: 'org-id', - name: 'repo-name', - visibility: 'PRIVATE', - localPath: path.join('/local/path'), - protocol: 'ssh', - sourceRemoteName: 'upstream', - })); - }); - }); }); From 4e92525618c44851abcda4e3f2c8ac50529f7816 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 25 Jul 2019 11:43:21 -0400 Subject: [PATCH 11/78] Builder for top-level `viewer` object --- test/builder/graphql/query.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/builder/graphql/query.js b/test/builder/graphql/query.js index e24fdb3348..ca93bd152a 100644 --- a/test/builder/graphql/query.js +++ b/test/builder/graphql/query.js @@ -4,6 +4,7 @@ import {createSpecBuilderClass} from './base'; import {RepositoryBuilder} from './repository'; import {PullRequestBuilder} from './pr'; +import {UserBuilder} from './user'; import { AddPullRequestReviewPayloadBuilder, @@ -45,6 +46,7 @@ const SearchResultBuilder = createSpecBuilderClass('SearchResultItemConnection', const QueryBuilder = createSpecBuilderClass('Query', { repository: {linked: RepositoryBuilder, nullable: true}, search: {linked: SearchResultBuilder}, + viewer: {linked: UserBuilder}, // Mutations addPullRequestReview: {linked: AddPullRequestReviewPayloadBuilder}, From c300eedd6dc0ce3a3441c80d4bd1a0c77bc82017 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 25 Jul 2019 11:43:47 -0400 Subject: [PATCH 12/78] Propagate propTypes up the CreateDialog stack --- lib/controllers/create-dialog-controller.js | 6 ++++++ test/controllers/create-dialog-controller.test.js | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/lib/controllers/create-dialog-controller.js b/lib/controllers/create-dialog-controller.js index c5210d4344..ccdd7163bc 100644 --- a/lib/controllers/create-dialog-controller.js +++ b/lib/controllers/create-dialog-controller.js @@ -19,8 +19,14 @@ export class BareCreateDialogController extends React.Component { getParams: PropTypes.func.isRequired, accept: PropTypes.func.isRequired, }).isRequired, + error: PropTypes.instanceOf(Error), + isLoading: PropTypes.bool.isRequired, + inProgress: PropTypes.bool.isRequired, // Atom environment + currentWindow: PropTypes.object.isRequired, + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, config: PropTypes.object.isRequired, } diff --git a/test/controllers/create-dialog-controller.test.js b/test/controllers/create-dialog-controller.test.js index f8517f6860..d0913365b2 100644 --- a/test/controllers/create-dialog-controller.test.js +++ b/test/controllers/create-dialog-controller.test.js @@ -28,6 +28,11 @@ describe('CreateDialogController', function() { From 0e7080a3826f347d833f0c3b2bba08c0618d468d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 25 Jul 2019 11:44:01 -0400 Subject: [PATCH 13/78] Flesh out the CreateDialogContainer --- .../createDialogContainerQuery.graphql.js | 289 ++++++++++++++++++ lib/containers/create-dialog-container.js | 107 ++++++- .../create-dialog-container.test.js | 68 ++++- 3 files changed, 447 insertions(+), 17 deletions(-) create mode 100644 lib/containers/__generated__/createDialogContainerQuery.graphql.js diff --git a/lib/containers/__generated__/createDialogContainerQuery.graphql.js b/lib/containers/__generated__/createDialogContainerQuery.graphql.js new file mode 100644 index 0000000000..d7a31cc4a9 --- /dev/null +++ b/lib/containers/__generated__/createDialogContainerQuery.graphql.js @@ -0,0 +1,289 @@ +/** + * @flow + * @relayHash 0700928de288d25bcd390fb8b89ba08a + */ + +/* eslint-disable */ + +'use strict'; + +/*:: +import type { ConcreteRequest } from 'relay-runtime'; +type createDialogController_user$ref = any; +type repositoryHomeSelectionView_user$ref = any; +export type createDialogContainerQueryVariables = {| + organizationCount: number, + organizationCursor?: ?string, +|}; +export type createDialogContainerQueryResponse = {| + +viewer: {| + +$fragmentRefs: createDialogController_user$ref & repositoryHomeSelectionView_user$ref + |} +|}; +export type createDialogContainerQuery = {| + variables: createDialogContainerQueryVariables, + response: createDialogContainerQueryResponse, +|}; +*/ + + +/* +query createDialogContainerQuery( + $organizationCount: Int! + $organizationCursor: String +) { + viewer { + ...createDialogController_user + ...repositoryHomeSelectionView_user_12CDS5 + id + } +} + +fragment createDialogController_user on User { + id +} + +fragment repositoryHomeSelectionView_user_12CDS5 on User { + id + login + avatarUrl(size: 24) + organizations(first: $organizationCount, after: $organizationCursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + login + avatarUrl(size: 24) + viewerCanCreateRepositories + __typename + } + } + } +} +*/ + +const node/*: ConcreteRequest*/ = (function(){ +var v0 = [ + { + "kind": "LocalArgument", + "name": "organizationCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "organizationCursor", + "type": "String", + "defaultValue": null + } +], +v1 = { + "kind": "ScalarField", + "alias": null, + "name": "id", + "args": null, + "storageKey": null +}, +v2 = { + "kind": "ScalarField", + "alias": null, + "name": "login", + "args": null, + "storageKey": null +}, +v3 = { + "kind": "ScalarField", + "alias": null, + "name": "avatarUrl", + "args": [ + { + "kind": "Literal", + "name": "size", + "value": 24 + } + ], + "storageKey": "avatarUrl(size:24)" +}, +v4 = [ + { + "kind": "Variable", + "name": "after", + "variableName": "organizationCursor" + }, + { + "kind": "Variable", + "name": "first", + "variableName": "organizationCount" + } +]; +return { + "kind": "Request", + "fragment": { + "kind": "Fragment", + "name": "createDialogContainerQuery", + "type": "Query", + "metadata": null, + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "viewer", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + { + "kind": "FragmentSpread", + "name": "createDialogController_user", + "args": null + }, + { + "kind": "FragmentSpread", + "name": "repositoryHomeSelectionView_user", + "args": [ + { + "kind": "Variable", + "name": "organizationCount", + "variableName": "organizationCount" + }, + { + "kind": "Variable", + "name": "organizationCursor", + "variableName": "organizationCursor" + } + ] + } + ] + } + ] + }, + "operation": { + "kind": "Operation", + "name": "createDialogContainerQuery", + "argumentDefinitions": (v0/*: any*/), + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "viewer", + "storageKey": null, + "args": null, + "concreteType": "User", + "plural": false, + "selections": [ + (v1/*: any*/), + (v2/*: any*/), + (v3/*: any*/), + { + "kind": "LinkedField", + "alias": null, + "name": "organizations", + "storageKey": null, + "args": (v4/*: any*/), + "concreteType": "OrganizationConnection", + "plural": false, + "selections": [ + { + "kind": "LinkedField", + "alias": null, + "name": "pageInfo", + "storageKey": null, + "args": null, + "concreteType": "PageInfo", + "plural": false, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "hasNextPage", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "endCursor", + "args": null, + "storageKey": null + } + ] + }, + { + "kind": "LinkedField", + "alias": null, + "name": "edges", + "storageKey": null, + "args": null, + "concreteType": "OrganizationEdge", + "plural": true, + "selections": [ + { + "kind": "ScalarField", + "alias": null, + "name": "cursor", + "args": null, + "storageKey": null + }, + { + "kind": "LinkedField", + "alias": null, + "name": "node", + "storageKey": null, + "args": null, + "concreteType": "Organization", + "plural": false, + "selections": [ + (v1/*: any*/), + (v2/*: any*/), + (v3/*: any*/), + { + "kind": "ScalarField", + "alias": null, + "name": "viewerCanCreateRepositories", + "args": null, + "storageKey": null + }, + { + "kind": "ScalarField", + "alias": null, + "name": "__typename", + "args": null, + "storageKey": null + } + ] + } + ] + } + ] + }, + { + "kind": "LinkedHandle", + "alias": null, + "name": "organizations", + "args": (v4/*: any*/), + "handle": "connection", + "key": "RepositoryHomeSelectionView_organizations", + "filters": null + } + ] + } + ] + }, + "params": { + "operationKind": "query", + "name": "createDialogContainerQuery", + "id": null, + "text": "query createDialogContainerQuery(\n $organizationCount: Int!\n $organizationCursor: String\n) {\n viewer {\n ...createDialogController_user\n ...repositoryHomeSelectionView_user_12CDS5\n id\n }\n}\n\nfragment createDialogController_user on User {\n id\n}\n\nfragment repositoryHomeSelectionView_user_12CDS5 on User {\n id\n login\n avatarUrl(size: 24)\n organizations(first: $organizationCount, after: $organizationCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n login\n avatarUrl(size: 24)\n viewerCanCreateRepositories\n __typename\n }\n }\n }\n}\n", + "metadata": {} + } +}; +})(); +// prettier-ignore +(node/*: any*/).hash = '95fae77aa6d826a222d2e2dde81c5cc3'; +module.exports = node; diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js index 68df1d2c6f..3b8b91d846 100644 --- a/lib/containers/create-dialog-container.js +++ b/lib/containers/create-dialog-container.js @@ -1,7 +1,112 @@ import React from 'react'; +import PropTypes from 'prop-types'; +import {QueryRenderer, graphql} from 'react-relay'; + +import CreateDialogController, {BareCreateDialogController} from '../controllers/create-dialog-controller'; +import ObserveModel from '../views/observe-model'; +import RelayNetworkLayerManager from '../relay-network-layer-manager'; +import {getEndpoint} from '../models/endpoint'; +import {GithubLoginModelPropType} from '../prop-types'; + +const DOTCOM = getEndpoint('github.com'); export default class CreateDialogContainer extends React.Component { + static propTypes = { + // Model + loginModel: GithubLoginModelPropType.isRequired, + request: PropTypes.object.isRequired, + inProgress: PropTypes.bool.isRequired, + + // Atom environment + currentWindow: PropTypes.object.isRequired, + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + } + render() { - return null; + return ( + + {this.renderWithToken} + + ); + } + + renderWithToken = token => { + if (!token) { + return this.renderLoading(); + } + + const environment = RelayNetworkLayerManager.getEnvironmentForHost(DOTCOM, token); + const query = graphql` + query createDialogContainerQuery( + $organizationCount: Int! + $organizationCursor: String + ) { + viewer { + ...createDialogController_user + ...repositoryHomeSelectionView_user @arguments( + organizationCount: $organizationCount + organizationCursor: $organizationCursor + ) + } + } + `; + const variables = { + organizationCount: 100, + organizationCursor: null, + }; + + return ( + + ); } + + renderWithResult = ({error, props}) => { + if (error) { + return this.renderError(error); + } + + if (!props) { + return this.renderLoading(); + } + + return ( + + ); + } + + renderError(error) { + return ( + + ); + } + + renderLoading() { + return ( + + ); + } + + fetchToken = loginModel => loginModel.getToken(DOTCOM) } diff --git a/test/containers/create-dialog-container.test.js b/test/containers/create-dialog-container.test.js index 277344fe41..a11c9b4bd0 100644 --- a/test/containers/create-dialog-container.test.js +++ b/test/containers/create-dialog-container.test.js @@ -1,9 +1,18 @@ import React from 'react'; +import {QueryRenderer} from 'react-relay'; import {shallow} from 'enzyme'; import CreateDialogContainer from '../../lib/containers/create-dialog-container'; +import CreateDialogController, {BareCreateDialogController} from '../../lib/controllers/create-dialog-controller'; +import {dialogRequests} from '../../lib/controllers/dialogs-controller'; import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; import GithubLoginModel from '../../lib/models/github-login-model'; +import {getEndpoint} from '../../lib/models/endpoint'; +import {queryBuilder} from '../builder/graphql/query'; + +import query from '../../lib/containers/__generated__/createDialogContainerQuery.graphql'; + +const DOTCOM = getEndpoint('github.com'); describe('CreateDialogContainer', function() { let atomEnv; @@ -17,41 +26,68 @@ describe('CreateDialogContainer', function() { }); function buildApp(override = {}) { - const loginModel = new GithubLoginModel(InMemoryStrategy); - return ( ); } - it('renders the dialog view in a loading state before the token is provided', async function() { + it('renders the dialog controller in a loading state before the token is provided', function() { const loginModel = new GithubLoginModel(InMemoryStrategy); - loginModel.setToken('https://api.github.com', '12345'); + loginModel.setToken('https://api.github.com', 'good-token'); const wrapper = shallow(buildApp({loginModel})); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')(null); + assert.isNull(tokenWrapper.find(BareCreateDialogController).prop('user')); + assert.isTrue(tokenWrapper.find(BareCreateDialogController).prop('isLoading')); + }); + + it('fetches the login token from the login model', async function() { + const loginModel = new GithubLoginModel(InMemoryStrategy); + loginModel.setToken(DOTCOM, 'good-token'); + + const wrapper = shallow(buildApp({loginModel})); const observer = wrapper.find('ObserveModel'); assert.strictEqual(observer.prop('model'), loginModel); - assert.strictEqual(await observer.prop('fetchData')(loginModel), '12345'); - - const tokenWrapper = observer.renderProp('children')(null); - assert.isTrue(tokenWrapper.find('CreateDialogView').prop('isLoading')); + assert.strictEqual(await observer.prop('fetchData')(loginModel), 'good-token'); }); - it('renders the dialog view in a loading state before the GraphQL query completes', function() { + it('renders the dialog controller in a loading state before the GraphQL query completes', function() { const wrapper = shallow(buildApp()); - const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('12345'); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('good-token'); + const queryWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({error: null, props: null}); + + assert.isNull(queryWrapper.find(BareCreateDialogController).prop('user')); + assert.isTrue(queryWrapper.find(BareCreateDialogController).prop('isLoading')); + }); + + it('passes GraphQL errors to the dialog controller', function() { + const error = new Error('AAHHHHHHH'); - const query = tokenWrapper.find('QueryRenderer'); - const queryWrapper = query.renderProp('render')({error: null, props: null}); + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('good-token'); + const queryWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({error, props: null}); - assert.isTrue(queryWrapper.find('CreateDialogView').prop('isLoading')); + assert.isNull(queryWrapper.find(BareCreateDialogController).prop('user')); + assert.strictEqual(queryWrapper.find(BareCreateDialogController).prop('error'), error); }); - it('passes GraphQL errors to the dialog view'); + it('passes GraphQL query results to the dialog controller', function() { + const props = queryBuilder(query).build(); + + const wrapper = shallow(buildApp()); + const tokenWrapper = wrapper.find('ObserveModel').renderProp('children')('good-token'); + const queryWrapper = tokenWrapper.find(QueryRenderer).renderProp('render')({error: null, props}); - it('passes GraphQL query results to the dialog view'); + assert.strictEqual(queryWrapper.find(CreateDialogController).prop('user'), props.user); + }); }); From a4e90f8687238dc26768e3c8e57d85b11674f1fd Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 26 Jul 2019 13:24:29 -0400 Subject: [PATCH 14/78] GraphQL builder fields for createRepository mutation --- test/builder/graphql/mutations.js | 5 +++++ test/builder/graphql/query.js | 2 ++ 2 files changed, 7 insertions(+) diff --git a/test/builder/graphql/mutations.js b/test/builder/graphql/mutations.js index b0332c6f83..a1b97a9914 100644 --- a/test/builder/graphql/mutations.js +++ b/test/builder/graphql/mutations.js @@ -2,6 +2,7 @@ import {createSpecBuilderClass} from './base'; import {CommentBuilder, ReviewBuilder, ReviewThreadBuilder} from './pr'; import {ReactableBuilder} from './reactable'; +import {RepositoryBuilder} from './repository'; const ReviewEdgeBuilder = createSpecBuilderClass('PullRequestReviewEdge', { node: {linked: ReviewBuilder}, @@ -50,3 +51,7 @@ export const AddReactionPayloadBuilder = createSpecBuilderClass('AddReactionPayl export const RemoveReactionPayloadBuilder = createSpecBuilderClass('RemoveReactionPayload', { subject: {linked: ReactableBuilder}, }); + +export const CreateRepositoryPayloadBuilder = createSpecBuilderClass('CreateRepositoryPayload', { + repository: {linked: RepositoryBuilder}, +}); diff --git a/test/builder/graphql/query.js b/test/builder/graphql/query.js index ca93bd152a..5c4c8ca25f 100644 --- a/test/builder/graphql/query.js +++ b/test/builder/graphql/query.js @@ -17,6 +17,7 @@ import { UnresolveReviewThreadPayloadBuilder, AddReactionPayloadBuilder, RemoveReactionPayloadBuilder, + CreateRepositoryPayloadBuilder, } from './mutations'; class SearchResultItemBuilder { @@ -59,6 +60,7 @@ const QueryBuilder = createSpecBuilderClass('Query', { unresolveReviewThread: {linked: UnresolveReviewThreadPayloadBuilder}, addReaction: {linked: AddReactionPayloadBuilder}, removeReaction: {linked: RemoveReactionPayloadBuilder}, + createRepository: {linked: CreateRepositoryPayloadBuilder}, }); export function queryBuilder(...nodes) { From 3a4946e658c7857d2462bef820959aa8eee5b468 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 26 Jul 2019 13:24:59 -0400 Subject: [PATCH 15/78] CreateDialog and create and publish action methods --- lib/views/create-dialog.js | 56 +++++- test/views/create-dialog.test.js | 319 +++++++++++++++++++++++++++++-- 2 files changed, 358 insertions(+), 17 deletions(-) diff --git a/lib/views/create-dialog.js b/lib/views/create-dialog.js index 440ad36d98..d1e9dc55eb 100644 --- a/lib/views/create-dialog.js +++ b/lib/views/create-dialog.js @@ -1,7 +1,61 @@ import React from 'react'; +import PropTypes from 'prop-types'; +import fs from 'fs-extra'; + +import CreateDialogContainer from '../containers/create-dialog-container'; +import createRepositoryMutation from '../mutations/create-repository'; +import {GithubLoginModelPropType} from '../prop-types'; export default class CreateDialog extends React.Component { + static propTypes = { + // Model + loginModel: GithubLoginModelPropType.isRequired, + request: PropTypes.object.isRequired, + inProgress: PropTypes.bool.isRequired, + + // Atom environment + currentWindow: PropTypes.object.isRequired, + workspace: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, + config: PropTypes.object.isRequired, + } + render() { - return null; + return ; } } + +export async function createRepository( + {ownerID, name, visibility, localPath, protocol, sourceRemoteName}, + {clone, relayEnvironment}, +) { + await fs.ensureDir(localPath, 0o755); + const result = await createRepositoryMutation(relayEnvironment, {name, ownerID, visibility}); + const sourceURL = result.createRepository.repository[protocol === 'ssh' ? 'sshUrl' : 'url']; + await clone(sourceURL, localPath, sourceRemoteName); +} + +export async function publishRepository( + {ownerID, name, visibility, protocol, sourceRemoteName}, + {repository, relayEnvironment}, +) { + let defaultBranchName; + const branchSet = await repository.getBranches(); + const branchNames = new Set(branchSet.getNames()); + if (branchNames.has('master')) { + defaultBranchName = 'master'; + } else { + const head = branchSet.getHeadBranch(); + if (head.isPresent()) { + defaultBranchName = head.getName(); + } + } + if (!defaultBranchName) { + throw new Error('Unable to determine the desired default branch from the repository'); + } + + const result = await createRepositoryMutation(relayEnvironment, {name, ownerID, visibility}); + const sourceURL = result.createRepository.repository[protocol === 'ssh' ? 'sshUrl' : 'url']; + const remote = await repository.addRemote(sourceRemoteName, sourceURL); + await repository.push(defaultBranchName, {remote, setUpstream: true}); +} diff --git a/test/views/create-dialog.test.js b/test/views/create-dialog.test.js index f5d9da5720..ac877841bd 100644 --- a/test/views/create-dialog.test.js +++ b/test/views/create-dialog.test.js @@ -1,42 +1,329 @@ import React from 'react'; import {shallow} from 'enzyme'; +import fs from 'fs-extra'; +import temp from 'temp'; -import CreateDialog from '../../lib/views/create-dialog'; +import CreateDialog, {createRepository, publishRepository} from '../../lib/views/create-dialog'; +import CreateDialogContainer from '../../lib/containers/create-dialog-container'; +import {dialogRequests} from '../../lib/controllers/dialogs-controller'; +import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; +import GithubLoginModel from '../../lib/models/github-login-model'; +import RelayNetworkLayerManager, {expectRelayQuery} from '../../lib/relay-network-layer-manager'; +import {getEndpoint} from '../../lib/models/endpoint'; +import {relayResponseBuilder} from '../builder/graphql/query'; +import {cloneRepository, buildRepository} from '../helpers'; + +import createRepositoryQuery from '../../lib/mutations/__generated__/createRepositoryMutation.graphql'; + +const CREATED_REMOTE = Symbol('created-remote'); describe('CreateDialog', function() { - let atomEnv; + let atomEnv, relayEnvironment; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); + relayEnvironment = RelayNetworkLayerManager.getEnvironmentForHost(getEndpoint('github.com'), 'good-token'); }); afterEach(function() { atomEnv.destroy(); }); - function buildApp(override = {}) { - return ; - } + it('passes everything to the CreateDialogContainer', function() { + const request = dialogRequests.create(); + const loginModel = new GithubLoginModel(InMemoryStrategy); + + const wrapper = shallow( + , + ); + + const container = wrapper.find(CreateDialogContainer); + assert.strictEqual(container.prop('loginModel'), loginModel); + assert.strictEqual(container.prop('request'), request); + assert.isFalse(container.prop('inProgress')); + assert.strictEqual(container.prop('currentWindow'), atomEnv.getCurrentWindow()); + assert.strictEqual(container.prop('workspace'), atomEnv.workspace); + assert.strictEqual(container.prop('commands'), atomEnv.commands); + assert.strictEqual(container.prop('config'), atomEnv.config); + }); + + describe('createRepository', function() { + it('successfully creates a locally cloned GitHub repository', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PUBLIC'}}, + }, op => { + return relayResponseBuilder(op) + .createRepository(m => { + m.repository(r => { + r.sshUrl('ssh@github.com:user0/repo-name.git'); + r.url('https://github.com/user0/repo-name'); + }); + }) + .build(); + }).resolve(); + + const localPath = temp.path({prefix: 'createrepo-'}); + const clone = sinon.stub().resolves(); + + await createRepository({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PUBLIC', + localPath, + protocol: 'https', + sourceRemoteName: 'home', + }, {clone, relayEnvironment}); + + const localStat = await fs.stat(localPath); + assert.isTrue(localStat.isDirectory()); + assert.isTrue(clone.calledWith('https://github.com/user0/repo-name', localPath, 'home')); + }); - describe('create mode', function() { - it('shows a header for repository creation'); + it('clones with ssh when requested', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PRIVATE'}}, + }, op => { + return relayResponseBuilder(op) + .createRepository(m => { + m.repository(r => { + r.sshUrl('ssh@github.com:user0/repo-name.git'); + r.url('https://github.com/user0/repo-name'); + }); + }) + .build(); + }).resolve(); - it('displays form controls to configure repository owner and name'); + const localPath = temp.path({prefix: 'createrepo-'}); + const clone = sinon.stub().resolves(); - it('chooses public or private visibility'); + await createRepository({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PRIVATE', + localPath, + protocol: 'ssh', + sourceRemoteName: 'origin', + }, {clone, relayEnvironment}); - it('shows a directory selection control'); + assert.isTrue(clone.calledWith('ssh@github.com:user0/repo-name.git', localPath, 'origin')); + }); - it('shows advanced controls for clone protocol and source remote name'); + it('fails fast if the local path cannot be created', async function() { + const clone = sinon.stub().resolves(); - it('uses "create" text on the accept button'); + await assert.isRejected(createRepository({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PUBLIC', + localPath: __filename, + protocol: 'https', + sourceRemoteName: 'origin', + }, {clone, relayEnvironment})); + + assert.isFalse(clone.called); + }); + + it('fails if the mutation fails', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PRIVATE'}}, + }, op => { + return relayResponseBuilder(op) + .addError('oh no') + .build(); + }).resolve(); + + const clone = sinon.stub().resolves(); + + await assert.isRejected(createRepository({ + ownerID: 'user0', + name: 'already-exists', + visibility: 'PRIVATE', + localPath: __filename, + protocol: 'https', + sourceRemoteName: 'origin', + }, {clone, relayEnvironment})); + + assert.isFalse(clone.called); + }); }); - describe('publish mode', function() { - it('shows a header for repository publishing'); + describe('publishRepository', function() { + let repository; + + beforeEach(async function() { + repository = await buildRepository(await cloneRepository('multiple-commits')); + }); + + it('successfully publishes an existing local repository', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PUBLIC'}}, + }, op => { + return relayResponseBuilder(op) + .createRepository(m => { + m.repository(r => { + r.sshUrl('ssh@github.com:user0/repo-name.git'); + r.url('https://github.com/user0/repo-name'); + }); + }) + .build(); + }).resolve(); + + sinon.stub(repository, 'addRemote').resolves(CREATED_REMOTE); + sinon.stub(repository, 'push'); + + await publishRepository({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PUBLIC', + protocol: 'https', + sourceRemoteName: 'origin', + }, {repository, relayEnvironment}); + + assert.isTrue(repository.addRemote.calledWith('origin', 'https://github.com/user0/repo-name')); + assert.isTrue(repository.push.calledWith('master', {remote: CREATED_REMOTE, setUpstream: true})); + }); + + it('constructs an ssh remote URL when requested', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PUBLIC'}}, + }, op => { + return relayResponseBuilder(op) + .createRepository(m => { + m.repository(r => { + r.sshUrl('ssh@github.com:user0/repo-name.git'); + r.url('https://github.com/user0/repo-name'); + }); + }) + .build(); + }).resolve(); + + sinon.stub(repository, 'addRemote').resolves(CREATED_REMOTE); + sinon.stub(repository, 'push'); + + await publishRepository({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PUBLIC', + protocol: 'ssh', + sourceRemoteName: 'upstream', + }, {repository, relayEnvironment}); + + assert.isTrue(repository.addRemote.calledWith('upstream', 'ssh@github.com:user0/repo-name.git')); + assert.isTrue(repository.push.calledWith('master', {remote: CREATED_REMOTE, setUpstream: true})); + }); + + it('uses "master" as the default branch if present, even if not checked out', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PUBLIC'}}, + }, op => { + return relayResponseBuilder(op) + .createRepository(m => { + m.repository(r => { + r.sshUrl('ssh@github.com:user0/repo-name.git'); + r.url('https://github.com/user0/repo-name'); + }); + }) + .build(); + }).resolve(); + + await repository.checkout('other-branch', {createNew: true}); + + sinon.stub(repository, 'addRemote').resolves(CREATED_REMOTE); + sinon.stub(repository, 'push'); + + await publishRepository({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PUBLIC', + protocol: 'https', + sourceRemoteName: 'origin', + }, {repository, relayEnvironment}); + + assert.isTrue(repository.addRemote.calledWith('origin', 'https://github.com/user0/repo-name')); + assert.isTrue(repository.push.calledWith('master', {remote: CREATED_REMOTE, setUpstream: true})); + }); + + it('uses HEAD as the default branch if master is not present', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PUBLIC'}}, + }, op => { + return relayResponseBuilder(op) + .createRepository(m => { + m.repository(r => { + r.sshUrl('ssh@github.com:user0/repo-name.git'); + r.url('https://github.com/user0/repo-name'); + }); + }) + .build(); + }).resolve(); + + await repository.checkout('non-head-branch', {createNew: true}); + await repository.checkout('other-branch', {createNew: true}); + await repository.git.deleteRef('refs/heads/master'); + repository.refresh(); + + sinon.stub(repository, 'addRemote').resolves(CREATED_REMOTE); + sinon.stub(repository, 'push'); + + await publishRepository({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PUBLIC', + protocol: 'https', + sourceRemoteName: 'origin', + }, {repository, relayEnvironment}); + + assert.isTrue(repository.addRemote.calledWith('origin', 'https://github.com/user0/repo-name')); + assert.isTrue(repository.push.calledWith('other-branch', {remote: CREATED_REMOTE, setUpstream: true})); + }); + + it('fails if the source repository has no "master" or current branches', async function() { + await repository.checkout('other-branch', {createNew: true}); + await repository.checkout('HEAD^'); + await repository.git.deleteRef('refs/heads/master'); + repository.refresh(); + + await assert.isRejected(publishRepository({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PUBLIC', + protocol: 'https', + sourceRemoteName: 'origin', + }, {repository, relayEnvironment})); + }); - it('prepopulates and disables the directory selection control'); + it('fails if the mutation fails', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PRIVATE'}}, + }, op => { + return relayResponseBuilder(op) + .addError('oh no') + .build(); + }).resolve(); - it('uses "publish" text on the accept button'); + await assert.isRejected(publishRepository({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PRIVATE', + protocol: 'https', + sourceRemoteName: 'origin', + }, {repository, relayEnvironment})); + }); }); }); From 09669fc8ac1245975d590dbfe9addf7d3bb25161 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 26 Jul 2019 13:33:21 -0400 Subject: [PATCH 16/78] Pass currentWindow to RootController --- lib/controllers/root-controller.js | 1 + lib/github-package.js | 4 +++- lib/index.js | 1 + test/controllers/root-controller.test.js | 1 + test/github-package.test.js | 7 ++++--- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index d7056b70aa..c3485740c5 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -44,6 +44,7 @@ export default class RootController extends React.Component { config: PropTypes.object.isRequired, project: PropTypes.object.isRequired, confirm: PropTypes.func.isRequired, + currentWindow: PropTypes.object.isRequired, // Models loginModel: PropTypes.object.isRequired, diff --git a/lib/github-package.js b/lib/github-package.js index e49389bdba..e9bfccd366 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -32,7 +32,7 @@ export default class GithubPackage { constructor({ workspace, project, commands, notificationManager, tooltips, styles, grammars, keymaps, config, deserializers, - confirm, getLoadSettings, + confirm, getLoadSettings, currentWindow, configDirPath, renderFn, loginModel, }) { @@ -54,6 +54,7 @@ export default class GithubPackage { this.grammars = grammars; this.keymaps = keymaps; this.configPath = path.join(configDirPath, 'github.cson'); + this.currentWindow = currentWindow; this.styleCalculator = new StyleCalculator(this.styles, this.config); this.confirm = confirm; @@ -281,6 +282,7 @@ export default class GithubPackage { config={this.config} project={this.project} confirm={this.confirm} + currentWindow={this.currentWindow} workdirContextPool={this.contextPool} loginModel={this.loginModel} repository={this.getActiveRepository()} diff --git a/lib/index.js b/lib/index.js index 814bca9773..3127c286c7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -17,6 +17,7 @@ const entry = { confirm: atom.confirm.bind(atom), getLoadSettings: atom.getLoadSettings.bind(atom), + currentWindow: atom.getCurrentWindow(), configDirPath: atom.getConfigDirPath(), }); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index ee56e91dc0..db09672ab9 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -59,6 +59,7 @@ describe('RootController', function() { confirm={confirm} project={project} keymaps={atomEnv.keymaps} + currentWindow={atomEnv.getCurrentWindow()} loginModel={loginModel} workdirContextPool={workdirContextPool} diff --git a/test/github-package.test.js b/test/github-package.test.js index 6e037704fa..23bffceac0 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -10,7 +10,7 @@ import GithubPackage from '../lib/github-package'; describe('GithubPackage', function() { let atomEnv, workspace, project, commands, notificationManager, grammars, config, keymaps; let confirm, tooltips, styles; - let getLoadSettings, configDirPath, deserializers; + let getLoadSettings, currentWindow, configDirPath, deserializers; let githubPackage, contextPool; beforeEach(async function() { @@ -29,12 +29,13 @@ describe('GithubPackage', function() { styles = atomEnv.styles; grammars = atomEnv.grammars; getLoadSettings = atomEnv.getLoadSettings.bind(atomEnv); + currentWindow = atomEnv.getCurrentWindow(); configDirPath = path.join(__dirname, 'fixtures', 'atomenv-config'); githubPackage = new GithubPackage({ workspace, project, commands, notificationManager, tooltips, styles, grammars, keymaps, config, deserializers, - confirm, getLoadSettings, + confirm, getLoadSettings, currentWindow, configDirPath, renderFn: sinon.stub().callsFake((component, element, callback) => { if (callback) { @@ -77,7 +78,7 @@ describe('GithubPackage', function() { githubPackage1 = new GithubPackage({ workspace, project, commands, notificationManager, tooltips, styles, grammars, keymaps, - config, deserializers, confirm, getLoadSettings: getLoadSettings1, + config, deserializers, confirm, getLoadSettings: getLoadSettings1, currentWindow, configDirPath, }); } From 3d21f6ce273c74b6774f053a4a88f1afb9680fc7 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sat, 27 Jul 2019 14:18:15 -0400 Subject: [PATCH 17/78] Allow RelayNetworkLayerManager to be called without a token --- lib/relay-network-layer-manager.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/relay-network-layer-manager.js b/lib/relay-network-layer-manager.js index 846875c862..f97ddd859b 100644 --- a/lib/relay-network-layer-manager.js +++ b/lib/relay-network-layer-manager.js @@ -152,6 +152,10 @@ export default class RelayNetworkLayerManager { let {environment, network} = relayEnvironmentPerURL.get(url) || {}; tokenPerURL.set(url, token); if (!environment) { + if (!token) { + throw new Error(`You must authenticate to ${endpoint.getHost()} first.`); + } + const source = new RecordSource(); const store = new Store(source); network = Network.create(this.getFetchQuery(endpoint, token)); From a4737a781f15add2e4a2e8cdc3ccf3aea13f1c48 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sat, 27 Jul 2019 14:18:36 -0400 Subject: [PATCH 18/78] isPublishable() -> true for present repositories --- lib/models/repository-states/present.js | 4 ++++ lib/models/repository-states/state.js | 4 ++++ lib/models/repository.js | 1 + 3 files changed, 9 insertions(+) diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index 710bbc2cfe..31b856dda1 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -98,6 +98,10 @@ export default class Present extends State { return true; } + isPublishable() { + return true; + } + acceptInvalidation(spec) { this.cache.invalidate(spec()); this.didUpdate(); diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 9e36786062..9152b98e55 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -89,6 +89,10 @@ export default class State { return true; } + isPublishable() { + return false; + } + // Lifecycle actions ///////////////////////////////////////////////////////////////////////////////////////////////// // These generally default to rejecting a Promise with an error. diff --git a/lib/models/repository.js b/lib/models/repository.js index 65c07298b7..79bfaabc8a 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -291,6 +291,7 @@ const delegates = [ 'showGitTabLoading', 'showStatusBarTiles', 'hasDirectory', + 'isPublishable', 'init', 'clone', From 67f2626260d0f12937cead5cbd83b917231b167d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sat, 27 Jul 2019 14:18:54 -0400 Subject: [PATCH 19/78] `github:create-repository` and `github:publish-repository` commands --- lib/controllers/root-controller.js | 35 ++++++ test/controllers/root-controller.test.js | 144 +++++++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index c3485740c5..8a4200d8b1 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -10,6 +10,7 @@ import StatusBar from '../atom/status-bar'; import PaneItem from '../atom/pane-item'; import {openIssueishItem} from '../views/open-issueish-dialog'; import {openCommitDetailItem} from '../views/open-commit-dialog'; +import {createRepository, publishRepository} from '../views/create-dialog'; import Commands, {Command} from '../atom/commands'; import ChangedFileItem from '../items/changed-file-item'; import IssueishDetailItem from '../items/issueish-detail-item'; @@ -22,9 +23,11 @@ import CommentDecorationsContainer from '../containers/comment-decorations-conta import DialogsController, {dialogRequests} from './dialogs-controller'; import StatusBarTileController from './status-bar-tile-controller'; import RepositoryConflictController from './repository-conflict-controller'; +import RelayNetworkLayerManager from '../relay-network-layer-manager'; import GitCacheView from '../views/git-cache-view'; import GitTimingsView from '../views/git-timings-view'; import Conflict from '../models/conflicts/conflict'; +import {getEndpoint} from '../models/endpoint'; import Switchboard from '../switchboard'; import {WorkdirContextPoolPropType} from '../prop-types'; import {destroyFilePatchPaneItems, destroyEmptyFilePatchPaneItems, autobind} from '../helpers'; @@ -146,6 +149,10 @@ export default class RootController extends React.Component { this.openCloneDialog()} /> this.openIssueishDialog()} /> this.openCommitDialog()} /> + this.openCreateDialog()} /> + {this.props.repository.isPublishable() && ( + this.openPublishDialog(this.props.repository)} /> + ) || null} this.setState({dialogRequest}, resolve)); } + openCreateDialog = () => { + const dialogRequest = dialogRequests.create(); + dialogRequest.onProgressingAccept(async result => { + const dotcom = getEndpoint('github.com'); + const relayEnvironment = RelayNetworkLayerManager.getEnvironmentForHost(dotcom); + + await createRepository(result, {clone: this.props.clone, relayEnvironment}); + await this.closeDialog(); + }); + dialogRequest.onCancel(this.closeDialog); + + return new Promise(resolve => this.setState({dialogRequest}, resolve)); + } + + openPublishDialog = repository => { + const dialogRequest = dialogRequests.publish({localDir: repository.getWorkingDirectoryPath()}); + dialogRequest.onProgressingAccept(async result => { + const dotcom = getEndpoint('github.com'); + const relayEnvironment = RelayNetworkLayerManager.getEnvironmentForHost(dotcom); + + await publishRepository(result, {repository, relayEnvironment}); + await this.closeDialog(); + }); + dialogRequest.onCancel(this.closeDialog); + + return new Promise(resolve => this.setState({dialogRequest}, resolve)); + } + toggleCommitPreviewItem = () => { const workdir = this.props.repository.getWorkingDirectoryPath(); return this.props.workspace.toggle(CommitPreviewItem.buildURI(workdir)); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index db09672ab9..c897eb34a9 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -12,6 +12,7 @@ import Repository from '../../lib/models/repository'; import WorkdirContextPool from '../../lib/models/workdir-context-pool'; import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; import RefHolder from '../../lib/models/ref-holder'; +import {getEndpoint} from '../../lib/models/endpoint'; import GithubLoginModel from '../../lib/models/github-login-model'; import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; import {dialogRequests} from '../../lib/controllers/dialogs-controller'; @@ -20,6 +21,9 @@ import GitHubTabItem from '../../lib/items/github-tab-item'; import IssueishDetailItem from '../../lib/items/issueish-detail-item'; import CommitPreviewItem from '../../lib/items/commit-preview-item'; import CommitDetailItem from '../../lib/items/commit-detail-item'; +import RelayNetworkLayerManager, {expectRelayQuery} from '../../lib/relay-network-layer-manager'; +import createRepositoryQuery from '../../lib/mutations/__generated__/createRepositoryMutation.graphql'; +import {relayResponseBuilder} from '../builder/graphql/query'; import * as reporterProxy from '../../lib/reporter-proxy'; import RootController from '../../lib/controllers/root-controller'; @@ -556,6 +560,146 @@ describe('RootController', function() { }); }); + describe('openCreateDialog()', function() { + it('renders the modal create dialog', function() { + const wrapper = shallow(app); + + wrapper.find('Command[command="github:create-repository"]').prop('callback')(); + assert.strictEqual(wrapper.find('DialogsController').prop('request').identifier, 'create'); + }); + + it('creates a repository on GitHub on accept', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PUBLIC'}}, + }, op => { + return relayResponseBuilder(op) + .createRepository(m => { + m.repository(r => { + r.sshUrl('ssh@github.com:user0/repo-name.git'); + r.url('https://github.com/user0/repo-name'); + }); + }) + .build(); + }).resolve(); + + RelayNetworkLayerManager.getEnvironmentForHost(getEndpoint('github.com'), 'good-token'); + const clone = sinon.spy(); + const localPath = temp.path({prefix: 'rootctrl-'}); + + const wrapper = shallow(React.cloneElement(app, {clone})); + wrapper.find('Command[command="github:create-repository"]').prop('callback')(); + + const req0 = wrapper.find('DialogsController').prop('request'); + await req0.accept({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PUBLIC', + localPath, + protocol: 'https', + sourceRemoteName: 'home', + }); + + assert.isTrue(clone.calledWith('https://github.com/user0/repo-name', localPath, 'home')); + + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); + }); + + it('dismisses the CreateDialog on cancel', function() { + const wrapper = shallow(app); + wrapper.find('Command[command="github:create-repository"]').prop('callback')(); + + const req0 = wrapper.find('DialogsController').prop('request'); + req0.cancel(); + + wrapper.update(); + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); + }); + }); + + describe('openPublishDialog()', function() { + let publishable; + + beforeEach(async function() { + publishable = await buildRepository(await cloneRepository()); + }); + + it('renders the modal publish dialog', function() { + const wrapper = shallow(React.cloneElement(app, {repository: publishable})); + + wrapper.find('Command[command="github:publish-repository"]').prop('callback')(); + assert.strictEqual(wrapper.find('DialogsController').prop('request').identifier, 'publish'); + }); + + it('publishes the active repository to GitHub on accept', async function() { + expectRelayQuery({ + name: createRepositoryQuery.operation.name, + variables: {input: {name: 'repo-name', ownerId: 'user0', visibility: 'PUBLIC'}}, + }, op => { + return relayResponseBuilder(op) + .createRepository(m => { + m.repository(r => { + r.sshUrl('ssh@github.com:user0/repo-name.git'); + r.url('https://github.com/user0/repo-name'); + }); + }) + .build(); + }).resolve(); + RelayNetworkLayerManager.getEnvironmentForHost(getEndpoint('github.com'), 'good-token'); + sinon.stub(publishable, 'push').resolves(); + + const wrapper = shallow(React.cloneElement(app, {repository: publishable})); + + wrapper.find('Command[command="github:publish-repository"]').prop('callback')(); + + const req0 = wrapper.find('DialogsController').prop('request'); + await req0.accept({ + ownerID: 'user0', + name: 'repo-name', + visibility: 'PUBLIC', + protocol: 'ssh', + sourceRemoteName: 'home', + }); + + const remoteSet = await publishable.getRemotes(); + const addedRemote = remoteSet.withName('home'); + assert.isTrue(addedRemote.isPresent()); + assert.strictEqual(addedRemote.getUrl(), 'ssh@github.com:user0/repo-name.git'); + + assert.isTrue(publishable.push.calledWith('master', {setUpstream: true, remote: addedRemote})); + + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); + }); + + it('dismisses the CreateDialog on cancel', function() { + const wrapper = shallow(React.cloneElement(app, {repository: publishable})); + wrapper.find('Command[command="github:publish-repository"]').prop('callback')(); + + const req0 = wrapper.find('DialogsController').prop('request'); + req0.cancel(); + + wrapper.update(); + const req1 = wrapper.find('DialogsController').prop('request'); + assert.strictEqual(req1, dialogRequests.null); + }); + + it('does not render the command if the current repository is absent', function() { + const repository = Repository.absent(); + const wrapper = shallow(React.cloneElement(app, {repository})); + assert.isFalse(wrapper.exists('Command[command="github:publish-repository"]')); + }); + + it('does not render the command if the current repository is empty', async function() { + const emptyWorkdir = temp.mkdirSync({prefix: 'rootctrl-'}); + const repository = await buildRepository(emptyWorkdir); + const wrapper = shallow(React.cloneElement(app, {repository})); + assert.isFalse(wrapper.exists('Command[command="github:publish-repository"]')); + }); + }); + describe('openFiles(filePaths)', () => { it('calls workspace.open, passing pending:true if only one file path is passed', async () => { const workdirPath = await cloneRepository('three-files'); From 1beab81fedf81079c6d30a7558174e8c78ce5634 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sat, 27 Jul 2019 14:25:13 -0400 Subject: [PATCH 20/78] Accept a source remote name when cloning a repository --- lib/git-shell-out-strategy.js | 1 + lib/github-package.js | 6 +++--- lib/models/repository-states/cloning.js | 5 +++-- lib/models/repository-states/empty.js | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index eee64471bf..6fe27f0d91 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -886,6 +886,7 @@ export default class GitShellOutStrategy { if (options.noLocal) { args.push('--no-local'); } if (options.bare) { args.push('--bare'); } if (options.recursive) { args.push('--recursive'); } + if (options.sourceRemoteName) { args.push('--origin', options.remoteName); } args.push(remoteUrl, this.workingDir); return this.exec(args, {useGitPromptServer: true, writeOperation: true}); diff --git a/lib/github-package.js b/lib/github-package.js index e9bfccd366..3da08abf44 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -442,16 +442,16 @@ export default class GithubPackage { await this.scheduleActiveContextUpdate(); } - clone = async (remoteUrl, projectPath) => { + clone = async (remoteUrl, projectPath, sourceRemoteName = 'origin') => { const context = this.contextPool.getContext(projectPath); let repository; if (context.isPresent()) { repository = context.getRepository(); - await repository.clone(remoteUrl); + await repository.clone(remoteUrl, sourceRemoteName); repository.destroy(); } else { repository = new Repository(projectPath, null, {pipelineManager: this.pipelineManager}); - await repository.clone(remoteUrl); + await repository.clone(remoteUrl, sourceRemoteName); } this.workdirCache.invalidate(); diff --git a/lib/models/repository-states/cloning.js b/lib/models/repository-states/cloning.js index 44afb487dd..52d1bc1cbe 100644 --- a/lib/models/repository-states/cloning.js +++ b/lib/models/repository-states/cloning.js @@ -6,14 +6,15 @@ import State from './state'; * Git is asynchronously cloning a repository into this working directory. */ export default class Cloning extends State { - constructor(repository, remoteUrl) { + constructor(repository, remoteUrl, sourceRemoteName) { super(repository); this.remoteUrl = remoteUrl; + this.sourceRemoteName = sourceRemoteName; } async start() { await fs.mkdirs(this.workdir()); - await this.doClone(this.remoteUrl, {recursive: true}); + await this.doClone(this.remoteUrl, {recursive: true, sourceRemoteName: this.sourceRemoteName}); await this.transitionTo('Loading'); } diff --git a/lib/models/repository-states/empty.js b/lib/models/repository-states/empty.js index 116ceae31c..fe8d9e3f24 100644 --- a/lib/models/repository-states/empty.js +++ b/lib/models/repository-states/empty.js @@ -12,8 +12,8 @@ export default class Empty extends State { return this.transitionTo('Initializing'); } - clone(remoteUrl) { - return this.transitionTo('Cloning', remoteUrl); + clone(remoteUrl, sourceRemoteName) { + return this.transitionTo('Cloning', remoteUrl, sourceRemoteName); } showGitTabInit() { From 78e7c9df4401baf99bc178a91e434013abe0a829 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sun, 28 Jul 2019 20:49:15 -0400 Subject: [PATCH 21/78] Pass currentWindow and loginModel to DialogsController --- lib/controllers/root-controller.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 8a4200d8b1..953c63966d 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -197,10 +197,13 @@ export default class RootController extends React.Component { renderDialogs() { return ( ); } From e4ecedbc9d4c5a99f2d0357cc50003711926d73f Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sun, 28 Jul 2019 20:49:30 -0400 Subject: [PATCH 22/78] Register create and publish dialogs in DialogsController --- lib/controllers/dialogs-controller.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/controllers/dialogs-controller.js b/lib/controllers/dialogs-controller.js index aac2aadd5d..edf272c4ff 100644 --- a/lib/controllers/dialogs-controller.js +++ b/lib/controllers/dialogs-controller.js @@ -6,6 +6,8 @@ import CloneDialog from '../views/clone-dialog'; import CredentialDialog from '../views/credential-dialog'; import OpenIssueishDialog from '../views/open-issueish-dialog'; import OpenCommitDialog from '../views/open-commit-dialog'; +import CreateDialog from '../views/create-dialog'; +import {GithubLoginModelPropType} from '../prop-types'; const DIALOG_COMPONENTS = { null: NullDialog, @@ -14,17 +16,21 @@ const DIALOG_COMPONENTS = { credential: CredentialDialog, issueish: OpenIssueishDialog, commit: OpenCommitDialog, + create: CreateDialog, + publish: CreateDialog, }; export default class DialogsController extends React.Component { static propTypes = { // Model + loginModel: GithubLoginModelPropType.isRequired, request: PropTypes.shape({ identifier: PropTypes.string.isRequired, isProgressing: PropTypes.bool.isRequired, }).isRequired, // Atom environment + currentWindow: PropTypes.object.isRequired, workspace: PropTypes.object.isRequired, commands: PropTypes.object.isRequired, config: PropTypes.object.isRequired, @@ -65,12 +71,14 @@ export default class DialogsController extends React.Component { const wrapped = wrapDialogRequest(request, {accept}); return { - config: this.props.config, - commands: this.props.commands, - workspace: this.props.workspace, + loginModel: this.props.loginModel, + request: wrapped, inProgress: this.state.requestInProgress === request, + currentWindow: this.props.currentWindow, + workspace: this.props.workspace, + commands: this.props.commands, + config: this.props.config, error: this.state.requestError[0] === request ? this.state.requestError[1] : null, - request: wrapped, }; } } From 5fad68a19e71fb606ecdd9510d32ec642a733558 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sun, 28 Jul 2019 20:50:04 -0400 Subject: [PATCH 23/78] Render BareRepositoryHomeSelectionView directly while loading --- lib/views/create-dialog-view.js | 5 +++-- test/views/create-dialog-view.test.js | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index 0e37002e48..d0d75c1bf9 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import DialogView from './dialog-view'; -import RepositoryHomeSelectionView from './repository-home-selection-view'; +import RepositoryHomeSelectionView, {BareRepositoryHomeSelectionView} from './repository-home-selection-view'; import DirectorySelect from './directory-select'; import RemoteConfigurationView from './remote-configuration-view'; import Octicon from '../atom/octicon'; @@ -59,6 +59,7 @@ export default class CreateDialogView extends React.Component { render() { const text = DIALOG_TEXT[this.props.request.identifier]; + const HomeSelectionView = this.props.user ? RepositoryHomeSelectionView : BareRepositoryHomeSelectionView; return (
- Date: Sun, 28 Jul 2019 20:50:40 -0400 Subject: [PATCH 24/78] Accept AutoFocus in RepositoryHomeSelectionView --- lib/views/repository-home-selection-view.js | 10 +++++++++- test/views/repository-home-selection-view.test.js | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/views/repository-home-selection-view.js b/lib/views/repository-home-selection-view.js index 9394910bdb..84d7d887b8 100644 --- a/lib/views/repository-home-selection-view.js +++ b/lib/views/repository-home-selection-view.js @@ -31,6 +31,9 @@ export class BareRepositoryHomeSelectionView extends React.Component { nameBuffer: PropTypes.object.isRequired, isLoading: PropTypes.bool.isRequired, selectedOwnerID: PropTypes.string.isRequired, + autofocus: PropTypes.shape({ + target: PropTypes.func.isRequired, + }).isRequired, // Selection callback didChangeOwnerID: PropTypes.func.isRequired, @@ -53,7 +56,12 @@ export class BareRepositoryHomeSelectionView extends React.Component { onChange={this.didChangeOwner} /> / - +
); } diff --git a/test/views/repository-home-selection-view.test.js b/test/views/repository-home-selection-view.test.js index 36f8452203..80631c8d0f 100644 --- a/test/views/repository-home-selection-view.test.js +++ b/test/views/repository-home-selection-view.test.js @@ -3,6 +3,7 @@ import {shallow} from 'enzyme'; import {TextBuffer} from 'atom'; import {BareRepositoryHomeSelectionView} from '../../lib/views/repository-home-selection-view'; +import AutoFocus from '../../lib/autofocus'; import userQuery from '../../lib/views/__generated__/repositoryHomeSelectionView_user.graphql'; import {userBuilder} from '../builder/graphql/user'; @@ -26,6 +27,7 @@ describe('RepositoryHomeSelectionView', function() { nameBuffer={nameBuffer} selectedOwnerID={''} didChangeOwnerID={() => {}} + autofocus={new AutoFocus()} {...override} /> ); From 478d62edb871538219fd2254214931afa26c6acd Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sun, 28 Jul 2019 20:50:59 -0400 Subject: [PATCH 25/78] Employ AutoFocus in CreateDialogView --- lib/views/create-dialog-view.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index d0d75c1bf9..64ff9d0fcb 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -5,6 +5,7 @@ import DialogView from './dialog-view'; import RepositoryHomeSelectionView, {BareRepositoryHomeSelectionView} from './repository-home-selection-view'; import DirectorySelect from './directory-select'; import RemoteConfigurationView from './remote-configuration-view'; +import AutoFocus from '../autofocus'; import Octicon from '../atom/octicon'; const DIALOG_TEXT = { @@ -57,6 +58,12 @@ export default class CreateDialogView extends React.Component { config: PropTypes.object.isRequired, } + constructor(props) { + super(props); + + this.autofocus = new AutoFocus(); + } + render() { const text = DIALOG_TEXT[this.props.request.identifier]; const HomeSelectionView = this.props.user ? RepositoryHomeSelectionView : BareRepositoryHomeSelectionView; @@ -68,6 +75,7 @@ export default class CreateDialogView extends React.Component { acceptText={text.acceptText} accept={this.props.accept} cancel={this.props.request.cancel} + autofocus={this.autofocus} inProgress={this.props.inProgress} error={this.props.error} workspace={this.props.workspace} @@ -84,6 +92,7 @@ export default class CreateDialogView extends React.Component { selectedOwnerID={this.props.selectedOwnerID} didChangeOwnerID={this.props.didChangeOwnerID} isLoading={this.props.isLoading} + autofocus={this.autofocus} />
@@ -130,5 +139,9 @@ export default class CreateDialogView extends React.Component { ); } + componentDidMount() { + this.autofocus.trigger(); + } + didChangeVisibility = event => this.props.didChangeVisibility(event.target.value); } From d1d62f0677368c5fb85444d9b82e7dd50630dd8e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sun, 28 Jul 2019 20:51:13 -0400 Subject: [PATCH 26/78] Modify DialogsController tests to add missing props --- test/controllers/dialogs-controller.test.js | 61 +++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/test/controllers/dialogs-controller.test.js b/test/controllers/dialogs-controller.test.js index 0c2934b9d4..7c967b86b6 100644 --- a/test/controllers/dialogs-controller.test.js +++ b/test/controllers/dialogs-controller.test.js @@ -2,6 +2,8 @@ import React from 'react'; import {shallow} from 'enzyme'; import DialogsController, {dialogRequests} from '../../lib/controllers/dialogs-controller'; +import GithubLoginModel from '../../lib/models/github-login-model'; +import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; describe('DialogsController', function() { let atomEnv; @@ -17,10 +19,13 @@ describe('DialogsController', function() { function buildApp(overrides = {}) { return ( ); @@ -215,8 +220,56 @@ describe('DialogsController', function() { assert.isTrue(cancel.called); }); - it('passes appropriate props to the CreateDialog when creating'); + it('passes appropriate props to the CreateDialog when creating', function() { + const accept = sinon.spy(); + const cancel = sinon.spy(); + const request = dialogRequests.create(); + request.onAccept(accept); + request.onCancel(cancel); + + const loginModel = new GithubLoginModel(InMemoryStrategy); + + const wrapper = shallow(buildApp({request, loginModel})); + const dialog = wrapper.find('CreateDialog'); + assert.strictEqual(dialog.prop('loginModel'), loginModel); + assert.strictEqual(dialog.prop('currentWindow'), atomEnv.getCurrentWindow()); + assert.strictEqual(dialog.prop('workspace'), atomEnv.workspace); + assert.strictEqual(dialog.prop('commands'), atomEnv.commands); + assert.strictEqual(dialog.prop('config'), atomEnv.config); + + const req = dialog.prop('request'); + + req.accept('abcd1234'); + assert.isTrue(accept.calledWith('abcd1234')); + + req.cancel(); + assert.isTrue(cancel.called); + }); + + it('passes appropriate props to the CreateDialog when publishing', function() { + const accept = sinon.spy(); + const cancel = sinon.spy(); + const request = dialogRequests.publish({localDir: __dirname}); + request.onAccept(accept); + request.onCancel(cancel); + + const loginModel = new GithubLoginModel(InMemoryStrategy); - it('passes appropriate props to the CreateDialog when publishing'); + const wrapper = shallow(buildApp({request, loginModel})); + const dialog = wrapper.find('CreateDialog'); + assert.strictEqual(dialog.prop('loginModel'), loginModel); + assert.strictEqual(dialog.prop('currentWindow'), atomEnv.getCurrentWindow()); + assert.strictEqual(dialog.prop('workspace'), atomEnv.workspace); + assert.strictEqual(dialog.prop('commands'), atomEnv.commands); + assert.strictEqual(dialog.prop('config'), atomEnv.config); + + const req = dialog.prop('request'); + + req.accept('abcd1234'); + assert.isTrue(accept.calledWith('abcd1234')); + + req.cancel(); + assert.isTrue(cancel.called); + }); }); }); From 1f83c42506ea20e8b31b400053555ff9111643d3 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sun, 28 Jul 2019 21:18:19 -0400 Subject: [PATCH 27/78] Lift AutoFocus up to CreateDialog --- lib/containers/create-dialog-container.js | 1 + lib/controllers/create-dialog-controller.js | 1 + lib/views/create-dialog-view.js | 16 +++------------- lib/views/create-dialog.js | 13 ++++++++++++- test/containers/create-dialog-container.test.js | 2 ++ .../controllers/create-dialog-controller.test.js | 2 ++ 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js index 3b8b91d846..c1efaf4c98 100644 --- a/lib/containers/create-dialog-container.js +++ b/lib/containers/create-dialog-container.js @@ -15,6 +15,7 @@ export default class CreateDialogContainer extends React.Component { // Model loginModel: GithubLoginModelPropType.isRequired, request: PropTypes.object.isRequired, + autofocus: PropTypes.object.isRequired, inProgress: PropTypes.bool.isRequired, // Atom environment diff --git a/lib/controllers/create-dialog-controller.js b/lib/controllers/create-dialog-controller.js index ccdd7163bc..d40996bd60 100644 --- a/lib/controllers/create-dialog-controller.js +++ b/lib/controllers/create-dialog-controller.js @@ -19,6 +19,7 @@ export class BareCreateDialogController extends React.Component { getParams: PropTypes.func.isRequired, accept: PropTypes.func.isRequired, }).isRequired, + autofocus: PropTypes.object.isRequired, error: PropTypes.instanceOf(Error), isLoading: PropTypes.bool.isRequired, inProgress: PropTypes.bool.isRequired, diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index 64ff9d0fcb..df8c8ad867 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -5,7 +5,6 @@ import DialogView from './dialog-view'; import RepositoryHomeSelectionView, {BareRepositoryHomeSelectionView} from './repository-home-selection-view'; import DirectorySelect from './directory-select'; import RemoteConfigurationView from './remote-configuration-view'; -import AutoFocus from '../autofocus'; import Octicon from '../atom/octicon'; const DIALOG_TEXT = { @@ -34,6 +33,7 @@ export default class CreateDialogView extends React.Component { getParams: PropTypes.func.isRequired, cancel: PropTypes.func.isRequired, }).isRequired, + autofocus: PropTypes.object.isRequired, error: PropTypes.instanceOf(Error), isLoading: PropTypes.bool.isRequired, inProgress: PropTypes.bool.isRequired, @@ -58,12 +58,6 @@ export default class CreateDialogView extends React.Component { config: PropTypes.object.isRequired, } - constructor(props) { - super(props); - - this.autofocus = new AutoFocus(); - } - render() { const text = DIALOG_TEXT[this.props.request.identifier]; const HomeSelectionView = this.props.user ? RepositoryHomeSelectionView : BareRepositoryHomeSelectionView; @@ -75,7 +69,7 @@ export default class CreateDialogView extends React.Component { acceptText={text.acceptText} accept={this.props.accept} cancel={this.props.request.cancel} - autofocus={this.autofocus} + autofocus={this.props.autofocus} inProgress={this.props.inProgress} error={this.props.error} workspace={this.props.workspace} @@ -92,7 +86,7 @@ export default class CreateDialogView extends React.Component { selectedOwnerID={this.props.selectedOwnerID} didChangeOwnerID={this.props.didChangeOwnerID} isLoading={this.props.isLoading} - autofocus={this.autofocus} + autofocus={this.props.autofocus} />
@@ -139,9 +133,5 @@ export default class CreateDialogView extends React.Component { ); } - componentDidMount() { - this.autofocus.trigger(); - } - didChangeVisibility = event => this.props.didChangeVisibility(event.target.value); } diff --git a/lib/views/create-dialog.js b/lib/views/create-dialog.js index d1e9dc55eb..b68cb99723 100644 --- a/lib/views/create-dialog.js +++ b/lib/views/create-dialog.js @@ -4,6 +4,7 @@ import fs from 'fs-extra'; import CreateDialogContainer from '../containers/create-dialog-container'; import createRepositoryMutation from '../mutations/create-repository'; +import AutoFocus from '../autofocus'; import {GithubLoginModelPropType} from '../prop-types'; export default class CreateDialog extends React.Component { @@ -20,8 +21,18 @@ export default class CreateDialog extends React.Component { config: PropTypes.object.isRequired, } + constructor(props) { + super(props); + + this.autofocus = new AutoFocus(); + } + render() { - return ; + return ; + } + + componentDidMount() { + this.autofocus.trigger(); } } diff --git a/test/containers/create-dialog-container.test.js b/test/containers/create-dialog-container.test.js index a11c9b4bd0..c22cbbf9f1 100644 --- a/test/containers/create-dialog-container.test.js +++ b/test/containers/create-dialog-container.test.js @@ -8,6 +8,7 @@ import {dialogRequests} from '../../lib/controllers/dialogs-controller'; import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; import GithubLoginModel from '../../lib/models/github-login-model'; import {getEndpoint} from '../../lib/models/endpoint'; +import AutoFocus from '../../lib/autofocus'; import {queryBuilder} from '../builder/graphql/query'; import query from '../../lib/containers/__generated__/createDialogContainerQuery.graphql'; @@ -30,6 +31,7 @@ describe('CreateDialogContainer', function() { Date: Sun, 28 Jul 2019 21:19:06 -0400 Subject: [PATCH 28/78] Use the Endpoint's login account to get the token --- lib/containers/create-dialog-container.js | 2 +- test/containers/create-dialog-container.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js index c1efaf4c98..56cef4761a 100644 --- a/lib/containers/create-dialog-container.js +++ b/lib/containers/create-dialog-container.js @@ -109,5 +109,5 @@ export default class CreateDialogContainer extends React.Component { ); } - fetchToken = loginModel => loginModel.getToken(DOTCOM) + fetchToken = loginModel => loginModel.getToken(DOTCOM.getLoginAccount()) } diff --git a/test/containers/create-dialog-container.test.js b/test/containers/create-dialog-container.test.js index c22cbbf9f1..e3b1e5ee3d 100644 --- a/test/containers/create-dialog-container.test.js +++ b/test/containers/create-dialog-container.test.js @@ -55,7 +55,7 @@ describe('CreateDialogContainer', function() { it('fetches the login token from the login model', async function() { const loginModel = new GithubLoginModel(InMemoryStrategy); - loginModel.setToken(DOTCOM, 'good-token'); + loginModel.setToken(DOTCOM.getLoginAccount(), 'good-token'); const wrapper = shallow(buildApp({loginModel})); const observer = wrapper.find('ObserveModel'); From 77e4fb29b7c484c007f711eac7f00e667e6ff88e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sun, 28 Jul 2019 21:19:30 -0400 Subject: [PATCH 29/78] The GraphQL user field is "viewer" --- lib/containers/create-dialog-container.js | 2 +- test/containers/create-dialog-container.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js index 56cef4761a..770b35c3bd 100644 --- a/lib/containers/create-dialog-container.js +++ b/lib/containers/create-dialog-container.js @@ -79,7 +79,7 @@ export default class CreateDialogContainer extends React.Component { return ( Date: Sun, 28 Jul 2019 21:19:50 -0400 Subject: [PATCH 30/78] Relay fragmentContainer composition does not work that way --- lib/containers/create-dialog-container.js | 3 +-- lib/controllers/create-dialog-controller.js | 10 +++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js index 770b35c3bd..69bf32414a 100644 --- a/lib/containers/create-dialog-container.js +++ b/lib/containers/create-dialog-container.js @@ -45,8 +45,7 @@ export default class CreateDialogContainer extends React.Component { $organizationCursor: String ) { viewer { - ...createDialogController_user - ...repositoryHomeSelectionView_user @arguments( + ...createDialogController_user @arguments( organizationCount: $organizationCount organizationCursor: $organizationCursor ) diff --git a/lib/controllers/create-dialog-controller.js b/lib/controllers/create-dialog-controller.js index d40996bd60..c91aadab37 100644 --- a/lib/controllers/create-dialog-controller.js +++ b/lib/controllers/create-dialog-controller.js @@ -183,8 +183,16 @@ export class BareCreateDialogController extends React.Component { export default createFragmentContainer(BareCreateDialogController, { user: graphql` - fragment createDialogController_user on User { + fragment createDialogController_user on User + @argumentDefinitions( + organizationCount: {type: "Int!"} + organizationCursor: {type: "String"} + ) { id + ...repositoryHomeSelectionView_user @arguments( + organizationCount: $organizationCount + organizationCursor: $organizationCursor + ) } `, }); From 68e095aa72216efd17b662033c329f3c4d64ca46 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sun, 28 Jul 2019 21:19:57 -0400 Subject: [PATCH 31/78] :gear: relay :gear: --- .../createDialogContainerQuery.graphql.js | 20 ++++------- .../createDialogController_user.graphql.js | 35 +++++++++++++++++-- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/lib/containers/__generated__/createDialogContainerQuery.graphql.js b/lib/containers/__generated__/createDialogContainerQuery.graphql.js index d7a31cc4a9..e37eaefb04 100644 --- a/lib/containers/__generated__/createDialogContainerQuery.graphql.js +++ b/lib/containers/__generated__/createDialogContainerQuery.graphql.js @@ -1,6 +1,6 @@ /** * @flow - * @relayHash 0700928de288d25bcd390fb8b89ba08a + * @relayHash 72a9fbd2efed6312f034405f54084c6f */ /* eslint-disable */ @@ -10,14 +10,13 @@ /*:: import type { ConcreteRequest } from 'relay-runtime'; type createDialogController_user$ref = any; -type repositoryHomeSelectionView_user$ref = any; export type createDialogContainerQueryVariables = {| organizationCount: number, organizationCursor?: ?string, |}; export type createDialogContainerQueryResponse = {| +viewer: {| - +$fragmentRefs: createDialogController_user$ref & repositoryHomeSelectionView_user$ref + +$fragmentRefs: createDialogController_user$ref |} |}; export type createDialogContainerQuery = {| @@ -33,14 +32,14 @@ query createDialogContainerQuery( $organizationCursor: String ) { viewer { - ...createDialogController_user - ...repositoryHomeSelectionView_user_12CDS5 + ...createDialogController_user_12CDS5 id } } -fragment createDialogController_user on User { +fragment createDialogController_user_12CDS5 on User { id + ...repositoryHomeSelectionView_user_12CDS5 } fragment repositoryHomeSelectionView_user_12CDS5 on User { @@ -141,11 +140,6 @@ return { { "kind": "FragmentSpread", "name": "createDialogController_user", - "args": null - }, - { - "kind": "FragmentSpread", - "name": "repositoryHomeSelectionView_user", "args": [ { "kind": "Variable", @@ -279,11 +273,11 @@ return { "operationKind": "query", "name": "createDialogContainerQuery", "id": null, - "text": "query createDialogContainerQuery(\n $organizationCount: Int!\n $organizationCursor: String\n) {\n viewer {\n ...createDialogController_user\n ...repositoryHomeSelectionView_user_12CDS5\n id\n }\n}\n\nfragment createDialogController_user on User {\n id\n}\n\nfragment repositoryHomeSelectionView_user_12CDS5 on User {\n id\n login\n avatarUrl(size: 24)\n organizations(first: $organizationCount, after: $organizationCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n login\n avatarUrl(size: 24)\n viewerCanCreateRepositories\n __typename\n }\n }\n }\n}\n", + "text": "query createDialogContainerQuery(\n $organizationCount: Int!\n $organizationCursor: String\n) {\n viewer {\n ...createDialogController_user_12CDS5\n id\n }\n}\n\nfragment createDialogController_user_12CDS5 on User {\n id\n ...repositoryHomeSelectionView_user_12CDS5\n}\n\nfragment repositoryHomeSelectionView_user_12CDS5 on User {\n id\n login\n avatarUrl(size: 24)\n organizations(first: $organizationCount, after: $organizationCursor) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n cursor\n node {\n id\n login\n avatarUrl(size: 24)\n viewerCanCreateRepositories\n __typename\n }\n }\n }\n}\n", "metadata": {} } }; })(); // prettier-ignore -(node/*: any*/).hash = '95fae77aa6d826a222d2e2dde81c5cc3'; +(node/*: any*/).hash = '862b8ec3127c9a52e9a54020afa47792'; module.exports = node; diff --git a/lib/controllers/__generated__/createDialogController_user.graphql.js b/lib/controllers/__generated__/createDialogController_user.graphql.js index 5379164de1..a519d455ca 100644 --- a/lib/controllers/__generated__/createDialogController_user.graphql.js +++ b/lib/controllers/__generated__/createDialogController_user.graphql.js @@ -8,11 +8,13 @@ /*:: import type { ReaderFragment } from 'relay-runtime'; +type repositoryHomeSelectionView_user$ref = any; import type { FragmentReference } from "relay-runtime"; declare export opaque type createDialogController_user$ref: FragmentReference; declare export opaque type createDialogController_user$fragmentType: createDialogController_user$ref; export type createDialogController_user = {| +id: string, + +$fragmentRefs: repositoryHomeSelectionView_user$ref, +$refType: createDialogController_user$ref, |}; export type createDialogController_user$data = createDialogController_user; @@ -28,7 +30,20 @@ const node/*: ReaderFragment*/ = { "name": "createDialogController_user", "type": "User", "metadata": null, - "argumentDefinitions": [], + "argumentDefinitions": [ + { + "kind": "LocalArgument", + "name": "organizationCount", + "type": "Int!", + "defaultValue": null + }, + { + "kind": "LocalArgument", + "name": "organizationCursor", + "type": "String", + "defaultValue": null + } + ], "selections": [ { "kind": "ScalarField", @@ -36,9 +51,25 @@ const node/*: ReaderFragment*/ = { "name": "id", "args": null, "storageKey": null + }, + { + "kind": "FragmentSpread", + "name": "repositoryHomeSelectionView_user", + "args": [ + { + "kind": "Variable", + "name": "organizationCount", + "variableName": "organizationCount" + }, + { + "kind": "Variable", + "name": "organizationCursor", + "variableName": "organizationCursor" + } + ] } ] }; // prettier-ignore -(node/*: any*/).hash = '525e9172a481ebccd304f9d2f535a493'; +(node/*: any*/).hash = '729f5d41fc5444c5f12632127f89ed21'; module.exports = node; From c21a044e3cf3a58b332c49fc8edbd496f6c4f9fa Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 29 Jul 2019 10:55:56 -0400 Subject: [PATCH 32/78] Use github-Create- instead of github-Publish- --- lib/views/create-dialog-view.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index df8c8ad867..5955d6bb9c 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -91,7 +91,7 @@ export default class CreateDialogView extends React.Component {
Visibility: -
- {owner.disabled && ( + {owner.disabled && !owner.placeholder && (
(insufficient permissions)
@@ -80,13 +86,22 @@ export class BareRepositoryHomeSelectionView extends React.Component { ); + componentDidMount() { + this.schedulePageLoad(); + } + + componentDidUpdate() { + this.schedulePageLoad(); + } + getOwners() { if (!this.props.user) { return [{ - id: '', - login: '', + id: 'loading', + login: 'loading...', avatarURL: '', disabled: true, + placeholder: true, }]; } @@ -114,10 +129,37 @@ export class BareRepositoryHomeSelectionView extends React.Component { }); } + if (this.props.relay && this.props.relay.hasMore()) { + owners.push({ + id: 'loading', + login: 'loading...', + avatarURL: '', + disabled: true, + placeholder: true, + }); + } + return owners; } didChangeOwner = owner => this.props.didChangeOwnerID(owner.id); + + schedulePageLoad() { + if (!this.props.relay.hasMore()) { + return; + } + + setTimeout(this.loadNextPage, PAGE_DELAY); + } + + loadNextPage = () => { + if (this.props.relay.isLoading()) { + setTimeout(this.loadNextPage, PAGE_DELAY); + return; + } + + this.props.relay.loadMore(PAGE_SIZE); + } } export default createPaginationContainer(BareRepositoryHomeSelectionView, { @@ -155,7 +197,7 @@ export default createPaginationContainer(BareRepositoryHomeSelectionView, { direction: 'forward', /* istanbul ignore next */ getConnectionFromProps(props) { - return props.user.organizations; + return props.user && props.user.organizations; }, /* istanbul ignore next */ getFragmentVariables(prevVars, totalCount) { diff --git a/test/views/repository-home-selection-view.test.js b/test/views/repository-home-selection-view.test.js index 80631c8d0f..1d880f043b 100644 --- a/test/views/repository-home-selection-view.test.js +++ b/test/views/repository-home-selection-view.test.js @@ -8,21 +8,29 @@ import userQuery from '../../lib/views/__generated__/repositoryHomeSelectionView import {userBuilder} from '../builder/graphql/user'; describe('RepositoryHomeSelectionView', function() { - let atomEnv; + let atomEnv, clock; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); + clock = sinon.useFakeTimers(); }); afterEach(function() { atomEnv.destroy(); + clock.restore(); }); function buildApp(override = {}) { + const relay = { + hasMore: () => false, + isLoading: () => false, + loadMore: () => {}, + }; const nameBuffer = new TextBuffer(); return ( { + conn.addEdge(edge => edge.node(o => o.id('org0'))); + conn.addEdge(edge => edge.node(o => o.id('org1'))); + conn.addEdge(edge => edge.node(o => o.id('org2'))); + }) + .build(); + const loadMore0 = sinon.spy(); + const wrapper = shallow(buildApp({user: page0, relay: { + hasMore: () => true, + isLoading: () => false, + loadMore: loadMore0, + }})); + + assert.isFalse(loadMore0.called); + clock.tick(500); + assert.isTrue(loadMore0.called); + + const page1 = userBuilder(userQuery) + .organizations(conn => { + conn.addEdge(edge => edge.node(o => o.id('org3'))); + conn.addEdge(edge => edge.node(o => o.id('org4'))); + conn.addEdge(edge => edge.node(o => o.id('org5'))); + }) + .build(); + const loadMore1 = sinon.spy(); + wrapper.setProps({user: page1, relay: { + hasMore: () => false, + isLoading: () => false, + loadMore: loadMore1, + }}); + + assert.isFalse(loadMore1.called); + clock.tick(500); + assert.isFalse(loadMore1.called); + }); it('passes the currently chosen owner to the select list', function() { const user = userBuilder(userQuery) From 9cfbfeaf01053e748e5bb66fb008d5a8a94efaed Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 30 Jul 2019 11:28:52 -0400 Subject: [PATCH 38/78] Relay containers understand explicitly null props --- lib/views/create-dialog-view.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index 5955d6bb9c..ee949a6f3e 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import DialogView from './dialog-view'; -import RepositoryHomeSelectionView, {BareRepositoryHomeSelectionView} from './repository-home-selection-view'; +import RepositoryHomeSelectionView from './repository-home-selection-view'; import DirectorySelect from './directory-select'; import RemoteConfigurationView from './remote-configuration-view'; import Octicon from '../atom/octicon'; @@ -60,7 +60,6 @@ export default class CreateDialogView extends React.Component { render() { const text = DIALOG_TEXT[this.props.request.identifier]; - const HomeSelectionView = this.props.user ? RepositoryHomeSelectionView : BareRepositoryHomeSelectionView; return (
- Date: Tue, 30 Jul 2019 11:29:07 -0400 Subject: [PATCH 39/78] Use the exported organization page size --- lib/containers/create-dialog-container.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js index 6ecb1cf4be..814c369b28 100644 --- a/lib/containers/create-dialog-container.js +++ b/lib/containers/create-dialog-container.js @@ -4,6 +4,7 @@ import {QueryRenderer, graphql} from 'react-relay'; import CreateDialogController from '../controllers/create-dialog-controller'; import ObserveModel from '../views/observe-model'; +import {PAGE_SIZE} from '../views/repository-home-selection-view'; import RelayNetworkLayerManager from '../relay-network-layer-manager'; import {getEndpoint} from '../models/endpoint'; import {GithubLoginModelPropType} from '../prop-types'; @@ -54,7 +55,7 @@ export default class CreateDialogContainer extends React.Component { } `; const variables = { - organizationCount: 100, + organizationCount: PAGE_SIZE, organizationCursor: null, // Force QueryRenderer to re-render when dialog request state changes From 0f13adc5a646da215b6bb8a0e89f8168ee4f5a22 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 30 Jul 2019 11:29:24 -0400 Subject: [PATCH 40/78] Retain and render with the last Relay props to avoid loading flicker --- lib/containers/create-dialog-container.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js index 814c369b28..177fb65212 100644 --- a/lib/containers/create-dialog-container.js +++ b/lib/containers/create-dialog-container.js @@ -27,6 +27,12 @@ export default class CreateDialogContainer extends React.Component { config: PropTypes.object.isRequired, } + constructor(props) { + super(props); + + this.lastProps = null; + } + render() { return ( @@ -78,13 +84,15 @@ export default class CreateDialogContainer extends React.Component { return this.renderError(error); } - if (!props) { + if (!props && !this.lastProps) { return this.renderLoading(); } + const currentProps = props || this.lastProps; + return ( From e2dea4bdb670ee74c5bbdd97d71b9772a54c7a94 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 30 Jul 2019 14:32:19 -0400 Subject: [PATCH 41/78] Style radio and details buttons when focused --- styles/dialog.less | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/styles/dialog.less b/styles/dialog.less index d2ece2dd55..302a972f1c 100644 --- a/styles/dialog.less +++ b/styles/dialog.less @@ -183,6 +183,11 @@ cursor: default; user-select: none; margin: @component-padding 0; + + &:focus { + border: solid 1px @button-background-color-selected; + border-radius: 4px; + } } main { @@ -191,14 +196,24 @@ } &-protocol { + display: inline-flex; + align-items: center; + &Heading { display: inline-block; margin-right: @component-padding; } &Option { - display: inline-block; + display: inline-flex; + align-items: center; + padding: @component-padding/4; margin-right: @component-padding; + + &:focus-within { + border: solid 1px @button-background-color-selected; + border-radius: 4px; + } } } @@ -207,7 +222,6 @@ label { display: flex; - flex-direction: row; align-items: center; .github-AtomTextEditor-container { @@ -220,6 +234,9 @@ .github-Create { &-visibility { + display: inline-flex; + align-items: center; + &Heading { display: inline-block; margin-right: @component-padding*2; @@ -227,8 +244,14 @@ &Option { display: inline-flex; - align-items: baseline; - margin-right: @component-padding*2; + align-items: center; + padding: @component-padding/4; + margin-right: @component-padding; + + &:focus-within { + border: solid 1px @button-background-color-selected; + border-radius: 4px; + } span::before { margin-left: @component-padding/2; From e726af0c328f6283e1d877d5be10f81c82a576e2 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 30 Jul 2019 14:32:33 -0400 Subject: [PATCH 42/78] Trigger autofocus when CreateDialogView mounts --- lib/views/create-dialog-view.js | 4 ++++ lib/views/create-dialog.js | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index ee949a6f3e..8e4a6d9963 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -132,5 +132,9 @@ export default class CreateDialogView extends React.Component { ); } + componentDidMount() { + this.props.autofocus.trigger(); + } + didChangeVisibility = event => this.props.didChangeVisibility(event.target.value); } diff --git a/lib/views/create-dialog.js b/lib/views/create-dialog.js index 0044601830..d9bf05b620 100644 --- a/lib/views/create-dialog.js +++ b/lib/views/create-dialog.js @@ -31,10 +31,6 @@ export default class CreateDialog extends React.Component { render() { return ; } - - componentDidMount() { - this.autofocus.trigger(); - } } export async function createRepository( From 14540f5e11bfa161c6a0e264859a0b50f23f5912 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 30 Jul 2019 14:32:52 -0400 Subject: [PATCH 43/78] Enable name editor while organizations are loading --- lib/views/repository-home-selection-view.js | 1 - test/views/repository-home-selection-view.test.js | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/views/repository-home-selection-view.js b/lib/views/repository-home-selection-view.js index bcaadac4a3..de098fe3bb 100644 --- a/lib/views/repository-home-selection-view.js +++ b/lib/views/repository-home-selection-view.js @@ -65,7 +65,6 @@ export class BareRepositoryHomeSelectionView extends React.Component {
diff --git a/test/views/repository-home-selection-view.test.js b/test/views/repository-home-selection-view.test.js index 1d880f043b..07ceeb711e 100644 --- a/test/views/repository-home-selection-view.test.js +++ b/test/views/repository-home-selection-view.test.js @@ -41,11 +41,10 @@ describe('RepositoryHomeSelectionView', function() { ); } - it('disables the select list and text input while loading', function() { + it('disables the select list while loading', function() { const wrapper = shallow(buildApp({isLoading: true})); assert.isTrue(wrapper.find('Select').prop('disabled')); - assert.isTrue(wrapper.find('AtomTextEditor').prop('readOnly')); }); it('passes a provided buffer to the name entry box', function() { From 9df9a18a22988ad999416d0d5b89cc0d41377db3 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 31 Jul 2019 13:19:19 -0400 Subject: [PATCH 44/78] TabGroup to assign form elements distinct, increasing tabIndex --- lib/tab-group.js | 37 +++++++++++++++++++++++++++++++++ test/tab-group.test.js | 47 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 lib/tab-group.js create mode 100644 test/tab-group.test.js diff --git a/lib/tab-group.js b/lib/tab-group.js new file mode 100644 index 0000000000..5552386b51 --- /dev/null +++ b/lib/tab-group.js @@ -0,0 +1,37 @@ +/** + * Assign successive, distinct tabIndex values to DOM elements. + */ +export default class TabGroup { + constructor() { + this.index = 1; + + for (const element of document.querySelectorAll('[tabindex]')) { + if (element.disabled) { + continue; + } + + if (element.tabIndex < 0) { + continue; + } + + if (element.tabIndex > this.index) { + this.index = element.tabIndex + 1; + } + } + + this.startIndex = this.index; + } + + nextIndex() { + const i = this.index; + this.index++; + return i; + } + + focusBeginning() { + const element = document.querySelector(`[tabIndex="${this.startIndex}"]`); + if (element) { + element.focus(); + } + } +} diff --git a/test/tab-group.test.js b/test/tab-group.test.js new file mode 100644 index 0000000000..f8c65dae1f --- /dev/null +++ b/test/tab-group.test.js @@ -0,0 +1,47 @@ +import TabGroup from '../lib/tab-group'; + +describe('TabGroup', function() { + let div; + + beforeEach(function() { + div = document.createElement('div'); + div.tabIndex = 1000000; + document.body.appendChild(div); + }); + + afterEach(function() { + div.remove(); + }); + + it('begins above the highest tabIndex existing in the DOM', function() { + const group = new TabGroup(); + assert.strictEqual(group.nextIndex(), 1000001); + }); + + it('assigns ascending indices to each successive tab target', function() { + const group = new TabGroup(); + assert.strictEqual(group.nextIndex(), 1000001); + assert.strictEqual(group.nextIndex(), 1000002); + assert.strictEqual(group.nextIndex(), 1000003); + }); + + it('brings focus to the lowest tabIndex assigned by this group', function() { + const group = new TabGroup(); + + const child0 = document.createElement('div'); + child0.tabIndex = group.nextIndex(); + sinon.stub(child0, 'focus'); + div.appendChild(child0); + + const child1 = document.createElement('div'); + child1.tabIndex = group.nextIndex(); + div.appendChild(child1); + + const child2 = document.createElement('div'); + child2.tabIndex = group.nextIndex(); + div.appendChild(child2); + + group.focusBeginning(); + assert.isTrue(child0.focus.called); + }); +}); From f681590d31c1bd2c4e9f9996eff720bd7b36ac63 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 31 Jul 2019 13:20:08 -0400 Subject: [PATCH 45/78] Use TabGroup through CreateDialog components --- lib/containers/create-dialog-container.js | 1 + lib/views/create-dialog-view.js | 12 +++++- lib/views/create-dialog.js | 10 ++++- lib/views/dialog-view.js | 13 +++--- lib/views/directory-select.js | 5 +++ lib/views/remote-configuration-view.js | 7 +++ lib/views/repository-home-selection-view.js | 7 +++ .../create-dialog-container.test.js | 2 + .../create-dialog-controller.test.js | 2 + test/views/dialog-view.test.js | 43 ++++++++++--------- test/views/directory-select.test.js | 2 + test/views/remote-configuration-view.test.js | 2 + .../repository-home-selection-view.test.js | 3 +- 13 files changed, 79 insertions(+), 30 deletions(-) diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js index 177fb65212..746c138964 100644 --- a/lib/containers/create-dialog-container.js +++ b/lib/containers/create-dialog-container.js @@ -18,6 +18,7 @@ export default class CreateDialogContainer extends React.Component { request: PropTypes.object.isRequired, error: PropTypes.instanceOf(Error), autofocus: PropTypes.object.isRequired, + tabGroup: PropTypes.object.isRequired, inProgress: PropTypes.bool.isRequired, // Atom environment diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index 8e4a6d9963..1e0acd0b64 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -33,7 +33,6 @@ export default class CreateDialogView extends React.Component { getParams: PropTypes.func.isRequired, cancel: PropTypes.func.isRequired, }).isRequired, - autofocus: PropTypes.object.isRequired, error: PropTypes.instanceOf(Error), isLoading: PropTypes.bool.isRequired, inProgress: PropTypes.bool.isRequired, @@ -44,6 +43,11 @@ export default class CreateDialogView extends React.Component { sourceRemoteName: PropTypes.object.isRequired, selectedProtocol: PropTypes.oneOf(['https', 'ssh']).isRequired, acceptEnabled: PropTypes.bool.isRequired, + autofocus: PropTypes.object.isRequired, + tabGroup: PropTypes.shape({ + nextIndex: PropTypes.func.isRequired, + focusBeginning: PropTypes.func.isRequired, + }), // Change callbacks didChangeOwnerID: PropTypes.func.isRequired, @@ -69,6 +73,7 @@ export default class CreateDialogView extends React.Component { accept={this.props.accept} cancel={this.props.request.cancel} autofocus={this.props.autofocus} + tabGroup={this.props.tabGroup} inProgress={this.props.inProgress} error={this.props.error} workspace={this.props.workspace} @@ -86,6 +91,7 @@ export default class CreateDialogView extends React.Component { didChangeOwnerID={this.props.didChangeOwnerID} isLoading={this.props.isLoading} autofocus={this.props.autofocus} + tabGroup={this.props.tabGroup} />
@@ -98,6 +104,7 @@ export default class CreateDialogView extends React.Component { value="PUBLIC" checked={this.props.selectedVisibility === 'PUBLIC'} onChange={this.didChangeVisibility} + tabIndex={this.props.tabGroup.nextIndex()} /> Public @@ -110,6 +117,7 @@ export default class CreateDialogView extends React.Component { value="PRIVATE" checked={this.props.selectedVisibility === 'PRIVATE'} onChange={this.didChangeVisibility} + tabIndex={this.props.tabGroup.nextIndex()} /> Private @@ -120,12 +128,14 @@ export default class CreateDialogView extends React.Component { currentWindow={this.props.currentWindow} buffer={this.props.localPath} disabled={this.props.request.identifier === 'publish'} + tabGroup={this.props.tabGroup} />
diff --git a/lib/views/create-dialog.js b/lib/views/create-dialog.js index d9bf05b620..1918b13353 100644 --- a/lib/views/create-dialog.js +++ b/lib/views/create-dialog.js @@ -5,6 +5,7 @@ import fs from 'fs-extra'; import CreateDialogContainer from '../containers/create-dialog-container'; import createRepositoryMutation from '../mutations/create-repository'; import AutoFocus from '../autofocus'; +import TabGroup from '../tab-group'; import {GithubLoginModelPropType} from '../prop-types'; export default class CreateDialog extends React.Component { @@ -26,10 +27,17 @@ export default class CreateDialog extends React.Component { super(props); this.autofocus = new AutoFocus(); + this.tabGroup = new TabGroup(); } render() { - return ; + return ( + + ); } } diff --git a/lib/views/dialog-view.js b/lib/views/dialog-view.js index 5c6fe497e9..5a3e077fbe 100644 --- a/lib/views/dialog-view.js +++ b/lib/views/dialog-view.js @@ -11,18 +11,17 @@ export default class DialogView extends React.Component { prompt: PropTypes.string, progressMessage: PropTypes.string, acceptEnabled: PropTypes.bool, - acceptTabIndex: PropTypes.number, acceptClassName: PropTypes.string, acceptText: PropTypes.string, - cancelTabIndex: PropTypes.number, // Callbacks accept: PropTypes.func.isRequired, cancel: PropTypes.func.isRequired, // State - autofocus: PropTypes.shape({ - trigger: PropTypes.func.isRequired, + tabGroup: PropTypes.shape({ + nextIndex: PropTypes.func.isRequired, + focusBeginning: PropTypes.func.isRequired, }), inProgress: PropTypes.bool.isRequired, error: PropTypes.instanceOf(Error), @@ -45,7 +44,7 @@ export default class DialogView extends React.Component { render() { return ( -
this.props.autofocus.trigger()}> +
this.props.tabGroup.focusBeginning()}> @@ -72,13 +71,13 @@ export default class DialogView extends React.Component {
); diff --git a/lib/views/remote-configuration-view.js b/lib/views/remote-configuration-view.js index c09b03b77d..d41b88fa72 100644 --- a/lib/views/remote-configuration-view.js +++ b/lib/views/remote-configuration-view.js @@ -9,6 +9,10 @@ export default class RemoteConfigurationView extends React.Component { currentProtocol: PropTypes.oneOf(['https', 'ssh']), sourceRemoteBuffer: PropTypes.object.isRequired, didChangeProtocol: PropTypes.func.isRequired, + + tabGroup: PropTypes.shape({ + nextIndex: PropTypes.func.isRequired, + }), } render() { @@ -38,6 +42,7 @@ export default class RemoteConfigurationView extends React.Component { value="https" checked={this.props.currentProtocol === 'https'} onChange={this.handleProtocolChange} + tabIndex={this.props.tabGroup.nextIndex()} /> HTTPS @@ -49,6 +54,7 @@ export default class RemoteConfigurationView extends React.Component { value="ssh" checked={this.props.currentProtocol === 'ssh'} onChange={this.handleProtocolChange} + tabIndex={this.props.tabGroup.nextIndex()} /> SSH @@ -60,6 +66,7 @@ export default class RemoteConfigurationView extends React.Component { mini={true} autoWidth={false} buffer={this.props.sourceRemoteBuffer} + tabIndex={this.props.tabGroup.nextIndex()} />
diff --git a/lib/views/repository-home-selection-view.js b/lib/views/repository-home-selection-view.js index de098fe3bb..e43472e538 100644 --- a/lib/views/repository-home-selection-view.js +++ b/lib/views/repository-home-selection-view.js @@ -40,6 +40,11 @@ export class BareRepositoryHomeSelectionView extends React.Component { autofocus: PropTypes.shape({ target: PropTypes.func.isRequired, }).isRequired, + tabGroup: PropTypes.shape({ + nextIndex: PropTypes.func.isRequired, + }), + + // Tab index manipulation // Selection callback didChangeOwnerID: PropTypes.func.isRequired, @@ -60,12 +65,14 @@ export class BareRepositoryHomeSelectionView extends React.Component { value={currentOwner} valueRenderer={this.renderOwner} onChange={this.didChangeOwner} + tabIndex={this.props.tabGroup.nextIndex()} /> / ); diff --git a/test/containers/create-dialog-container.test.js b/test/containers/create-dialog-container.test.js index bf6050a69d..4c2cb0aa46 100644 --- a/test/containers/create-dialog-container.test.js +++ b/test/containers/create-dialog-container.test.js @@ -9,6 +9,7 @@ import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; import GithubLoginModel from '../../lib/models/github-login-model'; import {getEndpoint} from '../../lib/models/endpoint'; import AutoFocus from '../../lib/autofocus'; +import TabGroup from '../../lib/tab-group'; import {queryBuilder} from '../builder/graphql/query'; import query from '../../lib/containers/__generated__/createDialogContainerQuery.graphql'; @@ -32,6 +33,7 @@ describe('CreateDialogContainer', function() { loginModel={new GithubLoginModel(InMemoryStrategy)} request={dialogRequests.create()} autofocus={new AutoFocus()} + tabGroup={new TabGroup()} inProgress={false} currentWindow={atomEnv.getCurrentWindow()} workspace={atomEnv.workspace} diff --git a/test/controllers/create-dialog-controller.test.js b/test/controllers/create-dialog-controller.test.js index 601830c684..ed13f087aa 100644 --- a/test/controllers/create-dialog-controller.test.js +++ b/test/controllers/create-dialog-controller.test.js @@ -6,6 +6,7 @@ import {BareCreateDialogController} from '../../lib/controllers/create-dialog-co import CreateDialogView from '../../lib/views/create-dialog-view'; import {dialogRequests} from '../../lib/controllers/dialogs-controller'; import AutoFocus from '../../lib/autofocus'; +import TabGroup from '../../lib/tab-group'; import {userBuilder} from '../builder/graphql/user'; import userQuery from '../../lib/controllers/__generated__/createDialogController_user.graphql'; @@ -30,6 +31,7 @@ describe('CreateDialogController', function() { user={userBuilder(userQuery).build()} request={dialogRequests.create()} autofocus={new AutoFocus()} + tabGroup={new TabGroup()} isLoading={false} inProgress={false} currentWindow={atomEnv.getCurrentWindow()} diff --git a/test/views/dialog-view.test.js b/test/views/dialog-view.test.js index e6ea829350..5602f7c94c 100644 --- a/test/views/dialog-view.test.js +++ b/test/views/dialog-view.test.js @@ -2,7 +2,7 @@ import React from 'react'; import {shallow} from 'enzyme'; import DialogView from '../../lib/views/dialog-view'; -import AutoFocus from '../../lib/autofocus'; +import TabGroup from '../../lib/tab-group'; describe('DialogView', function() { let atomEnv; @@ -20,7 +20,7 @@ describe('DialogView', function() { {}} @@ -93,30 +93,33 @@ describe('DialogView', function() { }); describe('tabbing', function() { - it('defaults the tabIndex of the buttons to 0', function() { - const wrapper = shallow(buildApp()); + let div; - assert.strictEqual(wrapper.find('.github-Dialog-cancelButton').prop('tabIndex'), 0); - assert.strictEqual(wrapper.find('.btn-primary').prop('tabIndex'), 0); + beforeEach(function() { + div = document.createElement('div'); + div.tabIndex = 1000000; + document.body.appendChild(div); }); - it('customizes the tabIndex of the standard buttons', function() { - const wrapper = shallow(buildApp({ - cancelTabIndex: 10, - acceptTabIndex: 20, - })); + afterEach(function() { + div.remove(); + }); + + it('assigns successive distinct tab indices to button elements', function() { + const tabGroup = new TabGroup(); + const wrapper = shallow(buildApp({tabGroup})); - assert.strictEqual(wrapper.find('.github-Dialog-cancelButton').prop('tabIndex'), 10); - assert.strictEqual(wrapper.find('.btn-primary').prop('tabIndex'), 20); + assert.strictEqual(wrapper.find('.github-Dialog-cancelButton').prop('tabIndex'), 1000001); + assert.strictEqual(wrapper.find('.btn-primary').prop('tabIndex'), 1000002); }); it('recaptures focus after it leaves the dialog element', function() { - const autofocus = new AutoFocus(); - const wrapper = shallow(buildApp({autofocus})); + const tabGroup = new TabGroup(); + const wrapper = shallow(buildApp({tabGroup})); - sinon.spy(autofocus, 'trigger'); - wrapper.find('.github-Dialog').simulate('transitionEnd'); - assert.isTrue(autofocus.trigger.called); + sinon.spy(tabGroup, 'focusBeginning'); + wrapper.find('.github-Dialog').prop('onTransitionEnd')(); + assert.isTrue(tabGroup.focusBeginning.called); }); }); @@ -147,7 +150,7 @@ describe('DialogView', function() { const accept = sinon.spy(); const wrapper = shallow(buildApp({accept})); - wrapper.find('.btn-primary').simulate('click'); + wrapper.find('.btn-primary').prop('onClick')(); assert.isTrue(accept.called); }); @@ -163,7 +166,7 @@ describe('DialogView', function() { const cancel = sinon.spy(); const wrapper = shallow(buildApp({cancel})); - wrapper.find('.github-Dialog-cancelButton').simulate('click'); + wrapper.find('.github-Dialog-cancelButton').prop('onClick')(); assert.isTrue(cancel.called); }); }); diff --git a/test/views/directory-select.test.js b/test/views/directory-select.test.js index bd8439ba2a..d0b0833eb4 100644 --- a/test/views/directory-select.test.js +++ b/test/views/directory-select.test.js @@ -3,6 +3,7 @@ import {shallow} from 'enzyme'; import {TextBuffer} from 'atom'; import DirectorySelect from '../../lib/views/directory-select'; +import TabGroup from '../../lib/tab-group'; describe('DirectorySelect', function() { let atomEnv; @@ -23,6 +24,7 @@ describe('DirectorySelect', function() { currentWindow={atomEnv.getCurrentWindow()} buffer={buffer} showOpenDialog={() => {}} + tabGroup={new TabGroup()} {...override} /> ); diff --git a/test/views/remote-configuration-view.test.js b/test/views/remote-configuration-view.test.js index 8503f8efa0..fba86afcdb 100644 --- a/test/views/remote-configuration-view.test.js +++ b/test/views/remote-configuration-view.test.js @@ -3,6 +3,7 @@ import {shallow} from 'enzyme'; import {TextBuffer} from 'atom'; import RemoteConfigurationView from '../../lib/views/remote-configuration-view'; +import TabGroup from '../../lib/tab-group'; describe('RemoteConfigurationView', function() { function buildApp(override = {}) { @@ -12,6 +13,7 @@ describe('RemoteConfigurationView', function() { currentProtocol={'https'} didChangeProtocol={() => {}} sourceRemoteBuffer={sourceRemoteBuffer} + tabGroup={new TabGroup()} {...override} /> ); diff --git a/test/views/repository-home-selection-view.test.js b/test/views/repository-home-selection-view.test.js index 07ceeb711e..2ab647a425 100644 --- a/test/views/repository-home-selection-view.test.js +++ b/test/views/repository-home-selection-view.test.js @@ -4,6 +4,7 @@ import {TextBuffer} from 'atom'; import {BareRepositoryHomeSelectionView} from '../../lib/views/repository-home-selection-view'; import AutoFocus from '../../lib/autofocus'; +import TabGroup from '../../lib/tab-group'; import userQuery from '../../lib/views/__generated__/repositoryHomeSelectionView_user.graphql'; import {userBuilder} from '../builder/graphql/user'; @@ -36,6 +37,7 @@ describe('RepositoryHomeSelectionView', function() { selectedOwnerID={''} didChangeOwnerID={() => {}} autofocus={new AutoFocus()} + tabGroup={new TabGroup()} {...override} /> ); @@ -51,7 +53,6 @@ describe('RepositoryHomeSelectionView', function() { const nameBuffer = new TextBuffer(); const wrapper = shallow(buildApp({nameBuffer})); - assert.isFalse(wrapper.find('AtomTextEditor').prop('readOnly')); assert.strictEqual(wrapper.find('AtomTextEditor').prop('buffer'), nameBuffer); }); From 8e713cc9f4a7d5b9eef57d057a6719c5b08efffd Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 31 Jul 2019 13:20:17 -0400 Subject: [PATCH 46/78] State typo --- lib/controllers/create-dialog-controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/controllers/create-dialog-controller.js b/lib/controllers/create-dialog-controller.js index 7baab11396..1ad2f75c1b 100644 --- a/lib/controllers/create-dialog-controller.js +++ b/lib/controllers/create-dialog-controller.js @@ -170,7 +170,7 @@ export class BareCreateDialogController extends React.Component { return Promise.resolve(); } - const ownerID = this.state.selectedOwnerID !== '' ? this.state.selectedOwner : this.props.user.id; + const ownerID = this.state.selectedOwnerID !== '' ? this.state.selectedOwnerID : this.props.user.id; return this.props.request.accept({ ownerID, From 699d87c808b5879940bef10e1c1ef06cff03fa5e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 31 Jul 2019 13:46:30 -0400 Subject: [PATCH 47/78] Use TabGroup in other dialogs --- lib/views/clone-dialog.js | 6 +++++- lib/views/credential-dialog.js | 16 +++++++--------- lib/views/dialog-view.js | 2 -- lib/views/init-dialog.js | 5 ++++- lib/views/open-commit-dialog.js | 11 +++++++++-- lib/views/open-issueish-dialog.js | 5 ++++- test/views/credential-dialog.test.js | 4 +++- test/views/open-commit-dialog.test.js | 2 ++ test/views/open-issueish-dialog.test.js | 1 + 9 files changed, 35 insertions(+), 17 deletions(-) diff --git a/lib/views/clone-dialog.js b/lib/views/clone-dialog.js index 32bb97d8d1..2e4fd3f478 100644 --- a/lib/views/clone-dialog.js +++ b/lib/views/clone-dialog.js @@ -7,6 +7,7 @@ import path from 'path'; import AtomTextEditor from '../atom/atom-text-editor'; import AutoFocus from '../autofocus'; +import TabGroup from '../tab-group'; import DialogView from './dialog-view'; export default class CloneDialog extends React.Component { @@ -46,6 +47,7 @@ export default class CloneDialog extends React.Component { ); this.autofocus = new AutoFocus(); + this.tabGroup = new TabGroup(); } render() { @@ -57,7 +59,7 @@ export default class CloneDialog extends React.Component { acceptText="Clone" accept={this.accept} cancel={this.props.request.cancel} - autofocus={this.autofocus} + tabGroup={this.tabGroup} inProgress={this.props.inProgress} error={this.props.error} workspace={this.props.workspace} @@ -71,6 +73,7 @@ export default class CloneDialog extends React.Component { mini={true} readOnly={this.props.inProgress} buffer={this.sourceURL} + tabIndex={this.tabGroup.nextIndex()} /> diff --git a/lib/views/credential-dialog.js b/lib/views/credential-dialog.js index ea45d9d23c..53f8c9dcc0 100644 --- a/lib/views/credential-dialog.js +++ b/lib/views/credential-dialog.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import DialogView from './dialog-view'; import AutoFocus from '../autofocus'; +import TabGroup from '../tab-group'; export default class CredentialDialog extends React.Component { static propTypes = { @@ -24,6 +25,7 @@ export default class CredentialDialog extends React.Component { super(props); this.autofocus = new AutoFocus(); + this.tabGroup = new TabGroup(); this.state = { username: '', @@ -41,12 +43,10 @@ export default class CredentialDialog extends React.Component { @@ -85,7 +85,7 @@ export default class CredentialDialog extends React.Component { {params.includeRemember && ( diff --git a/lib/views/open-commit-dialog.js b/lib/views/open-commit-dialog.js index e95106bd03..e516b69210 100644 --- a/lib/views/open-commit-dialog.js +++ b/lib/views/open-commit-dialog.js @@ -7,6 +7,7 @@ import CommitDetailItem from '../items/commit-detail-item'; import {GitError} from '../git-shell-out-strategy'; import DialogView from './dialog-view'; import AutoFocus from '../autofocus'; +import TabGroup from '../tab-group'; import {addEvent} from '../reporter-proxy'; export default class OpenCommitDialog extends React.Component { @@ -36,6 +37,7 @@ export default class OpenCommitDialog extends React.Component { }; this.autofocus = new AutoFocus(); + this.tabGroup = new TabGroup(); } render() { @@ -46,7 +48,7 @@ export default class OpenCommitDialog extends React.Component { acceptText="Open commit" accept={this.accept} cancel={this.props.request.cancel} - autofocus={this.autofocus} + tabGroup={this.tabGroup} inProgress={this.props.inProgress} error={this.props.error} workspace={this.props.workspace} @@ -54,7 +56,12 @@ export default class OpenCommitDialog extends React.Component { diff --git a/lib/views/open-issueish-dialog.js b/lib/views/open-issueish-dialog.js index 39d6ac95c8..b5e70edeed 100644 --- a/lib/views/open-issueish-dialog.js +++ b/lib/views/open-issueish-dialog.js @@ -5,6 +5,7 @@ import {TextBuffer} from 'atom'; import AtomTextEditor from '../atom/atom-text-editor'; import IssueishDetailItem from '../items/issueish-detail-item'; import AutoFocus from '../autofocus'; +import TabGroup from '../tab-group'; import DialogView from './dialog-view'; import {addEvent} from '../reporter-proxy'; @@ -38,6 +39,7 @@ export default class OpenIssueishDialog extends React.Component { this.sub = this.url.onDidChange(this.didChangeURL); this.autofocus = new AutoFocus(); + this.tabGroup = new TabGroup(); } render() { @@ -48,7 +50,7 @@ export default class OpenIssueishDialog extends React.Component { acceptText="Open Issue or Pull Request" accept={this.accept} cancel={this.props.request.cancel} - autofocus={this.autofocus} + tabGroup={this.tabGroup} inProgress={this.props.inProgress} error={this.props.error} workspace={this.props.workspace} @@ -58,6 +60,7 @@ export default class OpenIssueishDialog extends React.Component { Issue or pull request URL: ); diff --git a/test/views/open-commit-dialog.test.js b/test/views/open-commit-dialog.test.js index 7a0b0d3cd2..fcadc8d7f2 100644 --- a/test/views/open-commit-dialog.test.js +++ b/test/views/open-commit-dialog.test.js @@ -28,7 +28,9 @@ describe('OpenCommitDialog', function() { return ( diff --git a/test/views/open-issueish-dialog.test.js b/test/views/open-issueish-dialog.test.js index c338475f9f..e8b71a990a 100644 --- a/test/views/open-issueish-dialog.test.js +++ b/test/views/open-issueish-dialog.test.js @@ -24,6 +24,7 @@ describe('OpenIssueishDialog', function() { return ( Date: Wed, 31 Jul 2019 15:43:23 -0400 Subject: [PATCH 48/78] Child TabGroups and resetting to handle being passed among components --- lib/tab-group.js | 79 +++++++++++++++++++++++++++++++++--------- test/tab-group.test.js | 30 ++++++++++++++++ 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/lib/tab-group.js b/lib/tab-group.js index 5552386b51..adc412b952 100644 --- a/lib/tab-group.js +++ b/lib/tab-group.js @@ -1,34 +1,81 @@ +class TabIndexSequence { + constructor(startIndex, reserved) { + this.startIndex = startIndex; + this.currentIndex = startIndex; + this.reserved = reserved; + } + + nextIndex() { + const i = this.currentIndex; + this.currentIndex++; + this.checkRange(); + return i; + } + + reset() { + this.currentIndex = this.startIndex; + } + + advance(count) { + this.currentIndex += count; + this.checkRange(); + } + + checkRange() { + if (this.currentIndex - this.startIndex > this.reserved) { + throw new Error('Tab index out of range'); + } + } +} + +const SEQUENCE = Symbol('sequence'); + /** * Assign successive, distinct tabIndex values to DOM elements. */ export default class TabGroup { - constructor() { - this.index = 1; + constructor(options = {}) { + if (options[SEQUENCE]) { + this.startIndex = null; + this.sequence = options[SEQUENCE]; + } else { + this.startIndex = 1; + for (const element of document.querySelectorAll('[tabindex]')) { + if (element.disabled) { + continue; + } - for (const element of document.querySelectorAll('[tabindex]')) { - if (element.disabled) { - continue; - } - - if (element.tabIndex < 0) { - continue; - } + if (element.tabIndex < 0) { + continue; + } - if (element.tabIndex > this.index) { - this.index = element.tabIndex + 1; + if (element.tabIndex > this.startIndex) { + this.startIndex = element.tabIndex + 1; + } } + this.sequence = new TabIndexSequence(this.startIndex, Infinity); } + } - this.startIndex = this.index; + reset() { + this.sequence.reset(); } nextIndex() { - const i = this.index; - this.index++; - return i; + return this.sequence.nextIndex(); + } + + reserve(count) { + const childSequence = new TabIndexSequence(this.sequence.nextIndex(), count); + this.sequence.advance(count - 1); + return new TabGroup({[SEQUENCE]: childSequence}); } focusBeginning() { + if (this.startIndex === null) { + return; + } + const element = document.querySelector(`[tabIndex="${this.startIndex}"]`); if (element) { element.focus(); diff --git a/test/tab-group.test.js b/test/tab-group.test.js index f8c65dae1f..f3592fcf05 100644 --- a/test/tab-group.test.js +++ b/test/tab-group.test.js @@ -44,4 +44,34 @@ describe('TabGroup', function() { group.focusBeginning(); assert.isTrue(child0.focus.called); }); + + it('creates a child group that reserves a range of indices', function() { + const parent = new TabGroup(); + assert.strictEqual(parent.nextIndex(), 1000001); + + const child0 = parent.reserve(2); + assert.strictEqual(parent.nextIndex(), 1000004); + assert.strictEqual(parent.nextIndex(), 1000005); + + const child1 = parent.reserve(3); + assert.strictEqual(parent.nextIndex(), 1000009); + + assert.strictEqual(child0.nextIndex(), 1000002); + assert.strictEqual(child0.nextIndex(), 1000003); + + assert.strictEqual(child1.nextIndex(), 1000006); + assert.strictEqual(child1.nextIndex(), 1000007); + assert.strictEqual(child1.nextIndex(), 1000008); + assert.throws(() => child1.nextIndex(), /Tab index out of range/); + }); + + it('resets to its start index', function() { + const parent = new TabGroup(); + assert.strictEqual(parent.nextIndex(), 1000001); + assert.strictEqual(parent.nextIndex(), 1000002); + + parent.reset(); + assert.strictEqual(parent.nextIndex(), 1000001); + assert.strictEqual(parent.nextIndex(), 1000002); + }); }); From 557d7b38a778dc0b5d78fe49cb3b8bdfc62a9263 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 31 Jul 2019 15:44:14 -0400 Subject: [PATCH 49/78] Put tabGroup and autoFocus in CreateDialogView --- lib/containers/create-dialog-container.js | 2 -- lib/controllers/create-dialog-controller.js | 1 - lib/views/create-dialog-view.js | 33 +++++++++++-------- lib/views/create-dialog.js | 17 +--------- .../create-dialog-container.test.js | 4 --- .../create-dialog-controller.test.js | 4 --- 6 files changed, 20 insertions(+), 41 deletions(-) diff --git a/lib/containers/create-dialog-container.js b/lib/containers/create-dialog-container.js index 746c138964..267a811676 100644 --- a/lib/containers/create-dialog-container.js +++ b/lib/containers/create-dialog-container.js @@ -17,8 +17,6 @@ export default class CreateDialogContainer extends React.Component { loginModel: GithubLoginModelPropType.isRequired, request: PropTypes.object.isRequired, error: PropTypes.instanceOf(Error), - autofocus: PropTypes.object.isRequired, - tabGroup: PropTypes.object.isRequired, inProgress: PropTypes.bool.isRequired, // Atom environment diff --git a/lib/controllers/create-dialog-controller.js b/lib/controllers/create-dialog-controller.js index 1ad2f75c1b..de1ab5c641 100644 --- a/lib/controllers/create-dialog-controller.js +++ b/lib/controllers/create-dialog-controller.js @@ -19,7 +19,6 @@ export class BareCreateDialogController extends React.Component { getParams: PropTypes.func.isRequired, accept: PropTypes.func.isRequired, }).isRequired, - autofocus: PropTypes.object.isRequired, error: PropTypes.instanceOf(Error), isLoading: PropTypes.bool.isRequired, inProgress: PropTypes.bool.isRequired, diff --git a/lib/views/create-dialog-view.js b/lib/views/create-dialog-view.js index 1e0acd0b64..1cc3f5144b 100644 --- a/lib/views/create-dialog-view.js +++ b/lib/views/create-dialog-view.js @@ -5,6 +5,8 @@ import DialogView from './dialog-view'; import RepositoryHomeSelectionView from './repository-home-selection-view'; import DirectorySelect from './directory-select'; import RemoteConfigurationView from './remote-configuration-view'; +import AutoFocus from '../autofocus'; +import TabGroup from '../tab-group'; import Octicon from '../atom/octicon'; const DIALOG_TEXT = { @@ -43,11 +45,6 @@ export default class CreateDialogView extends React.Component { sourceRemoteName: PropTypes.object.isRequired, selectedProtocol: PropTypes.oneOf(['https', 'ssh']).isRequired, acceptEnabled: PropTypes.bool.isRequired, - autofocus: PropTypes.object.isRequired, - tabGroup: PropTypes.shape({ - nextIndex: PropTypes.func.isRequired, - focusBeginning: PropTypes.func.isRequired, - }), // Change callbacks didChangeOwnerID: PropTypes.func.isRequired, @@ -62,7 +59,15 @@ export default class CreateDialogView extends React.Component { config: PropTypes.object.isRequired, } + constructor(props) { + super(props); + + this.autofocus = new AutoFocus(); + this.tabGroup = new TabGroup(); + } + render() { + this.tabGroup.reset(); const text = DIALOG_TEXT[this.props.request.identifier]; return ( @@ -72,8 +77,8 @@ export default class CreateDialogView extends React.Component { acceptText={text.acceptText} accept={this.props.accept} cancel={this.props.request.cancel} - autofocus={this.props.autofocus} - tabGroup={this.props.tabGroup} + autofocus={this.autofocus} + tabGroup={this.tabGroup} inProgress={this.props.inProgress} error={this.props.error} workspace={this.props.workspace} @@ -90,8 +95,8 @@ export default class CreateDialogView extends React.Component { selectedOwnerID={this.props.selectedOwnerID} didChangeOwnerID={this.props.didChangeOwnerID} isLoading={this.props.isLoading} - autofocus={this.props.autofocus} - tabGroup={this.props.tabGroup} + autofocus={this.autofocus} + tabGroup={this.tabGroup.reserve(2)} />
@@ -104,7 +109,7 @@ export default class CreateDialogView extends React.Component { value="PUBLIC" checked={this.props.selectedVisibility === 'PUBLIC'} onChange={this.didChangeVisibility} - tabIndex={this.props.tabGroup.nextIndex()} + tabIndex={this.tabGroup.nextIndex()} /> Public @@ -117,7 +122,7 @@ export default class CreateDialogView extends React.Component { value="PRIVATE" checked={this.props.selectedVisibility === 'PRIVATE'} onChange={this.didChangeVisibility} - tabIndex={this.props.tabGroup.nextIndex()} + tabIndex={this.tabGroup.nextIndex()} /> Private @@ -128,14 +133,14 @@ export default class CreateDialogView extends React.Component { currentWindow={this.props.currentWindow} buffer={this.props.localPath} disabled={this.props.request.identifier === 'publish'} - tabGroup={this.props.tabGroup} + tabGroup={this.tabGroup.reserve(2)} />
@@ -143,7 +148,7 @@ export default class CreateDialogView extends React.Component { } componentDidMount() { - this.props.autofocus.trigger(); + this.autofocus.trigger(); } didChangeVisibility = event => this.props.didChangeVisibility(event.target.value); diff --git a/lib/views/create-dialog.js b/lib/views/create-dialog.js index 1918b13353..1b474bfc32 100644 --- a/lib/views/create-dialog.js +++ b/lib/views/create-dialog.js @@ -4,8 +4,6 @@ import fs from 'fs-extra'; import CreateDialogContainer from '../containers/create-dialog-container'; import createRepositoryMutation from '../mutations/create-repository'; -import AutoFocus from '../autofocus'; -import TabGroup from '../tab-group'; import {GithubLoginModelPropType} from '../prop-types'; export default class CreateDialog extends React.Component { @@ -23,21 +21,8 @@ export default class CreateDialog extends React.Component { config: PropTypes.object.isRequired, } - constructor(props) { - super(props); - - this.autofocus = new AutoFocus(); - this.tabGroup = new TabGroup(); - } - render() { - return ( - - ); + return ; } } diff --git a/test/containers/create-dialog-container.test.js b/test/containers/create-dialog-container.test.js index 4c2cb0aa46..26667e59f6 100644 --- a/test/containers/create-dialog-container.test.js +++ b/test/containers/create-dialog-container.test.js @@ -8,8 +8,6 @@ import {dialogRequests} from '../../lib/controllers/dialogs-controller'; import {InMemoryStrategy} from '../../lib/shared/keytar-strategy'; import GithubLoginModel from '../../lib/models/github-login-model'; import {getEndpoint} from '../../lib/models/endpoint'; -import AutoFocus from '../../lib/autofocus'; -import TabGroup from '../../lib/tab-group'; import {queryBuilder} from '../builder/graphql/query'; import query from '../../lib/containers/__generated__/createDialogContainerQuery.graphql'; @@ -32,8 +30,6 @@ describe('CreateDialogContainer', function() { Date: Wed, 31 Jul 2019 15:44:30 -0400 Subject: [PATCH 50/78] Reset tabGroups at the start of each render --- lib/views/directory-select.js | 3 +++ lib/views/remote-configuration-view.js | 3 +++ lib/views/repository-home-selection-view.js | 5 +++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/views/directory-select.js b/lib/views/directory-select.js index 1903afec54..c23781d520 100644 --- a/lib/views/directory-select.js +++ b/lib/views/directory-select.js @@ -13,6 +13,7 @@ export default class DirectorySelect extends React.Component { disabled: PropTypes.bool, showOpenDialog: PropTypes.func, tabGroup: PropTypes.shape({ + reset: PropTypes.func.isRequired, nextIndex: PropTypes.func.isRequired, }), } @@ -23,6 +24,8 @@ export default class DirectorySelect extends React.Component { } render() { + this.props.tabGroup.reset(); + return (
o.id === this.props.selectedOwnerID) || owners[0]; From b088d8013655e69bbf1058ee3f6d7f839509fabb Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 31 Jul 2019 15:44:40 -0400 Subject: [PATCH 51/78] Select non-bare children --- test/views/create-dialog-view.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/views/create-dialog-view.test.js b/test/views/create-dialog-view.test.js index 19c96e8e26..a4f2a6da30 100644 --- a/test/views/create-dialog-view.test.js +++ b/test/views/create-dialog-view.test.js @@ -3,7 +3,7 @@ import {shallow} from 'enzyme'; import {TextBuffer} from 'atom'; import CreateDialogView from '../../lib/views/create-dialog-view'; -import {BareRepositoryHomeSelectionView} from '../../lib/views/repository-home-selection-view'; +import RepositoryHomeSelectionView from '../../lib/views/repository-home-selection-view'; import {dialogRequests} from '../../lib/controllers/dialogs-controller'; describe('CreateDialogView', function() { @@ -46,7 +46,7 @@ describe('CreateDialogView', function() { it('renders in a loading state when no relay data is available', function() { const wrapper = shallow(buildApp({user: null, isLoading: true})); - const homeView = wrapper.find(BareRepositoryHomeSelectionView); + const homeView = wrapper.find(RepositoryHomeSelectionView); assert.isNull(homeView.prop('user')); assert.isTrue(homeView.prop('isLoading')); }); From 669975a71a02869b6e6c91fdb7120e681c98d920 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sat, 3 Aug 2019 21:26:45 -0400 Subject: [PATCH 52/78] CSS for react-select --- styles/dialog.less | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/styles/dialog.less b/styles/dialog.less index 302a972f1c..5834b11556 100644 --- a/styles/dialog.less +++ b/styles/dialog.less @@ -146,6 +146,31 @@ &-owner { flex: 1; + .Select-control { + border-color: @button-border-color; + } + + &.Select--single > .Select-control .Select-value.Select-value { + background: @button-background-color; + } + + &.Select.has-value.Select--single > .Select-control .Select-value .Select-value-label { + color: @text-color; + } + + .Select-option { + background: @button-background-color; + color: @text-color; + + &.is-focused { + background: @button-background-color-selected; + } + + &.is-disabled { + color: @text-color-subtle; + } + } + &Option { display: flex; flex-direction: row; From 05b959685cf6f9e5aeea4bbbbca0d2dddac5f2d7 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sat, 3 Aug 2019 22:02:56 -0400 Subject: [PATCH 53/78] Wait until Repository loads to register publish command --- lib/controllers/root-controller.js | 92 ++++++++++++++++++------------ 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 953c63966d..53ee82a659 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -5,12 +5,14 @@ import {remote} from 'electron'; import React, {Fragment} from 'react'; import PropTypes from 'prop-types'; import {CompositeDisposable} from 'event-kit'; +import yubikiri from 'yubikiri'; import StatusBar from '../atom/status-bar'; import PaneItem from '../atom/pane-item'; import {openIssueishItem} from '../views/open-issueish-dialog'; import {openCommitDetailItem} from '../views/open-commit-dialog'; import {createRepository, publishRepository} from '../views/create-dialog'; +import ObserveModel from '../views/observe-model'; import Commands, {Command} from '../atom/commands'; import ChangedFileItem from '../items/changed-file-item'; import IssueishDetailItem from '../items/issueish-detail-item'; @@ -135,41 +137,56 @@ export default class RootController extends React.Component { const devMode = global.atom && global.atom.inDevMode(); return ( - - {devMode && } - - - - - - - - - this.openInitializeDialog()} /> - this.openCloneDialog()} /> - this.openIssueishDialog()} /> - this.openCommitDialog()} /> - this.openCreateDialog()} /> - {this.props.repository.isPublishable() && ( - this.openPublishDialog(this.props.repository)} /> - ) || null} - - - - - + + + {devMode && } + + + + + + + + + this.openInitializeDialog()} /> + this.openCloneDialog()} /> + this.openIssueishDialog()} /> + this.openCommitDialog()} /> + this.openCreateDialog()} /> + + + + + + + {data => { + if (!data || !data.isPublishable || !data.remotes.filter(r => r.isGithubRepo()).isEmpty()) { + return null; + } + + return ( + + this.openPublishDialog(this.props.repository)} + /> + + ); + }} + + ); } @@ -407,6 +424,11 @@ export default class RootController extends React.Component { ); } + fetchData = repository => yubikiri({ + isPublishable: repository.isPublishable(), + remotes: repository.getRemotes(), + }); + async openTabs() { if (this.props.startOpen) { await Promise.all([ From d19c1521c29102c57f3a14ae83998613006d0326 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sun, 4 Aug 2019 21:21:17 -0400 Subject: [PATCH 54/78] Manage focus manually with Tabbable components --- lib/tab-group.js | 101 ++++++++------------- lib/views/tabbable.js | 67 ++++++++++++++ test/tab-group.test.js | 199 ++++++++++++++++++++++++++++------------- 3 files changed, 241 insertions(+), 126 deletions(-) create mode 100644 lib/views/tabbable.js diff --git a/lib/tab-group.js b/lib/tab-group.js index adc412b952..834050233d 100644 --- a/lib/tab-group.js +++ b/lib/tab-group.js @@ -1,84 +1,59 @@ -class TabIndexSequence { - constructor(startIndex, reserved) { - this.startIndex = startIndex; - this.currentIndex = startIndex; - this.reserved = reserved; +export default class TabGroup { + constructor() { + this.nodesByElement = new Map(); + this.lastElement = null; + this.autofocusTarget = null; } - nextIndex() { - const i = this.currentIndex; - this.currentIndex++; - this.checkRange(); - return i; - } + appendElement(element, autofocus) { + const lastNode = this.nodesByElement.get(this.lastElement) || {next: element, previous: element}; + const next = lastNode.next; + const previous = this.lastElement || element; - reset() { - this.currentIndex = this.startIndex; - } + this.nodesByElement.set(element, {next, previous}); + this.nodesByElement.get(lastNode.next).previous = element; + lastNode.next = element; - advance(count) { - this.currentIndex += count; - this.checkRange(); - } + this.lastElement = element; - checkRange() { - if (this.currentIndex - this.startIndex > this.reserved) { - throw new Error('Tab index out of range'); + if (autofocus && this.autofocusTarget === null) { + this.autofocusTarget = element; } } -} -const SEQUENCE = Symbol('sequence'); + removeElement(element) { + const node = this.nodesByElement.get(element); + if (node) { + const beforeNode = this.nodesByElement.get(node.previous); + const afterNode = this.nodesByElement.get(node.next); -/** - * Assign successive, distinct tabIndex values to DOM elements. - */ -export default class TabGroup { - constructor(options = {}) { - if (options[SEQUENCE]) { - this.startIndex = null; - this.sequence = options[SEQUENCE]; - } else { - this.startIndex = 1; - for (const element of document.querySelectorAll('[tabindex]')) { - if (element.disabled) { - continue; - } - - if (element.tabIndex < 0) { - continue; - } - - if (element.tabIndex > this.startIndex) { - this.startIndex = element.tabIndex + 1; - } - } - this.sequence = new TabIndexSequence(this.startIndex, Infinity); + beforeNode.next = node.next; + afterNode.previous = node.previous; } + this.nodesByElement.delete(element); } - reset() { - this.sequence.reset(); + after(element) { + const node = this.nodesByElement.get(element) || {next: undefined}; + return node.next; } - nextIndex() { - return this.sequence.nextIndex(); + focusAfter(element) { + const after = this.after(element); + after && after.focus(); } - reserve(count) { - const childSequence = new TabIndexSequence(this.sequence.nextIndex(), count); - this.sequence.advance(count - 1); - return new TabGroup({[SEQUENCE]: childSequence}); + before(element) { + const node = this.nodesByElement.get(element) || {previous: undefined}; + return node.previous; } - focusBeginning() { - if (this.startIndex === null) { - return; - } + focusBefore(element) { + const before = this.before(element); + before && before.focus(); + } - const element = document.querySelector(`[tabIndex="${this.startIndex}"]`); - if (element) { - element.focus(); - } + autofocus() { + this.autofocusTarget && this.autofocusTarget.focus(); } } diff --git a/lib/views/tabbable.js b/lib/views/tabbable.js new file mode 100644 index 0000000000..67581e7182 --- /dev/null +++ b/lib/views/tabbable.js @@ -0,0 +1,67 @@ +import React, {Fragment} from 'react'; +import PropTypes from 'prop-types'; + +import Commands, {Command} from '../atom/commands'; +import AtomTextEditor from '../atom/atom-text-editor'; +import RefHolder from '../models/ref-holder'; +import {unusedProps} from '../helpers'; + +export function makeTabbable(Component) { + return class extends React.Component { + static propTypes = { + tabGroup: PropTypes.shape({ + appendElement: PropTypes.func.isRequired, + removeElement: PropTypes.func.isRequired, + focusAfter: PropTypes.func.isRequired, + focusBefore: PropTypes.func.isRequired, + }).isRequired, + autofocus: PropTypes.bool, + + commands: PropTypes.object.isRequired, + } + + static defaultProps = { + autofocus: false, + } + + constructor(props) { + super(props); + + this.elementRef = new RefHolder(); + } + + render() { + + + return ( + + + + + + + + ); + } + + componentDidMount() { + this.elementRef.map(element => this.props.tabGroup.appendElement(element, this.props.autofocus)); + } + + componentWillUnmount() { + this.elementRef.map(element => this.props.tabGroup.removeElement(element)); + } + + focusNext = () => this.elementRef.map(element => this.props.tabGroup.focusAfter(element)); + + focusPrevious = () => this.elementRef.map(element => this.props.tabGroup.focusBefore(element)); + }; +} + +export const TabbableInput = makeTabbable('input'); + +export const TabbableTextEditor = makeTabbable(AtomTextEditor); diff --git a/test/tab-group.test.js b/test/tab-group.test.js index f3592fcf05..dc01c0a0b4 100644 --- a/test/tab-group.test.js +++ b/test/tab-group.test.js @@ -1,77 +1,150 @@ +import React from 'react'; +import {mount} from 'enzyme'; + import TabGroup from '../lib/tab-group'; +import {TabbableInput} from '../lib/views/tabbable'; describe('TabGroup', function() { - let div; + let atomEnv, root; beforeEach(function() { - div = document.createElement('div'); - div.tabIndex = 1000000; - document.body.appendChild(div); - }); - - afterEach(function() { - div.remove(); - }); + atomEnv = global.buildAtomEnvironment(); - it('begins above the highest tabIndex existing in the DOM', function() { - const group = new TabGroup(); - assert.strictEqual(group.nextIndex(), 1000001); + root = document.createElement('div'); + document.body.appendChild(root); }); - it('assigns ascending indices to each successive tab target', function() { - const group = new TabGroup(); - assert.strictEqual(group.nextIndex(), 1000001); - assert.strictEqual(group.nextIndex(), 1000002); - assert.strictEqual(group.nextIndex(), 1000003); - }); - - it('brings focus to the lowest tabIndex assigned by this group', function() { - const group = new TabGroup(); - - const child0 = document.createElement('div'); - child0.tabIndex = group.nextIndex(); - sinon.stub(child0, 'focus'); - div.appendChild(child0); - - const child1 = document.createElement('div'); - child1.tabIndex = group.nextIndex(); - div.appendChild(child1); - - const child2 = document.createElement('div'); - child2.tabIndex = group.nextIndex(); - div.appendChild(child2); - - group.focusBeginning(); - assert.isTrue(child0.focus.called); + afterEach(function() { + root.remove(); + atomEnv.destroy(); }); - it('creates a child group that reserves a range of indices', function() { - const parent = new TabGroup(); - assert.strictEqual(parent.nextIndex(), 1000001); - - const child0 = parent.reserve(2); - assert.strictEqual(parent.nextIndex(), 1000004); - assert.strictEqual(parent.nextIndex(), 1000005); - - const child1 = parent.reserve(3); - assert.strictEqual(parent.nextIndex(), 1000009); - - assert.strictEqual(child0.nextIndex(), 1000002); - assert.strictEqual(child0.nextIndex(), 1000003); - - assert.strictEqual(child1.nextIndex(), 1000006); - assert.strictEqual(child1.nextIndex(), 1000007); - assert.strictEqual(child1.nextIndex(), 1000008); - assert.throws(() => child1.nextIndex(), /Tab index out of range/); + describe('with tabbable elements', function() { + let group, zero, one, two; + + beforeEach(function() { + group = new TabGroup(); + + mount( +
+ + + +
, + {attachTo: root}, + ); + + zero = root.querySelector('#zero'); + one = root.querySelector('#one'); + two = root.querySelector('#two'); + + sinon.stub(zero, 'focus'); + sinon.stub(one, 'focus'); + sinon.stub(two, 'focus'); + }); + + it('appends elements into a doubly-linked circular list', function() { + let current = zero; + + current = group.after(current); + assert.strictEqual(current.id, 'one'); + current = group.after(current); + assert.strictEqual(current.id, 'two'); + current = group.after(current); + assert.strictEqual(current.id, 'zero'); + + current = group.before(current); + assert.strictEqual(current.id, 'two'); + current = group.before(current); + assert.strictEqual(current.id, 'one'); + current = group.before(current); + assert.strictEqual(current.id, 'zero'); + current = group.before(current); + assert.strictEqual(current.id, 'two'); + }); + + it('brings focus to a successor element, wrapping around at the end', function() { + group.focusAfter(zero); + assert.strictEqual(one.focus.callCount, 1); + + group.focusAfter(one); + assert.strictEqual(two.focus.callCount, 1); + + group.focusAfter(two); + assert.strictEqual(zero.focus.callCount, 1); + }); + + it('is a no-op with unregistered elements', function() { + const unregistered = document.createElement('div'); + + group.focusAfter(unregistered); + group.focusBefore(unregistered); + + assert.isFalse(zero.focus.called); + assert.isFalse(one.focus.called); + assert.isFalse(two.focus.called); + }); + + it('brings focus to a predecessor element, wrapping around at the beginning', function() { + group.focusBefore(zero); + assert.strictEqual(two.focus.callCount, 1); + + group.focusBefore(two); + assert.strictEqual(one.focus.callCount, 1); + + group.focusBefore(one); + assert.strictEqual(zero.focus.callCount, 1); + }); }); - it('resets to its start index', function() { - const parent = new TabGroup(); - assert.strictEqual(parent.nextIndex(), 1000001); - assert.strictEqual(parent.nextIndex(), 1000002); - - parent.reset(); - assert.strictEqual(parent.nextIndex(), 1000001); - assert.strictEqual(parent.nextIndex(), 1000002); + describe('autofocus', function() { + it('brings focus to the first rendered element with autofocus', function() { + const group = new TabGroup(); + + mount( +
+ + {false && } + + + +
, + {attachTo: root}, + ); + + const elements = ['zero', 'one', 'two', 'three'].map(id => document.getElementById(id)); + for (const element of elements) { + sinon.stub(element, 'focus'); + } + + group.autofocus(); + + assert.isFalse(elements[0].focus.called); + assert.isFalse(elements[1].focus.called); + assert.isTrue(elements[2].focus.called); + assert.isFalse(elements[3].focus.called); + }); + + it('is a no-op if no elements are autofocusable', function() { + const group = new TabGroup(); + + mount( +
+ + +
, + {attachTo: root}, + ); + + const elements = ['zero', 'one'].map(id => document.getElementById(id)); + for (const element of elements) { + sinon.stub(element, 'focus'); + } + + group.autofocus(); + + assert.isFalse(elements[0].focus.called); + assert.isFalse(elements[1].focus.called); + }); }); }); From 5baa35cfee08b5acd3e1e7d0b3706c079ef8d39e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 1 Oct 2019 10:07:57 -0400 Subject: [PATCH 55/78] External refElements in AtomTextEditor --- lib/atom/atom-text-editor.js | 25 +++++++++++++++++++------ test/atom/atom-text-editor.test.js | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/lib/atom/atom-text-editor.js b/lib/atom/atom-text-editor.js index 6f1105dde1..7e0c3f7315 100644 --- a/lib/atom/atom-text-editor.js +++ b/lib/atom/atom-text-editor.js @@ -41,6 +41,7 @@ export default class AtomTextEditor extends React.Component { tabIndex: PropTypes.number, refModel: RefHolderPropType, + refElement: RefHolderPropType, children: PropTypes.node, } @@ -62,7 +63,7 @@ export default class AtomTextEditor extends React.Component { this.subs = new CompositeDisposable(); this.refParent = new RefHolder(); - this.refElement = new RefHolder(); + this.refElement = null; this.refModel = null; } @@ -91,7 +92,7 @@ export default class AtomTextEditor extends React.Component { } element.appendChild(editor.getElement()); this.getRefModel().setter(editor); - this.refElement.setter(editor.getElement()); + this.getRefElement().setter(editor.getElement()); this.subs.add( editor.onDidChangeCursorPosition(this.props.didChangeCursorPosition), @@ -136,20 +137,20 @@ export default class AtomTextEditor extends React.Component { observeEmptiness = () => { this.getRefModel().map(editor => { if (editor.isEmpty() && this.props.hideEmptiness) { - this.refElement.map(element => element.classList.add(EMPTY_CLASS)); + this.getRefElement().map(element => element.classList.add(EMPTY_CLASS)); } else { - this.refElement.map(element => element.classList.remove(EMPTY_CLASS)); + this.getRefElement().map(element => element.classList.remove(EMPTY_CLASS)); } return null; }); } contains(element) { - return this.refElement.map(e => e.contains(element)).getOr(false); + return this.getRefElement().map(e => e.contains(element)).getOr(false); } focus() { - this.refElement.map(e => e.focus()); + this.getRefElement().map(e => e.focus()); } getRefModel() { @@ -164,6 +165,18 @@ export default class AtomTextEditor extends React.Component { return this.refModel; } + getRefElement() { + if (this.props.refElement) { + return this.props.refElement; + } + + if (!this.refElement) { + this.refElement = new RefHolder(); + } + + return this.refElement; + } + getModel() { return this.getRefModel().getOr(undefined); } diff --git a/test/atom/atom-text-editor.test.js b/test/atom/atom-text-editor.test.js index 297260cb9b..b0cd1d3bd7 100644 --- a/test/atom/atom-text-editor.test.js +++ b/test/atom/atom-text-editor.test.js @@ -35,6 +35,28 @@ describe('AtomTextEditor', function() { assert.isTrue(workspace.isTextEditor(app.instance().refModel.get())); }); + it('creates its own element ref if one is not provided by a parent', function() { + const app = mount(); + + const model = app.instance().refModel.get(); + const element = app.instance().refElement.get(); + assert.strictEqual(element, model.getElement()); + assert.strictEqual(element.getModel(), model); + }); + + it('accepts parent-provided model and element refs', function() { + const refElement = new RefHolder(); + + mount(); + + const model = refModel.get(); + const element = refElement.get(); + + assert.isTrue(workspace.isTextEditor(model)); + assert.strictEqual(element, model.getElement()); + assert.strictEqual(element.getModel(), model); + }); + it('returns undefined if the current model is unavailable', function() { const emptyHolder = new RefHolder(); const app = shallow(); From 0e4cc818d3ede087f6dbadda7d1b2080c8f80d10 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 1 Oct 2019 10:09:33 -0400 Subject: [PATCH 56/78] Tabbable Select lists --- lib/views/tabbable.js | 55 ++++++++++++++++++++++++++++++++++++++----- styles/dialog.less | 5 ++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/lib/views/tabbable.js b/lib/views/tabbable.js index 67581e7182..d3897bb28f 100644 --- a/lib/views/tabbable.js +++ b/lib/views/tabbable.js @@ -1,12 +1,14 @@ import React, {Fragment} from 'react'; import PropTypes from 'prop-types'; +import Select from 'react-select'; import Commands, {Command} from '../atom/commands'; import AtomTextEditor from '../atom/atom-text-editor'; import RefHolder from '../models/ref-holder'; +import {RefHolderPropType} from '../prop-types'; import {unusedProps} from '../helpers'; -export function makeTabbable(Component) { +export function makeTabbable(Component, options = {}) { return class extends React.Component { static propTypes = { tabGroup: PropTypes.shape({ @@ -27,22 +29,30 @@ export function makeTabbable(Component) { constructor(props) { super(props); + this.rootRef = new RefHolder(); this.elementRef = new RefHolder(); + + if (options.rootRefProp) { + this.rootRef = new RefHolder(); + this.rootRefProps = {[options.rootRefProp]: this.rootRef}; + } else { + this.rootRef = this.elementRef; + this.rootRefProps = {}; + } } render() { - - return ( - + ); @@ -64,4 +74,37 @@ export function makeTabbable(Component) { export const TabbableInput = makeTabbable('input'); -export const TabbableTextEditor = makeTabbable(AtomTextEditor); +export const TabbableButton = makeTabbable('button'); + +export const TabbableSummary = makeTabbable('summary'); + +export const TabbableTextEditor = makeTabbable(AtomTextEditor, {rootRefProp: 'refElement'}); + +class WrapSelect extends React.Component { + static propTypes = { + refElement: RefHolderPropType.isRequired, + } + + constructor(props) { + super(props); + + this.refSelect = new RefHolder(); + } + + render() { + return ( +
+ Public
@@ -148,7 +150,7 @@ export default class CreateDialogView extends React.Component { } componentDidMount() { - this.autofocus.trigger(); + this.tabGroup.autofocus(); } didChangeVisibility = event => this.props.didChangeVisibility(event.target.value); diff --git a/lib/views/dialog-view.js b/lib/views/dialog-view.js index b50c79fed4..cb70255b57 100644 --- a/lib/views/dialog-view.js +++ b/lib/views/dialog-view.js @@ -4,6 +4,7 @@ import cx from 'classnames'; import Commands, {Command} from '../atom/commands'; import Panel from '../atom/panel'; +import {TabbableButton} from './tabbable'; export default class DialogView extends React.Component { static propTypes = { @@ -19,10 +20,7 @@ export default class DialogView extends React.Component { cancel: PropTypes.func.isRequired, // State - tabGroup: PropTypes.shape({ - nextIndex: PropTypes.func.isRequired, - focusBeginning: PropTypes.func.isRequired, - }), + tabGroup: PropTypes.object.isRequired, inProgress: PropTypes.bool.isRequired, error: PropTypes.instanceOf(Error), @@ -42,7 +40,7 @@ export default class DialogView extends React.Component { render() { return ( -
this.props.tabGroup.focusBeginning()}> +
@@ -68,19 +66,21 @@ export default class DialogView extends React.Component { )}
- - +
diff --git a/lib/views/directory-select.js b/lib/views/directory-select.js index c23781d520..e22137771f 100644 --- a/lib/views/directory-select.js +++ b/lib/views/directory-select.js @@ -2,20 +2,20 @@ import React from 'react'; import PropTypes from 'prop-types'; import {remote} from 'electron'; -import AtomTextEditor from '../../lib/atom/atom-text-editor'; +import {TabbableTextEditor, TabbableButton} from './tabbable'; const {dialog} = remote; export default class DirectorySelect extends React.Component { static propTypes = { - currentWindow: PropTypes.object.isRequired, buffer: PropTypes.object.isRequired, disabled: PropTypes.bool, showOpenDialog: PropTypes.func, - tabGroup: PropTypes.shape({ - reset: PropTypes.func.isRequired, - nextIndex: PropTypes.func.isRequired, - }), + tabGroup: PropTypes.object.isRequired, + + // Atom environment + currentWindow: PropTypes.object.isRequired, + commands: PropTypes.object.isRequired, } static defaultProps = { @@ -24,22 +24,22 @@ export default class DirectorySelect extends React.Component { } render() { - this.props.tabGroup.reset(); - return (
- -
); diff --git a/lib/views/remote-configuration-view.js b/lib/views/remote-configuration-view.js index 34a085f6cd..e39bc27846 100644 --- a/lib/views/remote-configuration-view.js +++ b/lib/views/remote-configuration-view.js @@ -2,23 +2,20 @@ import React from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; -import AtomTextEditor from '../atom/atom-text-editor'; +import {TabbableInput, TabbableSummary, TabbableTextEditor} from './tabbable'; export default class RemoteConfigurationView extends React.Component { static propTypes = { + tabGroup: PropTypes.object.isRequired, currentProtocol: PropTypes.oneOf(['https', 'ssh']), sourceRemoteBuffer: PropTypes.object.isRequired, didChangeProtocol: PropTypes.func.isRequired, - tabGroup: PropTypes.shape({ - reset: PropTypes.func.isRequired, - nextIndex: PropTypes.func.isRequired, - }), + // Atom environment + commands: PropTypes.object.isRequired, } render() { - this.props.tabGroup.reset(); - const httpsClassName = cx( 'github-RemoteConfiguration-protocolOption', 'github-RemoteConfiguration-protocolOption--https', @@ -33,43 +30,46 @@ export default class RemoteConfigurationView extends React.Component { return (
- Advanced + Advanced
Protocol:
diff --git a/lib/views/repository-home-selection-view.js b/lib/views/repository-home-selection-view.js index 08f62dcfd3..0ce99ddf19 100644 --- a/lib/views/repository-home-selection-view.js +++ b/lib/views/repository-home-selection-view.js @@ -1,9 +1,8 @@ import React, {Fragment} from 'react'; import PropTypes from 'prop-types'; import {createPaginationContainer, graphql} from 'react-relay'; -import Select from 'react-select'; -import AtomTextEditor from '../atom/atom-text-editor'; +import {TabbableTextEditor, TabbableSelect} from './tabbable'; const PAGE_DELAY = 500; @@ -37,27 +36,32 @@ export class BareRepositoryHomeSelectionView extends React.Component { nameBuffer: PropTypes.object.isRequired, isLoading: PropTypes.bool.isRequired, selectedOwnerID: PropTypes.string.isRequired, - autofocus: PropTypes.shape({ - target: PropTypes.func.isRequired, - }).isRequired, - tabGroup: PropTypes.shape({ - reset: PropTypes.func.isRequired, - nextIndex: PropTypes.func.isRequired, - }), + tabGroup: PropTypes.object.isRequired, + autofocusOwner: PropTypes.bool, + autofocusName: PropTypes.bool, // Selection callback didChangeOwnerID: PropTypes.func.isRequired, + + // Atom environment + commands: PropTypes.object.isRequired, } - render() { - this.props.tabGroup.reset(); + static defaultProps = { + autofocusOwner: false, + autofocusName: false, + } + render() { const owners = this.getOwners(); const currentOwner = owners.find(o => o.id === this.props.selectedOwnerID) || owners[0]; return (
- Password: - - + {params.includeRemember && (