From c9bf315b227a2c2fbe72d7e033ab26f3c77ecf62 Mon Sep 17 00:00:00 2001 From: Trinity Takei Date: Fri, 22 Nov 2024 21:24:09 +0100 Subject: [PATCH] Feature/milestone 1/repo name validation (#121) * [FEAT] GitHub repository name validator --- app/controllers/repositories_controller.rb | 9 +- .../repository_name_validator_controller.js | 90 +++++++++++++++++++ .../github_repository_name_validator.rb | 32 +++++++ app/views/repositories/new.html.erb | 32 +++++-- config/routes.rb | 2 + .../github_repository_name_validator_test.rb | 64 +++++++++++++ 6 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 app/javascript/controllers/repository_name_validator_controller.js create mode 100644 app/services/github_repository_name_validator.rb create mode 100644 test/services/github_repository_name_validator_test.rb diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb index a0aab43..f831645 100644 --- a/app/controllers/repositories_controller.rb +++ b/app/controllers/repositories_controller.rb @@ -1,7 +1,7 @@ # app/controllers/repositories_controller.rb class RepositoriesController < ApplicationController before_action :authenticate_user! - before_action :set_user + before_action :set_user, except: [ :check_name ] def index @repositories = @user.repositories @@ -22,6 +22,13 @@ def create end end + def check_name + validator = GithubRepositoryNameValidator.new( + params[:name], + current_user.github_username + ) + render json: { available: validator.valid? } + end private def set_user diff --git a/app/javascript/controllers/repository_name_validator_controller.js b/app/javascript/controllers/repository_name_validator_controller.js new file mode 100644 index 0000000..d81719a --- /dev/null +++ b/app/javascript/controllers/repository_name_validator_controller.js @@ -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) + } + } +} diff --git a/app/services/github_repository_name_validator.rb b/app/services/github_repository_name_validator.rb new file mode 100644 index 0000000..44a9177 --- /dev/null +++ b/app/services/github_repository_name_validator.rb @@ -0,0 +1,32 @@ +class GithubRepositoryNameValidator + VALID_FORMAT = /\A[a-zA-Z0-9][a-zA-Z0-9-]*(?

Create New Repository

<%= form_with(model: [ @user, @repository ], class: "space-y-4") do |f| %> -
- <%= 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" %> -
- <%= 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 %> - +
+ <%= 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" + } %> +
+ + +
+
+ <%= 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 %> + diff --git a/config/routes.rb b/config/routes.rb index 326698f..336f6a9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,4 +25,6 @@ end root to: "static#home" + + get "/repositories/check_name", to: "repositories#check_name" end diff --git a/test/services/github_repository_name_validator_test.rb b/test/services/github_repository_name_validator_test.rb new file mode 100644 index 0000000..5e0494a --- /dev/null +++ b/test/services/github_repository_name_validator_test.rb @@ -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