Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reuse integrations from across apps #712

Merged
merged 14 commits into from
Jan 20, 2025
Merged
43 changes: 30 additions & 13 deletions app/components/integration_card_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
</h4>
</header>
</div>

<footer>
<div class="flex flex-col justify-start items-start space-y-5">
<div class="text-xs text-slate-500">
<% if connected? %>
<%= connection_data -%>
<% end %>
</div>

<% if connected? %>
<div class="flex justify-between w-full items-baseline">
<%= render BadgeComponent.new(text: "Connected", status: :success) %>
Expand All @@ -32,24 +34,39 @@
<% end %>
</div>
<% end %>


<% if disconnected? %>
<% if connectable? %>
<div class="flex gap-1">
<div class="flex gap-1">

<% if connectable? %>
<%= connectable_form_partial %>
<% if repeated_integration.present? %>
<%= reusable_integration_form_partial(repeated_integration) %>
<% end %>

<% if creatable? %>
<%= render ModalComponent.new(title: creatable_modal_title) do |modal| %>
<% modal.with_button(label: "Connect", scheme: :light, type: :action, size: :xxs, arrow: :none)
.with_icon("plus.svg", size: :md) %>
<% modal.with_body do %>
<%= creatable_form_partial %>
<% end %>
<% end %>
</div>
<% end %>
<% if creatable? %>
<%= render ModalComponent.new(title: creatable_modal_title) do |modal| %>
<% modal.with_button(label: "Connect", scheme: :light, type: :action, size: :xxs, arrow: :none)
.with_icon("plus.svg", size: :md) %>
<% modal.with_body do %>
<%= creatable_form_partial %>
<% end %>

<% if repeated_integrations_across_apps.present? %>
<% if repeated_integrations_across_apps.size > 1 %>
<%= render ModalComponent.new(title: "Choose the app to reuse the integration") do |modal| %>
<% modal.with_button(label: "Reuse existing integration", scheme: :supporting, type: :action, size: :xxs)
.with_icon("repeat.svg", size: :md) %>
<% modal.with_body do %>
<%= reusable_integrations_form_partial(repeated_integrations_across_apps) %>
<% end %>
<% end %>
<% else %>
<%= reusable_integration_form_partial(repeated_integrations_across_apps.sole) %>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
</div>
</footer>
Expand Down
29 changes: 22 additions & 7 deletions app/components/integration_card_component.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class IntegrationCardComponent < BaseComponent
include Memery

CONNECTABLE_PROVIDER_TO_TITLE = {
app_store: "API details",
bugsnag: "Auth Token",
Expand All @@ -19,18 +21,14 @@ def initialize(app, integration, category)
alias_method :provider, :providable
delegate :creatable?, :connectable?, to: :provider

def repeated_integration
Integration.existing_integration(@app, providable_type)
memoize def repeated_integrations_across_apps
Integration.existing_integrations_across_apps(@app, providable_type)
end

def connect_path
connect_app_integrations_path(@app, integration)
end

def reuse_existing_integration_path(existing_integration)
reuse_app_integration_path(@app, existing_integration)
end

def logo
image_tag("integrations/logo_#{provider}.png", width: 24, height: 24)
end
Expand All @@ -51,7 +49,24 @@ def connectable_form_partial

def reusable_integration_form_partial(existing_integration)
render(partial: "integrations/reusable",
locals: {app: @app, integration: @integration, category: @category, url: reuse_existing_integration_path(existing_integration), type: providable_type, provider: provider})
locals: {app: @app,
integration: @integration,
existing_integration: existing_integration,
category: @category,
url: reuse_app_integrations_path(@app),
type: providable_type,
provider: provider})
end

def reusable_integrations_form_partial(existing_integrations)
render(partial: "integrations/app_reuseable",
locals: {app: @app,
integration: @integration,
existing_integrations: existing_integrations,
category: @category,
url: reuse_app_integrations_path(@app),
type: providable_type,
provider: provider})
end

def disconnectable?
Expand Down
2 changes: 1 addition & 1 deletion app/components/option_cards_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<% options.each do |opt| %>
<li>
<%= form.radio_button(opt[:opt_name], opt[:opt_value], opt[:options]) %>
<label for="<%= opt[:id] %>" class="inline-flex items-center justify-between w-full p-3 text-secondary bg-white border border-main-200 rounded-lg cursor-pointer dark:hover:text-main-300 dark:border-main-700 dark:peer-checked:text-blue-700 peer-checked:border-blue-800 peer-checked:text-blue-800 hover:text-main-600 peer-checked:bg-main-100 hover:bg-main-100 dark:text-secondary-50 dark:bg-main-800 dark:peer-checked:bg-main-700 dark:hover:bg-main-700">
<label for="<%= opt[:id] %>" class="inline-flex items-center justify-between gap-1 w-full h-full p-3 text-secondary bg-white border border-main-200 rounded-lg cursor-pointer dark:hover:text-main-300 dark:border-main-700 dark:peer-checked:text-blue-700 peer-checked:border-blue-800 peer-checked:text-blue-800 hover:text-main-600 peer-checked:bg-main-100 hover:bg-main-100 dark:text-secondary-50 dark:bg-main-800 dark:peer-checked:bg-main-700 dark:hover:bg-main-700">
<div class="block">
<div class="w-full text-base font-semibold"><%= opt[:title] %></div>
<div class="w-full"><%= opt[:subtitle] %></div>
Expand Down
6 changes: 3 additions & 3 deletions app/controllers/integrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ def destroy
private

def initiate_integration(existing_integration)
@app.integrations.find_or_initialize_by(
@app.integrations.build(
category: @integration.category,
status: Integration.statuses[:connected],
metadata: existing_integration.metadata,
providable: existing_integration.providable
providable: existing_integration.providable.dup
)
end

Expand All @@ -79,7 +79,7 @@ def set_integration
end

def set_existing_integration
@existing_integration = Integration.find_by(id: params[:id])
@existing_integration = Integration.find_by(id: params[:integration][:existing_integration_id])
end

def set_providable
Expand Down
12 changes: 0 additions & 12 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,6 @@ def version_in_progress(version)
version.to_semverish.to_s(patch_glob: true)
end

def text_field_classes(is_disabled:)
if is_disabled
"form-input w-full disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-600 disabled:cursor-not-allowed"
else
"form-input w-full"
end
end

def ago_in_words(time, prefix: nil, suffix: "ago")
return "N/A" unless time
builder = ""
Expand Down Expand Up @@ -127,10 +119,6 @@ def time_format(timestamp, with_year: false, with_time: true, only_time: false,
timestamp.strftime("%b #{timestamp.day.ordinalize}#{", %Y" if with_year}#{" at %-l:%M %P" if with_time}")
end

def subtitle(text)
content_tag(:span, text, class: "text-sm text-slate-400")
end

def short_sha(sha)
sha[0, 7]
end
Expand Down
1 change: 1 addition & 0 deletions app/models/bitbucket_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def install_path
end

def complete_access
return if oauth_access_token.present? && oauth_refresh_token.present?
set_tokens(Installations::Bitbucket::Api.oauth_access_token(code, redirect_uri))
end

Expand Down
1 change: 1 addition & 0 deletions app/models/gitlab_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ def install_path
end

def complete_access
return if oauth_access_token.present? && oauth_refresh_token.present?
set_tokens(Installations::Gitlab::Api.oauth_access_token(code, redirect_uri))
end

Expand Down
2 changes: 1 addition & 1 deletion app/models/google_play_store_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class GooglePlayStoreIntegration < ApplicationRecord

attr_accessor :json_key_file

after_create :draft_check
after_create_commit :draft_check
after_create_commit :refresh_external_app

PROD_CHANNEL = {id: :production, name: "Production", is_production: true}.freeze
Expand Down
6 changes: 4 additions & 2 deletions app/models/integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,10 @@ def firebase_build_channel_provider
kept.build_channel.find(&:google_firebase_integration?)&.providable
end

def existing_integration(app, providable_type)
app.integrations.connected.find_by(providable_type: providable_type)
def existing_integrations_across_apps(app, providable_type)
Integration.connected
.where(integrable_id: app.organization.apps, providable_type: providable_type)
.select("DISTINCT ON (metadata) *")
end

def build_channels_for_platform(platform)
Expand Down
1 change: 1 addition & 0 deletions app/models/slack_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def install_path
end

def complete_access
return if oauth_access_token.present?
self.oauth_access_token = Installations::Slack::Api.oauth_access_token(code)
end

Expand Down
18 changes: 10 additions & 8 deletions app/views/apps/_form.html.erb
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<%= form_with(model: [app], builder: EnhancedFormHelper::AuthzForm) do |form| %>
<%= render FormComponent.new(model: [app], free_form: true) do |form| %>
<div class="grid gap-4 mb-4 sm:grid-cols-2">
<div><%= form.labeled_text_field :name, "Name" %></div>
<div><%= form.labeled_text_field :bundle_identifier, "Bundle Identifier" %></div>
<div><%= form.F.labeled_text_field :name, "Name" %></div>
<div><%= form.F.labeled_text_field :bundle_identifier, "Bundle Identifier" %></div>

<div class="sm:col-span-2"
data-controller="domain--build-number-help"
data-domain--build-number-help-number-current-value="">

<%= form.labeled_number_field :build_number,
<%= form.F.labeled_number_field :build_number,
"Build Number",
{ data: { domain__build_number_help_target: "input",
action: "domain--build-number-help#increment" } } %>
Expand All @@ -26,10 +26,12 @@
</div>
</div>

<div><%= form.labeled_select :platform, "Mobile Platform", options_for_select(App.allowed_platforms, "Android") %></div>
<div><%= form.labeled_tz_select :timezone, "Timezone", default_timezones, { model: ActiveSupport::TimeZone } %></div>
<div class="sm:col-span-2"><%= form.labeled_textarea :description, "Description" %></div>
<div><%= form.F.labeled_select :platform, "Mobile Platform", options_for_select(App.allowed_platforms, "Android") %></div>
<div><%= form.F.labeled_tz_select :timezone, "Timezone", default_timezones, { model: ActiveSupport::TimeZone } %></div>
<div class="sm:col-span-2"><%= form.F.labeled_textarea :description, "Description" %></div>
</div>

<%= form.authz_submit "Add an app", "plus.svg" %>
<% form.with_action do %>
<%= form.F.authz_submit "Add an app", "plus.svg" %>
<% end %>
<% end %>
21 changes: 21 additions & 0 deletions app/views/integrations/_app_reuseable.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<%= render FormComponent.new(model: [app, integration], url: url, method: :post, free_form: true) do |form| %>
<div class="grid grid-cols-1 gap-4 mt-4 p-4">
<% integrations_options = existing_integrations.each_with_index.map { |integration, i|
{
title: integration.app.name,
subtitle: integration.providable.connection_data,
icon: "integrations/logo_#{provider}.png",
opt_name: :existing_integration_id,
opt_value: integration.id,
options: {checked: i == 0}
}
} %>
<%= render OptionCardsComponent.new(form: form.F, options: integrations_options) %>
<%= form.F.hidden_field :category, value: category %>
<%= form.F.hidden_field "providable[type]", value: type %>
</div>

<% form.with_action do %>
<%= form.F.authz_submit "Save", "archive.svg", size: :sm %>
<% end %>
<% end %>
5 changes: 2 additions & 3 deletions app/views/integrations/_reusable.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<%= form_with(model: [app, integration], method: :post, builder: EnhancedFormHelper::AuthzForm, url:, data: { turbo: false }) do |form| %>
<%= form.hidden_field :category, value: category %>
<%= form.fields_for :providable do |subform| %>
<%= subform.hidden_field :type, value: type %>
<% end %>
<%= form.hidden_field :existing_integration_id, value: existing_integration.id %>
<%= form.hidden_field "providable[type]", value: type %>
<%= form.authz_submit "Reuse existing integration", "repeat.svg", scheme: :supporting, size: :xxs %>
<% end %>
10 changes: 0 additions & 10 deletions app/views/trains/_versioning_strategy.html.erb

This file was deleted.

4 changes: 1 addition & 3 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,8 @@
end

resources :integrations, only: %i[index create destroy] do
member do
post :reuse
end
collection do
post :reuse
get :connect, to: "integrations#connect", as: :connect

resource :google_play_store, only: [:create],
Expand Down
14 changes: 5 additions & 9 deletions spec/controllers/integrations_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,30 @@
let(:organization) { app.organization }
let(:user) { create(:user, :with_email_authentication, :as_developer, member_organization: organization) }
let(:existing_integration) { create(:integration, status: "connected", category: "version_control", providable: create(:github_integration), integrable: app, metadata: {id: Faker::Number.number(digits: 8)}) }
let(:integration) { create(:integration, category: "ci_cd", providable: create(:github_integration), integrable: app) }
let(:integration) { build(:integration, category: "ci_cd", providable: create(:github_integration), integrable: app) }

before do
sign_in user.email_authentication
allow_any_instance_of(described_class).to receive(:current_user).and_return(user)
allow_any_instance_of(described_class).to receive(:set_integration)
end

describe "POST #reuse" do
context "when the existing integration is not connected or does not exist" do
it "redirects to the integrations path with an error message" do
post :reuse, params: {id: Faker::Internet.uuid, app_id: app.id}
existing_integration.update(status: "disconnected")
post :reuse, params: {integration: {existing_integration_id: existing_integration.id}, app_id: app.id}

expect(response).to redirect_to(app_integrations_path(app))
expect(flash[:alert]).to eq("Integration not found or not connected.")
end
end

context "when the existing integration is connected" do
before do
allow(Integration).to receive(:find_by).with(id: existing_integration.id.to_s).and_return(existing_integration)
end

it "reuses the integration and redirects to the integrations path with a success message" do
new_integration = instance_double(Integration, save: true)
allow(controller).to receive(:initiate_integration).and_return(new_integration)

post :reuse, params: {id: existing_integration.id, app_id: app.id}
post :reuse, params: {integration: {existing_integration_id: existing_integration.id}, app_id: app.id}

expect(response).to redirect_to(app_integrations_path(app))
expect(flash[:notice]).to eq("#{existing_integration.providable_type} integration reused successfully.")
Expand All @@ -42,7 +38,7 @@
new_integration = instance_double(Integration, save: false, errors: instance_double(ActiveModel::Errors, full_messages: ["Save failed"]))
allow(controller).to receive(:initiate_integration).and_return(new_integration)

post :reuse, params: {id: existing_integration.id, app_id: app.id}
post :reuse, params: {integration: {existing_integration_id: existing_integration.id}, app_id: app.id}

expect(response).to redirect_to(app_integrations_path(app))
expect(flash[:error]).to eq("Save failed")
Expand Down
Loading