-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feature/milestone 1/repo name validation (#121)
* [FEAT] GitHub repository name validator
- Loading branch information
1 parent
8dd7405
commit c9bf315
Showing
6 changed files
with
221 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
90 changes: 90 additions & 0 deletions
90
app/javascript/controllers/repository_name_validator_controller.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
import { Controller } from "@hotwired/stimulus" | ||
|
||
export default class extends Controller { | ||
static targets = ["input", "message", "spinner"] | ||
static values = { | ||
checkUrl: String, | ||
debounce: { type: Number, default: 500 } | ||
} | ||
|
||
static classes = ["error", "success"] | ||
|
||
initialize() { | ||
this.timeout = null | ||
this.debouncedValidate = this.debounce( | ||
this.performValidation.bind(this), | ||
this.debounceValue | ||
) | ||
} | ||
|
||
debounce(func, wait) { | ||
return (...args) => { | ||
clearTimeout(this.timeout) | ||
this.timeout = setTimeout(() => func.apply(this, args), wait) | ||
} | ||
} | ||
|
||
validate() { | ||
this.spinnerTarget.classList.remove('hidden') | ||
this.messageTarget.classList.add('hidden') | ||
this.debouncedValidate() | ||
} | ||
|
||
async performValidation() { | ||
const name = this.inputTarget.value | ||
const valid = this.validateFormat(name) | ||
|
||
this.spinnerTarget.classList.add('hidden') | ||
|
||
if (!valid) { | ||
this.showMessage("Invalid format. Use only letters, numbers, and single hyphens.", "error") | ||
return | ||
} | ||
|
||
try { | ||
const available = await this.checkAvailability(name) | ||
if (!available) { | ||
this.showMessage("Repository name already taken", "error") | ||
return | ||
} | ||
this.showMessage("Name available", "success") | ||
} catch (error) { | ||
// Error message already shown by checkAvailability | ||
return | ||
} | ||
} | ||
|
||
validateFormat(name) { | ||
const regex = /^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/ | ||
return regex.test(name) && !name.includes('--') | ||
} | ||
|
||
async checkAvailability(name) { | ||
try { | ||
const response = await fetch(`${this.checkUrlValue}?name=${encodeURIComponent(name)}`) | ||
if (!response.ok) throw new Error('Network response was not ok') | ||
const data = await response.json() | ||
return data.available | ||
} catch (error) { | ||
console.error('Error checking availability:', error) | ||
this.showMessage("Error checking availability", "error") | ||
throw error | ||
} | ||
} | ||
|
||
showMessage(message, type) { | ||
this.messageTarget.textContent = message | ||
this.messageTarget.classList.remove("hidden", this.errorClasses, this.successClasses) | ||
this.messageTarget.classList.add(type === "error" ? this.errorClasses : this.successClasses) | ||
} | ||
|
||
hideMessage() { | ||
this.messageTarget.classList.add("hidden") | ||
} | ||
|
||
disconnect() { | ||
if (this.timeout) { | ||
clearTimeout(this.timeout) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
class GithubRepositoryNameValidator | ||
VALID_FORMAT = /\A[a-zA-Z0-9][a-zA-Z0-9-]*(?<!-)\z/ | ||
|
||
def initialize(name, owner) | ||
@name = name.to_s | ||
@owner = owner.to_s | ||
end | ||
|
||
def valid? | ||
valid_format? && double_hyphen_free? && available? | ||
end | ||
|
||
private | ||
|
||
def valid_format? | ||
@name.match?(VALID_FORMAT) | ||
end | ||
|
||
def double_hyphen_free? | ||
!@name.include?("--") | ||
end | ||
|
||
def available? | ||
client = Octokit::Client.new(access_token: ENV["GITHUB_ACCESS_TOKEN"]) | ||
begin | ||
client.repository("#{@owner}/#{@name}") | ||
false # Repository exists | ||
rescue Octokit::NotFound | ||
true # Repository is available | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,28 @@ | ||
<div class="max-w-md mx-auto mt-8"> | ||
<h1 class="text-2xl font-bold mb-4">Create New Repository</h1> | ||
<%= form_with(model: [ @user, @repository ], class: "space-y-4") do |f| %> | ||
<div> | ||
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %> | ||
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %> | ||
</div> | ||
<%= f.submit "Create Repository", class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> | ||
<% end %> | ||
</div> | ||
<div data-controller="repository-name-validator" | ||
data-repository-name-validator-check-url-value="<%= repositories_check_name_path %>" | ||
data-repository-name-validator-debounce-value="500" | ||
data-repository-name-validator-error-class="text-red-600" | ||
data-repository-name-validator-success-class="text-green-600"> | ||
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %> | ||
<%= f.text_field :name, | ||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm", | ||
data: { | ||
repository_name_validator_target: "input", | ||
action: "input->repository-name-validator#validate" | ||
} %> | ||
<div class="mt-2 text-sm"> | ||
<div data-repository-name-validator-target="message" class="hidden"></div> | ||
<div data-repository-name-validator-target="spinner" class="hidden flex justify-center"> | ||
<svg class="animate-spin h-5 w-5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> | ||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> | ||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> | ||
</svg> | ||
</div> | ||
</div> | ||
</div> | ||
<%= f.submit "Create Repository", class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> | ||
<% end %> | ||
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,4 +25,6 @@ | |
end | ||
|
||
root to: "static#home" | ||
|
||
get "/repositories/check_name", to: "repositories#check_name" | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
require "test_helper" | ||
|
||
class GithubRepositoryNameValidatorTest < ActiveSupport::TestCase | ||
def setup | ||
@owner = "test-owner" | ||
end | ||
|
||
def test_valid_repository_name | ||
client = Minitest::Mock.new | ||
client.expect(:repository, nil) { raise Octokit::NotFound } | ||
|
||
Octokit::Client.stub(:new, client) do | ||
validator = GithubRepositoryNameValidator.new("valid-repo-name", @owner) | ||
assert validator.valid? | ||
end | ||
end | ||
|
||
def test_invalid_ending_with_hyphen | ||
validator = GithubRepositoryNameValidator.new("invalid-", @owner) | ||
assert_not validator.valid? | ||
end | ||
|
||
def test_invalid_starting_with_hyphen | ||
validator = GithubRepositoryNameValidator.new("-invalid", @owner) | ||
assert_not validator.valid? | ||
end | ||
|
||
def test_invalid_double_hyphen | ||
validator = GithubRepositoryNameValidator.new("invalid--name", @owner) | ||
assert_not validator.valid? | ||
end | ||
|
||
def test_invalid_empty_string | ||
validator = GithubRepositoryNameValidator.new("", @owner) | ||
assert_not validator.valid? | ||
end | ||
|
||
def test_invalid_special_characters | ||
validator = GithubRepositoryNameValidator.new("inv@lid", @owner) | ||
assert_not validator.valid? | ||
end | ||
|
||
def test_invalid_when_repository_exists | ||
client = Minitest::Mock.new | ||
client.expect(:repository, true, [ "#{@owner}/existing-repo" ]) | ||
|
||
Octokit::Client.stub(:new, client) do | ||
validator = GithubRepositoryNameValidator.new("existing-repo", @owner) | ||
assert_not validator.valid? | ||
end | ||
|
||
assert_mock client | ||
end | ||
|
||
def test_invalid_with_nil_name | ||
client = Minitest::Mock.new | ||
client.expect(:repository, nil) { raise Octokit::NotFound } | ||
|
||
Octokit::Client.stub(:new, client) do | ||
validator = GithubRepositoryNameValidator.new(nil, @owner) | ||
assert_not validator.valid? | ||
end | ||
end | ||
end |