Use Preact for asynchronous rendering for /settings/integrations (#986)
* Set default styles and move button style out of form * Add MVP of Preact /settings/integrations * Change background color on hover and focus * Remove unused constructor * Improve optimistic rendering * Add display name * Use loading indicator for optimistic rendering * WIP test * Add basic snapshot tests
This commit is contained in:
parent
56ee5835e4
commit
ebd4b50f3f
13 changed files with 432 additions and 46 deletions
|
|
@ -358,6 +358,14 @@
|
|||
overflow-y: auto;
|
||||
background: white;
|
||||
box-shadow: $shadow;
|
||||
&.loading-repos {
|
||||
height: 400px;
|
||||
background: white url(image_path('loading-ellipsis.svg')) no-repeat center center;
|
||||
background-size: 50px;
|
||||
}
|
||||
&.github-repos-errored {
|
||||
height: 400px;
|
||||
}
|
||||
.github-repo-row {
|
||||
background:white;
|
||||
padding: 12px;
|
||||
|
|
@ -367,8 +375,30 @@
|
|||
}
|
||||
.github-repo-row-name {
|
||||
font-weight:bold;
|
||||
button {
|
||||
cursor:pointer;
|
||||
font-size:1.2em;
|
||||
font-weight: bold;
|
||||
width: 130px;
|
||||
background: white;
|
||||
border:0px solid;
|
||||
border-radius:3px;
|
||||
padding: 3px 0px;
|
||||
margin-top:-5px;
|
||||
float: right;
|
||||
min-height: 28.5px;
|
||||
&:hover, &:focus {
|
||||
background: $green;
|
||||
}
|
||||
&:disabled {
|
||||
cursor: wait;
|
||||
background: white url(image_path('loading-ellipsis.svg')) no-repeat center center;
|
||||
background-size: 75px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.github-repo-fork {
|
||||
margin-left: 5px;
|
||||
font-weight:400;
|
||||
background: $purple;
|
||||
color:$bold-blue;
|
||||
|
|
@ -376,22 +406,6 @@
|
|||
padding: 1px 6px;
|
||||
font-size:0.9em;
|
||||
}
|
||||
form {
|
||||
display:inline-block;
|
||||
float:right;
|
||||
button {
|
||||
cursor:pointer;
|
||||
font-size:13px;
|
||||
background: white;
|
||||
border:1px solid $medium-gray;
|
||||
border-radius:3px;
|
||||
padding: 2px 8px;
|
||||
margin-top:-5px;
|
||||
&:hover {
|
||||
opacity:0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
.github-repo-featured-indicator {
|
||||
display:inline-block;
|
||||
float:right;
|
||||
|
|
|
|||
54
app/controllers/api/v0/github_repos_controller.rb
Normal file
54
app/controllers/api/v0/github_repos_controller.rb
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
module Api
|
||||
module V0
|
||||
class GithubReposController < ApplicationController
|
||||
def index
|
||||
client = create_octokit_client
|
||||
existing_user_repos = GithubRepo.
|
||||
where(user_id: current_user.id, featured: true).pluck(:github_id_code) #=> [1,2,3]
|
||||
existing_user_repos = Set.new(existing_user_repos)
|
||||
@repos = client.repositories.map do |repo|
|
||||
repo.selected = existing_user_repos.include?(repo.id)
|
||||
repo
|
||||
end
|
||||
end
|
||||
|
||||
def update_or_create
|
||||
@client = create_octokit_client
|
||||
@repo = GithubRepo.find_or_create(fetched_repo_params)
|
||||
if @repo.valid?
|
||||
render json: { featured: @repo.featured }
|
||||
else
|
||||
render json: "error: #{@repo.errors.full_messages}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_octokit_client
|
||||
current_user_token = Identity.find_by(provider: "github", user_id: current_user.id).token
|
||||
Octokit::Client.new(access_token: current_user_token)
|
||||
end
|
||||
|
||||
def fetched_repo_params
|
||||
params[:github_repo] = JSON.parse(params[:github_repo])
|
||||
fetched_repo = @client.repositories.select do |repo|
|
||||
repo.id == permitted_attributes(GithubRepo)[:github_id_code].to_i
|
||||
end.first
|
||||
{
|
||||
github_id_code: fetched_repo.id,
|
||||
user_id: current_user.id,
|
||||
name: fetched_repo.name,
|
||||
description: fetched_repo.description,
|
||||
language: fetched_repo.language,
|
||||
fork: fetched_repo.fork,
|
||||
url: fetched_repo.html_url,
|
||||
bytes_size: fetched_repo.size,
|
||||
watchers_count: fetched_repo.watchers,
|
||||
stargazers_count: fetched_repo.stargazers_count,
|
||||
featured: permitted_attributes(GithubRepo)[:featured],
|
||||
info_hash: fetched_repo.to_hash
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<GithubRepos /> when there are no repos loaded yet should render and match the snapshot 1`] = `
|
||||
<div
|
||||
class="github-repos loading-repos"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<GithubRepos /> when there was an error in the response renders and matches the snapshot 1`] = `
|
||||
RenderContext {
|
||||
"0": VNode {
|
||||
"attributes": undefined,
|
||||
"children": Array [],
|
||||
"key": undefined,
|
||||
"nodeName": [Function],
|
||||
},
|
||||
"length": 1,
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<SingleRepo /> when it is a fork should render and match the snapshot 1`] = `
|
||||
<div
|
||||
class="github-repo-row"
|
||||
>
|
||||
<div
|
||||
class="github-repo-row-name"
|
||||
>
|
||||
<button
|
||||
class="cta"
|
||||
id="github-repo-button-123"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
SELECT
|
||||
</button>
|
||||
dev.to
|
||||
<span
|
||||
class="github-repo-fork"
|
||||
>
|
||||
fork
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<SingleRepo /> when it is not already selected should render and match the snapshot 1`] = `
|
||||
<div
|
||||
class="github-repo-row"
|
||||
>
|
||||
<div
|
||||
class="github-repo-row-name"
|
||||
>
|
||||
<button
|
||||
class="cta"
|
||||
id="github-repo-button-123"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
SELECT
|
||||
</button>
|
||||
dev.to
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<SingleRepo /> when it is selected should render and match the snapshot 1`] = `
|
||||
<div
|
||||
class="github-repo-row github-repo-row-selected"
|
||||
>
|
||||
<div
|
||||
class="github-repo-row-name"
|
||||
>
|
||||
<button
|
||||
class="cta"
|
||||
id="github-repo-button-123"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
dev.to
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
34
app/javascript/githubRepos/__tests__/githubRepos.test.jsx
Normal file
34
app/javascript/githubRepos/__tests__/githubRepos.test.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { h } from 'preact';
|
||||
import { shallow } from 'preact-render-spy';
|
||||
import render from 'preact-render-to-json';
|
||||
import fetch from 'jest-fetch-mock';
|
||||
import { GithubRepos } from '../githubRepos';
|
||||
|
||||
global.fetch = fetch;
|
||||
|
||||
describe('<GithubRepos />', () => {
|
||||
describe('when there are no repos loaded yet', () => {
|
||||
it('should render and match the snapshot', () => {
|
||||
const tree = render(<GithubRepos />);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should have the loading div', () => {
|
||||
const context = shallow(<GithubRepos />);
|
||||
expect(context.find('.loading-repos')[0].attributes.className).toEqual(
|
||||
'github-repos loading-repos',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there was an error in the response', () => {
|
||||
it('renders and matches the snapshot', () => {
|
||||
fetch.mockReject('some error');
|
||||
const context = shallow(<GithubRepos />);
|
||||
context.setState({ erroredOut: true });
|
||||
context.rerender();
|
||||
const tree = render(context);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
56
app/javascript/githubRepos/__tests__/singleRepo.test.jsx
Normal file
56
app/javascript/githubRepos/__tests__/singleRepo.test.jsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { h } from 'preact';
|
||||
import { shallow } from 'preact-render-spy';
|
||||
import render from 'preact-render-to-json';
|
||||
import fetch from 'jest-fetch-mock';
|
||||
import { SingleRepo } from '../singleRepo';
|
||||
|
||||
global.fetch = fetch;
|
||||
|
||||
describe('<SingleRepo />', () => {
|
||||
describe('when it is not already selected', () => {
|
||||
const subject = (
|
||||
<SingleRepo
|
||||
githubIdCode={123}
|
||||
name="dev.to"
|
||||
fork={false}
|
||||
selected={false}
|
||||
/>
|
||||
);
|
||||
|
||||
it('should render and match the snapshot', () => {
|
||||
const tree = render(subject);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should have a state of { selected: false }', () => {
|
||||
const context = shallow(subject);
|
||||
expect(context.state()).toEqual({ selected: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it is selected', () => {
|
||||
const subject = (
|
||||
<SingleRepo githubIdCode={123} name="dev.to" fork={false} selected />
|
||||
);
|
||||
it('should render and match the snapshot', () => {
|
||||
const tree = render(subject);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should have a state of { selected: true }', () => {
|
||||
const context = shallow(subject);
|
||||
expect(context.state()).toEqual({ selected: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it is a fork', () => {
|
||||
const subject = (
|
||||
<SingleRepo githubIdCode={123} name="dev.to" fork selected={false} />
|
||||
);
|
||||
|
||||
it('should render and match the snapshot', () => {
|
||||
const tree = render(subject);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
59
app/javascript/githubRepos/githubRepos.jsx
Normal file
59
app/javascript/githubRepos/githubRepos.jsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { h, Component } from 'preact';
|
||||
import { SingleRepo } from './singleRepo';
|
||||
|
||||
export class GithubRepos extends Component {
|
||||
state = {
|
||||
repos: [],
|
||||
erroredOut: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.getGithubRepos();
|
||||
}
|
||||
|
||||
getGithubRepos = () => {
|
||||
fetch(`/api/github_repos`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
this.setState({ repos: json });
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error);
|
||||
this.setState({ erroredOut: true });
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { repos, erroredOut } = this.state;
|
||||
const allRepos = repos.map(repo => (
|
||||
<SingleRepo
|
||||
githubIdCode={repo.github_id_code}
|
||||
name={repo.name}
|
||||
fork={repo.fork}
|
||||
selected={repo.selected}
|
||||
/>
|
||||
));
|
||||
|
||||
if (erroredOut) {
|
||||
return (
|
||||
<div className="github-repos github-repos-errored">
|
||||
An error occurred. Please check your browser console and email
|
||||
<a href="mailto:yo@dev.to"> yo@dev.to </a>
|
||||
for more help.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (repos.length > 0) {
|
||||
return <div className="github-repos">{allRepos}</div>;
|
||||
}
|
||||
return <div className="github-repos loading-repos" />;
|
||||
}
|
||||
}
|
||||
|
||||
GithubRepos.displayName = 'GitHub Repos Wrapper';
|
||||
89
app/javascript/githubRepos/singleRepo.jsx
Normal file
89
app/javascript/githubRepos/singleRepo.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { h, Component } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export class SingleRepo extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { selected } = this.props;
|
||||
this.state = { selected };
|
||||
}
|
||||
|
||||
forkLabel = () => {
|
||||
const { fork } = this.props;
|
||||
if (fork) {
|
||||
return <span className="github-repo-fork">fork</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
submitRepo = () => {
|
||||
const { selected } = this.state;
|
||||
const { githubIdCode } = this.props;
|
||||
|
||||
const submitButton = document.getElementById(
|
||||
`github-repo-button-${githubIdCode}`,
|
||||
);
|
||||
submitButton.textContent = '';
|
||||
submitButton.disabled = true;
|
||||
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").content;
|
||||
const formData = new FormData();
|
||||
const formAttributes = {
|
||||
github_id_code: githubIdCode,
|
||||
featured: !selected,
|
||||
};
|
||||
formData.append('github_repo', JSON.stringify(formAttributes));
|
||||
|
||||
fetch('/api/github_repos/update_or_create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
submitButton.disabled = false;
|
||||
this.setState({ selected: json.featured });
|
||||
});
|
||||
};
|
||||
|
||||
githubRepoClassName = () => {
|
||||
const { selected } = this.state;
|
||||
if (selected) {
|
||||
return 'github-repo-row github-repo-row-selected';
|
||||
}
|
||||
return 'github-repo-row';
|
||||
};
|
||||
|
||||
render() {
|
||||
const { selected } = this.state;
|
||||
const { name, githubIdCode } = this.props;
|
||||
return (
|
||||
<div className={this.githubRepoClassName()}>
|
||||
<div className="github-repo-row-name">
|
||||
<button
|
||||
className="cta"
|
||||
type="button"
|
||||
id={`github-repo-button-${githubIdCode}`}
|
||||
onClick={this.submitRepo}
|
||||
>
|
||||
{selected ? 'REMOVE' : 'SELECT'}
|
||||
</button>
|
||||
{name}
|
||||
{this.forkLabel()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SingleRepo.displayName = 'Single GitHub Repo';
|
||||
|
||||
SingleRepo.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
githubIdCode: PropTypes.number.isRequired,
|
||||
fork: PropTypes.bool.isRequired,
|
||||
selected: PropTypes.bool.isRequired,
|
||||
};
|
||||
8
app/javascript/packs/githubRepos.jsx
Normal file
8
app/javascript/packs/githubRepos.jsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { h, render } from 'preact';
|
||||
import { GithubRepos } from '../githubRepos/githubRepos';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const root = document.getElementById('github-repos-container');
|
||||
|
||||
render(<GithubRepos />, root, root.firstElementChild);
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ class GithubRepoPolicy < ApplicationPolicy
|
|||
end
|
||||
|
||||
def permitted_attributes
|
||||
%i[github_id_code]
|
||||
%i[github_id_code featured]
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
6
app/views/api/v0/github_repos/index.json.jbuilder
Normal file
6
app/views/api/v0/github_repos/index.json.jbuilder
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
json.array! @repos.each do |repo|
|
||||
json.github_id_code repo.id
|
||||
json.name repo.name
|
||||
json.fork repo.fork
|
||||
json.selected repo.selected
|
||||
end
|
||||
|
|
@ -1,33 +1,9 @@
|
|||
<h3>Pin a few of your GitHub repos to your profile</h3>
|
||||
<% if @client %>
|
||||
<div class="github-repos">
|
||||
<% @client.repositories.each do |repo| %>
|
||||
<% if (user_repo = GithubRepo.find_by(github_id_code: repo.id)) && user_repo.featured %>
|
||||
<div class="github-repo-row github-repo-row-selected">
|
||||
<div class="github-repo-row-name">
|
||||
<%= repo.name %> <%= "<span class='github-repo-fork'>fork</span>".html_safe if repo.fork %>
|
||||
<%= form_for(user_repo) do |f| %>
|
||||
<button class="cta">Remove</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="github-repo-row">
|
||||
<div class="github-repo-row-name">
|
||||
<%= repo.name %> <%= "<span class='github-repo-fork'>fork</span>".html_safe if repo.fork %>
|
||||
<%= form_for(GithubRepo.new) do |f| %>
|
||||
<%= f.hidden_field :github_id_code, value: repo.id %>
|
||||
<button class="cta">Select</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="field">
|
||||
<a href="/users/auth/github" class="big-button cta" data-no-instant>
|
||||
<img src="<%= asset_path('github-logo.svg') %>" /> CONNECT GITHUB ACCOUNT
|
||||
</a>
|
||||
<div id="github-repos-container">
|
||||
<div class="github-repos loading-repos">
|
||||
</div>
|
||||
</div>
|
||||
<%= javascript_pack_tag "githubRepos", defer: true %>
|
||||
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
resources :follows, only: [:create]
|
||||
resources :github_repos, only: [:index] do
|
||||
collection do
|
||||
post "/update_or_create", to: "github_repos#update_or_create"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue