mirror of
https://github.com/czhu12/canine.git
synced 2025-12-30 07:39:43 -06:00
added some better ux
This commit is contained in:
18
app/actions/add_ons/filter.rb
Normal file
18
app/actions/add_ons/filter.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AddOns
|
||||
class Filter
|
||||
extend LightService::Action
|
||||
|
||||
expects :params, :add_ons
|
||||
promises :add_ons
|
||||
|
||||
executed do |context|
|
||||
query = context.params[:q].to_s.strip
|
||||
|
||||
if query.present?
|
||||
context.add_ons = context.add_ons.where("add_ons.name ILIKE ?", "%#{query}%")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
14
app/actions/add_ons/list.rb
Normal file
14
app/actions/add_ons/list.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AddOns
|
||||
class List
|
||||
extend LightService::Organizer
|
||||
|
||||
def self.call(account_user:, params: {})
|
||||
with(account_user: account_user, params: params).reduce(
|
||||
AddOns::VisibleToUser,
|
||||
AddOns::Filter
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,12 +4,19 @@ module AddOns
|
||||
class VisibleToUser
|
||||
extend LightService::Action
|
||||
|
||||
expects :user, :account
|
||||
expects :account_user
|
||||
promises :add_ons
|
||||
|
||||
executed do |context|
|
||||
user = context.user
|
||||
account = context.account
|
||||
account_user = context.account_user
|
||||
user = account_user.user
|
||||
account = account_user.account
|
||||
|
||||
# Admins can see all add_ons in the account
|
||||
if account_user.admin?
|
||||
context.add_ons = AddOn.joins(:cluster).where(clusters: { account_id: account.id })
|
||||
next context
|
||||
end
|
||||
|
||||
# If account has no teams, user can see all add_ons
|
||||
if account.teams.empty?
|
||||
|
||||
18
app/actions/clusters/filter.rb
Normal file
18
app/actions/clusters/filter.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clusters
|
||||
class Filter
|
||||
extend LightService::Action
|
||||
|
||||
expects :params, :clusters
|
||||
promises :clusters
|
||||
|
||||
executed do |context|
|
||||
query = context.params[:q].to_s.strip
|
||||
|
||||
if query.present?
|
||||
context.clusters = context.clusters.where("clusters.name ILIKE ?", "%#{query}%")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
14
app/actions/clusters/list.rb
Normal file
14
app/actions/clusters/list.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clusters
|
||||
class List
|
||||
extend LightService::Organizer
|
||||
|
||||
def self.call(account_user:, params: {})
|
||||
with(account_user: account_user, params: params).reduce(
|
||||
Clusters::VisibleToUser,
|
||||
Clusters::Filter
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,12 +4,19 @@ module Clusters
|
||||
class VisibleToUser
|
||||
extend LightService::Action
|
||||
|
||||
expects :user, :account
|
||||
expects :account_user
|
||||
promises :clusters
|
||||
|
||||
executed do |context|
|
||||
user = context.user
|
||||
account = context.account
|
||||
account_user = context.account_user
|
||||
user = account_user.user
|
||||
account = account_user.account
|
||||
|
||||
# Admins can see all clusters in the account
|
||||
if account_user.admin?
|
||||
context.clusters = Cluster.where(account_id: account.id)
|
||||
next context
|
||||
end
|
||||
|
||||
# If account has no teams, user can see all clusters
|
||||
if account.teams.empty?
|
||||
|
||||
18
app/actions/projects/filter.rb
Normal file
18
app/actions/projects/filter.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Projects
|
||||
class Filter
|
||||
extend LightService::Action
|
||||
|
||||
expects :params, :projects
|
||||
promises :projects
|
||||
|
||||
executed do |context|
|
||||
query = context.params[:q].to_s.strip
|
||||
|
||||
if query.present?
|
||||
context.projects = context.projects.where("projects.name ILIKE ?", "%#{query}%")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
14
app/actions/projects/list.rb
Normal file
14
app/actions/projects/list.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Projects
|
||||
class List
|
||||
extend LightService::Organizer
|
||||
|
||||
def self.call(account_user:, params: {})
|
||||
with(account_user: account_user, params: params).reduce(
|
||||
Projects::VisibleToUser,
|
||||
Projects::Filter
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,12 +4,19 @@ module Projects
|
||||
class VisibleToUser
|
||||
extend LightService::Action
|
||||
|
||||
expects :user, :account
|
||||
expects :account_user
|
||||
promises :projects
|
||||
|
||||
executed do |context|
|
||||
user = context.user
|
||||
account = context.account
|
||||
account_user = context.account_user
|
||||
user = account_user.user
|
||||
account = account_user.account
|
||||
|
||||
# Admins can see all projects in the account
|
||||
if account_user.admin?
|
||||
context.projects = Project.joins(:cluster).where(clusters: { account_id: account.id })
|
||||
next context
|
||||
end
|
||||
|
||||
# If account has no teams, user can see all projects
|
||||
if account.teams.empty?
|
||||
|
||||
@@ -8,6 +8,7 @@ class Accounts::TeamsController < ApplicationController
|
||||
|
||||
def show
|
||||
@pagy, @team_memberships = pagy(@team.team_memberships)
|
||||
@tab = params[:tab] || "clusters"
|
||||
end
|
||||
|
||||
def new
|
||||
|
||||
@@ -4,9 +4,14 @@ class AddOnsController < ApplicationController
|
||||
|
||||
# GET /add_ons
|
||||
def index
|
||||
add_ons = AddOns::VisibleToUser.execute(user: current_user, account: current_account).add_ons
|
||||
add_ons = AddOns::List.call(account_user: current_account_user, params: params).add_ons
|
||||
@pagy, @add_ons = pagy(add_ons)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json { render json: @add_ons.map { |a| { id: a.id, name: a.name } } }
|
||||
end
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @add_ons
|
||||
end
|
||||
@@ -120,7 +125,7 @@ class AddOnsController < ApplicationController
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_add_on
|
||||
add_ons = AddOns::VisibleToUser.execute(user: current_user, account: current_account).add_ons
|
||||
add_ons = AddOns::VisibleToUser.execute(account_user: current_account_user).add_ons
|
||||
@add_on = add_ons.find(params[:id])
|
||||
@service = K8::Helm::Service.create_from_add_on(K8::Connection.new(@add_on, current_user))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
|
||||
@@ -23,6 +23,12 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
helper_method :current_account
|
||||
|
||||
def current_account_user
|
||||
return nil unless user_signed_in? && current_account
|
||||
@current_account_user ||= AccountUser.find_by(user: current_user, account: current_account)
|
||||
end
|
||||
helper_method :current_account_user
|
||||
|
||||
def time_ago(t)
|
||||
if t.present?
|
||||
"#{time_ago_in_words(t)} ago"
|
||||
|
||||
@@ -8,9 +8,14 @@ class ClustersController < ApplicationController
|
||||
# GET /clusters
|
||||
def index
|
||||
sortable_column = params[:sort] || "created_at"
|
||||
clusters = Clusters::VisibleToUser.execute(user: current_user, account: current_account).clusters
|
||||
clusters = Clusters::List.call(account_user: current_account_user, params: params).clusters
|
||||
@pagy, @clusters = pagy(clusters.order(sortable_column => "asc"))
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json { render json: @clusters.map { |c| { id: c.id, name: c.name } } }
|
||||
end
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @clusters
|
||||
end
|
||||
@@ -172,7 +177,7 @@ class ClustersController < ApplicationController
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_cluster
|
||||
clusters = Clusters::VisibleToUser.execute(user: current_user, account: current_account).clusters
|
||||
clusters = Clusters::VisibleToUser.execute(account_user: current_account_user).clusters
|
||||
@cluster = clusters.find(params[:id])
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
|
||||
@@ -6,9 +6,14 @@ class ProjectsController < ApplicationController
|
||||
# GET /projects
|
||||
def index
|
||||
sortable_column = params[:sort] || "created_at"
|
||||
projects = Projects::VisibleToUser.execute(user: current_user, account: current_account).projects
|
||||
projects = Projects::List.call(account_user: current_account_user, params: params).projects
|
||||
@pagy, @projects = pagy(projects.order(sortable_column => "asc"))
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json { render json: @projects.map { |p| { id: p.id, name: p.name } } }
|
||||
end
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @projects
|
||||
end
|
||||
@@ -87,7 +92,7 @@ class ProjectsController < ApplicationController
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_project
|
||||
projects = Projects::VisibleToUser.execute(user: current_user, account: current_account).projects
|
||||
projects = Projects::VisibleToUser.execute(account_user: current_account_user).projects
|
||||
@project = projects.find(params[:id])
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
|
||||
@@ -48,7 +48,7 @@ export default class extends Controller {
|
||||
|
||||
createDropdown() {
|
||||
const dropdown = document.createElement('ul')
|
||||
dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-base-200 block rounded-box shadow-lg max-h-[300px] overflow-y-auto'
|
||||
dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-neutral block rounded-box shadow-lg max-h-[300px] overflow-y-auto'
|
||||
return dropdown
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import AsyncSearchDropdownController from "./components/async_search_dropdown_controller"
|
||||
|
||||
export default class extends AsyncSearchDropdownController {
|
||||
static values = {
|
||||
url: String,
|
||||
addUrl: String,
|
||||
resourceType: String,
|
||||
turboFrame: String
|
||||
}
|
||||
|
||||
async fetchResults(query) {
|
||||
const url = `${this.urlValue}.json?q=${encodeURIComponent(query)}`
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search resources')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
renderItem(resource) {
|
||||
return `
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">${this.escapeHtml(resource.name)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async onItemSelect(resource, itemElement) {
|
||||
try {
|
||||
itemElement.classList.add('opacity-50')
|
||||
itemElement.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Adding ${this.escapeHtml(resource.name)}...</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
const response = await fetch(this.addUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
resourceable_type: this.resourceTypeValue,
|
||||
resourceable_id: resource.id
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add resource')
|
||||
}
|
||||
|
||||
// Close the modal
|
||||
const modal = this.element.closest('dialog')
|
||||
if (modal) {
|
||||
modal.close()
|
||||
}
|
||||
|
||||
// Reload the turbo frame
|
||||
if (this.hasTurboFrameValue) {
|
||||
const frame = document.getElementById(this.turboFrameValue)
|
||||
if (frame) {
|
||||
frame.reload()
|
||||
}
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding resource:', error)
|
||||
alert('Failed to add resource. Please try again.')
|
||||
itemElement.classList.remove('opacity-50')
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,8 @@
|
||||
class AccountUser < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :account
|
||||
|
||||
def admin?
|
||||
account.owner_id == user_id
|
||||
end
|
||||
end
|
||||
|
||||
49
app/views/accounts/teams/_empty.html.erb
Normal file
49
app/views/accounts/teams/_empty.html.erb
Normal file
@@ -0,0 +1,49 @@
|
||||
<div class="card card-bordered bg-base-100">
|
||||
<div class="card-body text-center py-16">
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<h2 class="text-3xl font-bold">Organize access with teams</h2>
|
||||
<p class="text-base-content/70 max-w-2xl">
|
||||
Teams let you group members and control which resources they can access. Perfect for managing permissions as your organization scales.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mt-8 max-w-4xl">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center">
|
||||
<iconify-icon icon="lucide:shield-check" class="text-2xl text-primary"></iconify-icon>
|
||||
</div>
|
||||
<h3 class="font-semibold text-lg">Granular permissions</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Assign clusters, projects, and add-ons to specific teams. Members only see what they need.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center">
|
||||
<iconify-icon icon="lucide:user-plus" class="text-2xl text-primary"></iconify-icon>
|
||||
</div>
|
||||
<h3 class="font-semibold text-lg">Simple onboarding</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Add someone to a team and they instantly get access to all the right resources. No manual setup required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center">
|
||||
<iconify-icon icon="lucide:folder-tree" class="text-2xl text-primary"></iconify-icon>
|
||||
</div>
|
||||
<h3 class="font-semibold text-lg">Scale with confidence</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Structure teams by role, project, or however works best. Easily adjust as your organization evolves.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-8">
|
||||
<%= link_to new_team_path, class: "btn btn-primary" do %>
|
||||
<iconify-icon icon="lucide:plus"></iconify-icon>
|
||||
Create your first team
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,40 +1,55 @@
|
||||
<div class="overflow-auto">
|
||||
<table class="table mt-2 rounded-box" data-component="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="text-sm font-medium text-base-content/80">Name</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="text-sm font-medium text-base-content/80">Members</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="text-sm font-medium text-base-content/80">Resources</span>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% teams.each do |team| %>
|
||||
<tr class="cursor-pointer hover:bg-base-200/40" onclick="window.location='<%= team_path(team) %>'">
|
||||
<td>
|
||||
<div class="font-medium"><%= team.name %></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium"><%= team.users.count %></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium"><%= team.team_resources.count %></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm capitalize">
|
||||
<%= button_to team_path(team), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %>
|
||||
<iconify-icon icon="lucide:trash" height="18" class="text-error"></iconify-icon>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% if teams.empty? %>
|
||||
<%= render "accounts/teams/empty" %>
|
||||
<% else %>
|
||||
<div aria-label="Card" class="card card-bordered bg-base-100">
|
||||
<div class="card-body">
|
||||
<div class="text-right px-5 pt-5">
|
||||
<%= link_to new_team_path, class: "btn btn-primary btn-sm" do %>
|
||||
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
|
||||
<span class="hidden sm:inline">Create Team</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto">
|
||||
<table class="table mt-2 rounded-box" data-component="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="text-sm font-medium text-base-content/80">Name</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="text-sm font-medium text-base-content/80">Members</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="text-sm font-medium text-base-content/80">Resources</span>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% teams.each do |team| %>
|
||||
<tr class="cursor-pointer hover:bg-base-200/40" onclick="window.location='<%= team_path(team) %>'">
|
||||
<td>
|
||||
<div class="font-medium"><%= team.name %></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium"><%= team.users.count %></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium"><%= team.team_resources.count %></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm capitalize">
|
||||
<%= button_to team_path(team), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %>
|
||||
<iconify-icon icon="lucide:trash" height="18" class="text-error"></iconify-icon>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
29
app/views/accounts/teams/_resource_content.html.erb
Normal file
29
app/views/accounts/teams/_resource_content.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<%= turbo_frame_tag("team_resources_#{team.id}", data: { turbo_tabs_target: "content" }) do %>
|
||||
<div class="pt-4">
|
||||
<% if tab == "clusters" %>
|
||||
<div class="flex justify-end mb-2">
|
||||
<button class="btn btn-primary btn-sm" onclick="add_cluster_modal.showModal()">
|
||||
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
|
||||
<span class="hidden sm:inline">Add Cluster</span>
|
||||
</button>
|
||||
</div>
|
||||
<%= render "accounts/teams/team_resources_table", team: team, resource_type: "Cluster" %>
|
||||
<% elsif tab == "projects" %>
|
||||
<div class="flex justify-end mb-2">
|
||||
<button class="btn btn-primary btn-sm" onclick="add_project_modal.showModal()">
|
||||
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
|
||||
<span class="hidden sm:inline">Add Project</span>
|
||||
</button>
|
||||
</div>
|
||||
<%= render "accounts/teams/team_resources_table", team: team, resource_type: "Project" %>
|
||||
<% elsif tab == "addons" %>
|
||||
<div class="flex justify-end mb-2">
|
||||
<button class="btn btn-primary btn-sm" onclick="add_addon_modal.showModal()">
|
||||
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
|
||||
<span class="hidden sm:inline">Add Add-on</span>
|
||||
</button>
|
||||
</div>
|
||||
<%= render "accounts/teams/team_resources_table", team: team, resource_type: "AddOn" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
11
app/views/accounts/teams/_resource_tabs.html.erb
Normal file
11
app/views/accounts/teams/_resource_tabs.html.erb
Normal file
@@ -0,0 +1,11 @@
|
||||
<div role="tablist" class="tabs tabs-bordered" data-turbo-tabs-target="tabs">
|
||||
<%= link_to team_path(team, tab: 'clusters'), class: "tab #{'tab-active' if tab == 'clusters'}" do %>
|
||||
Clusters
|
||||
<% end %>
|
||||
<%= link_to team_path(team, tab: 'projects'), class: "tab #{'tab-active' if tab == 'projects'}" do %>
|
||||
Projects
|
||||
<% end %>
|
||||
<%= link_to team_path(team, tab: 'addons'), class: "tab #{'tab-active' if tab == 'addons'}" do %>
|
||||
Add-ons
|
||||
<% end %>
|
||||
</div>
|
||||
37
app/views/accounts/teams/_team_resources_table.html.erb
Normal file
37
app/views/accounts/teams/_team_resources_table.html.erb
Normal file
@@ -0,0 +1,37 @@
|
||||
<% resources = team.team_resources.where(resourceable_type: resource_type) %>
|
||||
<div class="overflow-auto">
|
||||
<table class="table mt-2 rounded-box" data-component="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="text-sm font-medium text-base-content/80">Name</span>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if resources.empty? %>
|
||||
<tr>
|
||||
<td colspan="2" class="text-center text-base-content/50 py-8">
|
||||
No <%= resource_type.underscore.humanize.downcase.pluralize %> assigned to this team yet.
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
<% resources.each do |team_resource| %>
|
||||
<tr class="hover:bg-base-200/40">
|
||||
<td>
|
||||
<div class="font-medium"><%= team_resource.resourceable.name %></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm capitalize">
|
||||
<%= button_to team_team_resource_path(team, team_resource), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-square btn-sm btn-danger min-w-max" do %>
|
||||
<iconify-icon icon="lucide:trash" height="18" class="text-error"></iconify-icon>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -32,24 +32,9 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<div aria-label="Card" class="card card-bordered bg-base-100">
|
||||
<div class="card-body">
|
||||
<div class="text-right px-5 pt-5">
|
||||
<%= link_to new_team_path, class: "btn btn-primary btn-sm" do %>
|
||||
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
|
||||
<span class="hidden sm:inline">Create Team</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= tag.div id: ("teams" if @pagy.page == 1) do %>
|
||||
<%= render "accounts/teams/list", teams: @teams, cached: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= tag.div id: "teams" do %>
|
||||
<%= render "accounts/teams/list", teams: @teams %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render 'shared/pagination', pagy: @pagy %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
<%= turbo_stream_from [:team_memberships, @team] %>
|
||||
|
||||
<div>
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
<li>
|
||||
<%= link_to teams_path, class: "inline-flex items-center gap-1" do %>
|
||||
<iconify-icon icon="lucide:users" height="14"></iconify-icon>
|
||||
Teams
|
||||
<% end %>
|
||||
</li>
|
||||
<li><%= @team.name %></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
@@ -54,16 +66,10 @@
|
||||
<div class="mt-5">
|
||||
<div aria-label="Card" class="card card-bordered bg-base-100">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center px-5 pt-5">
|
||||
<h3 class="text-lg font-semibold">Team Resources</h3>
|
||||
<button class="btn btn-primary btn-sm" onclick="add_team_resource_modal.showModal()">
|
||||
<iconify-icon icon="lucide:plus" height="16"></iconify-icon>
|
||||
<span class="hidden sm:inline">Grant Access</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= render "accounts/teams/team_resources_list", team: @team, cached: true %>
|
||||
<div class="px-5 pt-5" data-controller="turbo-tabs">
|
||||
<h3 class="text-lg font-semibold mb-4">Team Resources</h3>
|
||||
<%= render "accounts/teams/resource_tabs", team: @team, tab: @tab %>
|
||||
<%= render "accounts/teams/resource_content", team: @team, tab: @tab %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,45 +109,104 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Add Team Resource Modal -->
|
||||
<dialog aria-label="Modal" class="modal" id="add_team_resource_modal">
|
||||
<!-- Add Cluster Modal -->
|
||||
<dialog aria-label="Modal" class="modal" id="add_cluster_modal">
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button aria-label="Close modal" class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2">
|
||||
<iconify-icon icon="lucide:x" height="16"></iconify-icon>
|
||||
</button>
|
||||
</form>
|
||||
<div class="mb-4 w-full text-xl font-bold">Grant Resource Access to <%= @team.name %></div>
|
||||
<div class="mb-4 w-full text-xl font-bold">Add Cluster to <%= @team.name %></div>
|
||||
<div>
|
||||
<%= form_with(url: team_team_resources_path(@team)) do |form| %>
|
||||
<div class="form-control mt-1 w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Resource Type</span>
|
||||
</label>
|
||||
<%= form.select :resourceable_type,
|
||||
[["Cluster", "Cluster"], ["Project", "Project"], ["Add-on", "AddOn"]],
|
||||
{ prompt: "Select resource type" },
|
||||
class: "select select-bordered w-full focus:outline-offset-0",
|
||||
data: { action: "change->team-resource#updateResourceOptions" } %>
|
||||
<div class="form-control mt-1 w-full"
|
||||
data-controller="team-resource-search"
|
||||
data-team-resource-search-url-value="<%= clusters_path %>"
|
||||
data-team-resource-search-add-url-value="<%= team_team_resources_path(@team) %>"
|
||||
data-team-resource-search-resource-type-value="Cluster"
|
||||
data-team-resource-search-turbo-frame-value="team_resources_<%= @team.id %>">
|
||||
<label class="label">
|
||||
<span class="label-text">Search for a cluster</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
placeholder="Type to search clusters..."
|
||||
class="input input-bordered w-full focus:outline-offset-0" />
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-warning">Adding a cluster will enable all projects and add-ons within that cluster.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div class="form-control mt-1 w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Resource</span>
|
||||
</label>
|
||||
<%= form.select :resourceable_id,
|
||||
[],
|
||||
{ prompt: "Select a resource" },
|
||||
class: "select select-bordered w-full focus:outline-offset-0",
|
||||
id: "resourceable_id_select" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Grant access to a specific resource.</span>
|
||||
</label>
|
||||
<!-- Add Project Modal -->
|
||||
<dialog aria-label="Modal" class="modal" id="add_project_modal">
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button aria-label="Close modal" class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2">
|
||||
<iconify-icon icon="lucide:x" height="16"></iconify-icon>
|
||||
</button>
|
||||
</form>
|
||||
<div class="mb-4 w-full text-xl font-bold">Add Project to <%= @team.name %></div>
|
||||
<div>
|
||||
<div class="form-control mt-1 w-full"
|
||||
data-controller="team-resource-search"
|
||||
data-team-resource-search-url-value="<%= projects_path %>"
|
||||
data-team-resource-search-add-url-value="<%= team_team_resources_path(@team) %>"
|
||||
data-team-resource-search-resource-type-value="Project"
|
||||
data-team-resource-search-turbo-frame-value="team_resources_<%= @team.id %>">
|
||||
<label class="label">
|
||||
<span class="label-text">Search for a project</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
placeholder="Type to search projects..."
|
||||
class="input input-bordered w-full focus:outline-offset-0" />
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<%= form.button "Grant Access", class: "btn btn-primary" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Grant this team access to the selected project.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Add Add-on Modal -->
|
||||
<dialog aria-label="Modal" class="modal" id="add_addon_modal">
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button aria-label="Close modal" class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2">
|
||||
<iconify-icon icon="lucide:x" height="16"></iconify-icon>
|
||||
</button>
|
||||
</form>
|
||||
<div class="mb-4 w-full text-xl font-bold">Add Add-on to <%= @team.name %></div>
|
||||
<div>
|
||||
<div class="form-control mt-1 w-full"
|
||||
data-controller="team-resource-search"
|
||||
data-team-resource-search-url-value="<%= add_ons_path %>"
|
||||
data-team-resource-search-add-url-value="<%= team_team_resources_path(@team) %>"
|
||||
data-team-resource-search-resource-type-value="AddOn"
|
||||
data-team-resource-search-turbo-frame-value="team_resources_<%= @team.id %>">
|
||||
<label class="label">
|
||||
<span class="label-text">Search for an add-on</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
placeholder="Type to search add-ons..."
|
||||
class="input input-bordered w-full focus:outline-offset-0" />
|
||||
</div>
|
||||
<% end %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Grant this team access to the selected add-on.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
|
||||
@@ -73,7 +73,9 @@
|
||||
<% end %>
|
||||
</summary>
|
||||
<ul>
|
||||
<% current_account.projects.order(created_at: :desc).each do |project| %>
|
||||
<% Projects::VisibleToUser.execute(
|
||||
account_user: current_account_user
|
||||
).projects.order(created_at: :desc).each do |project| %>
|
||||
<li>
|
||||
<%= link_to root_projects_path(project), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(project_path(project))}" do %>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -104,7 +106,9 @@
|
||||
<% end %>
|
||||
</summary>
|
||||
<ul>
|
||||
<% current_account.clusters.order(created_at: :desc).each do |cluster| %>
|
||||
<% Clusters::VisibleToUser.execute(
|
||||
account_user: current_account_user
|
||||
).clusters.order(created_at: :desc).each do |cluster| %>
|
||||
<li>
|
||||
<%= link_to cluster_path(cluster), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(cluster_path(cluster))}" do %>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -135,7 +139,9 @@
|
||||
<% end %>
|
||||
</summary>
|
||||
<ul>
|
||||
<% current_account.add_ons.order(created_at: :desc).each do |add_on| %>
|
||||
<% AddOns::VisibleToUser.execute(
|
||||
account_user: current_account_user
|
||||
).add_ons.order(created_at: :desc).each do |add_on| %>
|
||||
<li>
|
||||
<%= link_to add_on_path(add_on), class: "hover:bg-base-content/15 #{'active' if request.path.start_with?(add_on_path(add_on))}" do %>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<% if content_for?(:title) %>
|
||||
<%= yield :title %> |
|
||||
<% end %>
|
||||
Canine - An open source alternative to Heroku
|
||||
Canine - A Developer-friendly PaaS for your Kubernetes
|
||||
</title>
|
||||
<meta name="description" content="Canine is an open source deployment platform that makes it easy to deploy and manage your applications.">
|
||||
|
||||
<% if content_for?(:og_title) %>
|
||||
<meta property="og:title" content="<%= yield :og_title %>" />
|
||||
<% else %>
|
||||
<meta property="og:title" content="Canine - An open source alternative to Heroku" />
|
||||
<meta property="og:title" content="Canine - A Developer-friendly PaaS for your Kubernetes" />
|
||||
<% end %>
|
||||
|
||||
<% if content_for?(:og_url) %>
|
||||
|
||||
@@ -3,19 +3,38 @@ require 'rails_helper'
|
||||
RSpec.describe AddOns::VisibleToUser do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user) }
|
||||
let!(:account_user) { AccountUser.find_by(user: user, account: account) || create(:account_user, user: user, account: account) }
|
||||
let!(:cluster) { create(:cluster, account: account) }
|
||||
|
||||
before do
|
||||
account.users << user
|
||||
account.users << user unless account.users.include?(user)
|
||||
end
|
||||
|
||||
describe '.execute' do
|
||||
context 'when user is an admin (account owner)' do
|
||||
let!(:add_on1) { create(:add_on, cluster: cluster) }
|
||||
let!(:add_on2) { create(:add_on, cluster: cluster) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
|
||||
before do
|
||||
account.update!(owner: user)
|
||||
team # Create team so account has teams
|
||||
end
|
||||
|
||||
it 'returns all add_ons in the account regardless of team membership' do
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.add_ons).to match_array([ add_on1, add_on2 ])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has no teams' do
|
||||
let!(:add_on1) { create(:add_on, cluster: cluster) }
|
||||
let!(:add_on2) { create(:add_on, cluster: cluster) }
|
||||
|
||||
it 'returns all add_ons in the account' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.add_ons).to match_array([ add_on1, add_on2 ])
|
||||
@@ -35,7 +54,7 @@ RSpec.describe AddOns::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns no add_ons' do
|
||||
result = described_class.execute(user: user, account: account.reload)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.add_ons).to be_empty
|
||||
@@ -49,7 +68,7 @@ RSpec.describe AddOns::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns only add_ons granted to user teams' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.add_ons).to eq([ add_on1 ])
|
||||
@@ -67,7 +86,7 @@ RSpec.describe AddOns::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns all add_ons in the granted cluster' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.add_ons).to match_array([ add_on4, add_on5 ])
|
||||
@@ -85,7 +104,7 @@ RSpec.describe AddOns::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns all accessible add_ons without duplicates' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.add_ons).to match_array([ add_on1, add_on4 ])
|
||||
@@ -101,7 +120,7 @@ RSpec.describe AddOns::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns add_ons from all teams user belongs to' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.add_ons).to match_array([ add_on1, add_on2 ])
|
||||
|
||||
@@ -3,18 +3,37 @@ require 'rails_helper'
|
||||
RSpec.describe Clusters::VisibleToUser do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user) }
|
||||
let!(:account_user) { AccountUser.find_by(user: user, account: account) || create(:account_user, user: user, account: account) }
|
||||
|
||||
before do
|
||||
account.users << user
|
||||
account.users << user unless account.users.include?(user)
|
||||
end
|
||||
|
||||
describe '.execute' do
|
||||
context 'when user is an admin (account owner)' do
|
||||
let!(:cluster1) { create(:cluster, account: account) }
|
||||
let!(:cluster2) { create(:cluster, account: account) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
|
||||
before do
|
||||
account.update!(owner: user)
|
||||
team # Create team so account has teams
|
||||
end
|
||||
|
||||
it 'returns all clusters in the account regardless of team membership' do
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.clusters).to match_array([ cluster1, cluster2 ])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has no teams' do
|
||||
let!(:cluster1) { create(:cluster, account: account) }
|
||||
let!(:cluster2) { create(:cluster, account: account) }
|
||||
|
||||
it 'returns all clusters in the account' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.clusters).to match_array([ cluster1, cluster2 ])
|
||||
@@ -34,7 +53,7 @@ RSpec.describe Clusters::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns no clusters' do
|
||||
result = described_class.execute(user: user, account: account.reload)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.clusters).to be_empty
|
||||
@@ -48,7 +67,7 @@ RSpec.describe Clusters::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns only clusters granted to user teams' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.clusters).to eq([ cluster1 ])
|
||||
@@ -64,7 +83,7 @@ RSpec.describe Clusters::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns clusters from all teams user belongs to' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.clusters).to match_array([ cluster1, cluster2 ])
|
||||
@@ -80,7 +99,7 @@ RSpec.describe Clusters::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns unique clusters without duplicates' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.clusters).to eq([ cluster1 ])
|
||||
|
||||
@@ -9,9 +9,25 @@ RSpec.describe Projects::VisibleToUser do
|
||||
let!(:project2) { create(:project, cluster:, account:) }
|
||||
|
||||
describe '.execute' do
|
||||
context 'when user is an admin (account owner)' do
|
||||
let(:team) { create(:team, account: account) }
|
||||
|
||||
before do
|
||||
account.update!(owner: user)
|
||||
team # Create team so account has teams
|
||||
end
|
||||
|
||||
it 'returns all projects in the account regardless of team membership' do
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.projects).to match_array([ project1, project2 ])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has no teams' do
|
||||
it 'returns all projects in the account' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.projects).to match_array([ project1, project2 ])
|
||||
@@ -25,7 +41,7 @@ RSpec.describe Projects::VisibleToUser do
|
||||
|
||||
context 'when user is not in any teams' do
|
||||
it 'returns no projects' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.projects).to be_empty
|
||||
@@ -39,7 +55,7 @@ RSpec.describe Projects::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns only projects granted to user teams' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.projects).to eq([ project1 ])
|
||||
@@ -57,7 +73,7 @@ RSpec.describe Projects::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns all projects in the granted cluster' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.projects).to match_array([ project4, project5 ])
|
||||
@@ -75,7 +91,7 @@ RSpec.describe Projects::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns all accessible projects without duplicates' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.projects).to match_array([ project1, project4 ])
|
||||
@@ -91,7 +107,7 @@ RSpec.describe Projects::VisibleToUser do
|
||||
end
|
||||
|
||||
it 'returns projects from all teams user belongs to' do
|
||||
result = described_class.execute(user: user, account: account)
|
||||
result = described_class.execute(account_user: account_user)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(result.projects).to match_array([ project1, project2 ])
|
||||
|
||||
Reference in New Issue
Block a user