Skip to content

Architecture

Iván edited this page Apr 5, 2023 · 4 revisions

MudClub software architecture

The code is structured as follows.

Components

Several custom ViewComponents designed to minimise repetitive HTML coding in Views and Helpers:

  • AccordionComponent: Used to create flowbite accordions for html views. Creation requires passing an :accordion Hash including an Array of :objects to display in accordion.
  • ButtonComponent: manage regular buttons used in views receiving a :button hash. Different :kinds are implemented (add/remove, delete, back/forward, import/export, link, jump, etc.)
  • CalendarComponent: calendar view as ViewComponent using TailwindCSS, it takes a start_date and, optionally a collection of events to render.
  • DropdownComponent: Two-level Dropdown menus relying on custom javascript controller.
  • FieldsComponent: The main workhorse to generate views in Mudclub. It renders fields: passed as array of table cell rows managing different kinds of content for each field (icon, label, text-box, button, etc.).
  • FlashComponent: Display controller notifications.
  • GridComponent: Sortable & filterable content grids.
  • ModalComponent: Used to manage standardised modal views.
  • ModalPieComponent: Display chartkick pie charts in modal views.
  • NestedComponent: Unified way to manage nested-forms. relies on stimulus-rail-nested-form
  • SearchComboComponent: Search interface box capable of taking multiple search items. relies on custom search javascript controller.
  • SubmitComponent: Cancel/Return and (optiona) submit/edit/save buttons to attach in any view.
  • TopbarComponent: Unified application topbar - managed by ApplicationController

Each component has a ruby file, initializing and updating the component:

# TopbarComponent - dynamic display of application top bar as ViewComponent
class TopbarComponent < ApplicationComponent
	def initialize(user:, login:, logout:)
		clubperson = Person.find(0)
		@clublogo  = clubperson.logo
		@clubname  = clubperson.nick
		@user      = user
		@profile   = set_profile(user:, login:, logout:)
		@tabcls    = 'hover:bg-blue-700 hover:text-white focus:bg-blue-700 focus:text-white focus:ring-2 focus:ring-gray-200 whitespace-nowrap shadow rounded ml-2 px-2 py-2 rounded-md font-semibold'
		@lnkcls    = 'no-underline block pl-2 pr-2 py-2 hover:bg-blue-700 hover:text-white whitespace-nowrap'
		@profcls   = 'align-middle rounded-full min-h-8 min-w-8 align-middle hover:bg-blue-700 hover:ring-4 hover:ring-blue-200 focus:ring-4 focus:ring-blue-200'
		@logincls  = 'login_button rounded hover:bg-blue-700 max-h-8 min-h-6'
		if user
			@menu_tabs = menu_tabs(user)
			@admin_tab = admin_tab(user) if user.admin? or user.is_coach?
		end
		@prof_tab = prof_tab(user)
		@ham_menu = set_hamburger_menu if user
	end
	# <... a bit more code ...>
end

... and an html.erb template file, used to render the component:

<!-- Navbar goes here -->
<nav class="sticky top-0 z-50 w-full h-15 bg-blue-900 text-gray-300">
	<div class="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
		<div class="relative flex items-center justify-between h-16">
			<!-- Mobile menu, show/hide based on menu state. -->
			<div class="absolute inset-y-0 left-0 flex items-center sm:hidden">
				<%= render DropdownComponent.new(button: @ham_menu) if @ham_menu %>
			</div>
			<!-- Regular menu for large screens, show/hide based on menu state. -->
			<div class="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
			<!-- REST OF REGULAR MENU CONTENT -->
			</div>
		</div>
		<!-- REST OF HTML TOPBAR CONTENT -->
	</div>
</nav>

Controllers

Perform all data preparation and actions - leverage Helper & Components to maximize "ruby" coding vs HTML or CSS.

Models

Object management - including associations and custom information access.

class Drill < ApplicationRecord
	has_paper_trail on: [:create, :update]
	belongs_to :coach
	belongs_to :kind
	has_and_belongs_to_many :skills
	accepts_nested_attributes_for :skills, reject_if: :all_blank, allow_destroy: true
	has_many :drill_targets
	has_many :targets, through: :drill_targets
	accepts_nested_attributes_for :targets, reject_if: :all_blank, allow_destroy: true
	accepts_nested_attributes_for :drill_targets, reject_if: :all_blank, allow_destroy: true
	has_one_attached :playbook
	has_rich_text :explanation
	scope :real, -> { where("id>0") }
	scope :by_name, -> (name) { where("unaccent(name) ILIKE unaccent(?) OR unaccent(description) ILIKE unaccent(?)","%#{name}%","%#{name}%").distinct }
	scope :by_kind, -> (kind_id) { (kind_id and kind_id.to_i>0) ? where(kind_id: kind_id.to_i) : where("kind_id>0") }
	scope :by_skill, -> (skill_id) { (skill_id and skill_id.to_i>0) ? joins(:skills).where(skills: {id: skill_id.to_i}) : all	}
	self.inheritance_column = "not_sti"
	FILTER_PARAMS = %i[name kind_id skill_id column direction].freeze

	def self.filter(filters)
		res = Drill.by_name(filters['name'])
		.by_kind(filters['kind_id'])
		.by_skill(filters['skill_id'])
		filters['column'] ? res.order("#{filters['column']} #{filters['direction']}") : res.order(:name)
	end

	# search all drills for specific subsets
	def self.search(search=nil)
		#<... more code...>
		res.order(:kind_id)
	end

	#<... more code...>

	# build new @drill from raw input hash given by form submital submittal
	# return nil if unsuccessful
	def rebuild(d_data)
		self.name        = d_data[:name]
		self.description = d_data[:description]
		#<... more code...>
		self
	end

	#<... more code...>
end

Helpers

Auxiliary code to define required Component definitions for each view.

module DrillsHelper
	# specific search bar to search through drills
	def drill_search_bar(search_in:, task_id: nil, scratch: nil)
		session.delete('drill_filters') if scratch
		fields = [
			{kind: "search-text", key: :name, label: I18n.t("person.name_a"), value: session.dig('drill_filters', 'name'), size: 10},
			{kind: "search-select", key: :kind_id, label: "#{I18n.t("kind.single")}:", value: session.dig('drill_filters', 'kind_id'), options: Kind.real.pluck(:name, :id)},
			{kind: "search-select", key: :skill_id, label: I18n.t("skill.single"), value: session.dig('drill_filters', 'skill_id'), options: Skill.real.pluck(:concept, :id)}
		]
		fields << {kind: "hidden", key: :task_id, value: task_id} if task_id
		res = [[{kind: "search-combo", url: search_in, fields: fields}]]
	end

	# return icon and top of FieldsComponent
	def drill_title_fields(title:, rows: nil, cols: nil)
		title_start(icon: "drill.svg", title: title, rows: rows, cols: cols)
	end
	#<... more code to create Component inputs ...>
end

Views

Actual HTML views to be served - minimalistic files mostly just rendering Components already created by Controllers.

<div id="drills-index">
	<%= render @title %>
	<%= render @search %>
	<%= turbo_frame_tag "search-results" do %>
		<%= render @grid %>
	<% end %>
</div>
Clone this wiki locally