diff --git a/Gemfile b/Gemfile index 3e0a2ae..1b3b49e 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,9 @@ gem 'sentry-rails' gem 'web-console' +# Search +gem 'ransack' + group :development, :test do gem 'brakeman', require: false gem 'debug', platforms: %i[mri windows], require: 'debug/prelude' diff --git a/Gemfile.lock b/Gemfile.lock index 4ed879b..197dd4c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -301,7 +301,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.10) + rack (3.1.12) rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) @@ -355,6 +355,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) + ransack (4.3.0) + activerecord (>= 6.1.5) + activesupport (>= 6.1.5) + i18n rbs (3.8.1) logger rdoc (6.12.0) @@ -568,6 +572,7 @@ DEPENDENCIES rails (~> 7.2.0) rails-controller-testing rails-mermaid_erd + ransack redis (>= 4.0.1) rolify rspec-rails diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index aa4d189..fbb4838 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,51 +1,19 @@ module Admin class UsersController < BaseController - before_action :set_user, only: %i[show edit update destroy] - - def index - @pagy, @users = pagy(User.order(created_at: :desc)) - end - - def show; end - - def new - @user = User.new - end - - def edit; end - - def create - @user = User.new(user_params) - - if @user.save - redirect_to admin_users_path(@user), notice: 'User was successfully created.' - else - render :new - end - end - - def update - if @user.update(user_params) - redirect_to admin_users_path(@user), notice: 'User was successfully updated.' - else - render :edit - end - end - - def destroy - @user.destroy! - redirect_to admin_users_url, notice: 'User was successfully destroyed.' - end - - private - - def set_user - @user = User.find(params[:id]) - authorize(@user) - end - - def user_params - params.require(:user).permit(policy(@user).permitted_attributes) - end + include Crudable + + crud_to class: User, + collection_variable: :@users, + object_variable: :@user, + collection_path: :admmin_users_path, + object_path: :admin_user_path, + searchable: true, + modal_form: false, + # collection_includes:, + flash_messages: { + created: I18n.t('common.create.success', model: :user), + updated: I18n.t('common.update.success', model: :user), + deleted: I18n.t('common.delete.success', model: :user) + } end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7f28d84..82eb552 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -28,6 +28,17 @@ def context_view_path(*strs) File.join([controller_path].push(*strs)) end + def current_user + @current_user ||= super.tap do |user| + if user.present? + ActiveRecord::Associations::Preloader.new( + records: [user], + associations: :roles + ).call + end + end + end + private def not_authorized diff --git a/app/controllers/concerns/crudable.rb b/app/controllers/concerns/crudable.rb new file mode 100644 index 0000000..229a351 --- /dev/null +++ b/app/controllers/concerns/crudable.rb @@ -0,0 +1,209 @@ +# Idea for this module from Mr Ben, https://github.com/tranquangvu +# Copyright (c) 2022 "Golden Owl Solutions", MIT License +# https://github.com/GoldenowlConsultingCompany + +module Crudable + extend ActiveSupport::Concern + + included do + before_action :prepare_collection, only: %i[index] + before_action :prepare_new_object, only: %i[new create] + before_action :prepare_object, only: %i[show edit update destroy] + + assign_resource_class_accessors + end + + module ClassMethods + attr_accessor :resource_class, + :resource_collection_variable, + :resource_object_variable, + :resource_query_variable, + :resource_pagy_variable, + :resource_route_namespaces, + :resource_collection_path, + :resource_object_path, + :resource_modal_form, + :resource_collection_includes, + :resource_object_includes, + :resource_flash_messages, + :resource_searchable + + RESOURCE_ACTIONS = %i[index show new create edit update destroy].freeze + + def crud_to(options = {}) + options.symbolize_keys! + options.assert_valid_keys( + :class, + :searchable, + :collection_variable, + :object_variable, + :route_namespaces, + :collection_path, + :object_path, + :modal_form, + :collection_includes, + :object_includes, + :flash_messages, + :only_actions, + :except_actions + ) + + only_actions = options.delete(:only_actions) + only_actions = only_actions.present? ? only_actions.map(&:to_sym) : RESOURCE_ACTIONS + except_actions = options.delete(:except_actions) + except_actions = except_actions.present? ? except_actions.map(&:to_sym) : [] + effect_actions = only_actions - except_actions + + assign_resource_class_accessors(options) + check_define_resource_actions(effect_actions) + format_request_resource_actions(effect_actions) + end + + private + + def assign_resource_class_accessors(options = {}) # rubocop:disable Metrics/AbcSize + self.resource_class = options.fetch(:class, (name.split('::').last.sub(/Controller$/, '').singularize.constantize rescue nil)) # rubocop:disable Style/RescueModifier + self.resource_collection_variable = options.fetch(:collection_variable, ("@#{resource_class.name.underscore.pluralize}" rescue :collection)).to_sym # rubocop:disable Style/RescueModifier + self.resource_object_variable = options.fetch(:object_variable, ("@#{resource_class.name.underscore}" rescue :object)).to_sym # rubocop:disable Style/RescueModifier + self.resource_searchable = options.fetch(:searchable, true) + self.resource_pagy_variable = :@pagy + self.resource_query_variable = :@q + + self.resource_route_namespaces = options.fetch(:route_namespaces, name.underscore.split('/')[0..-2]).map(&:to_sym) + self.resource_collection_path = options.fetch(:collection_path, nil) + self.resource_object_path = options.fetch(:object_path, nil) + + self.resource_modal_form = options.fetch(:modal_form, false) + self.resource_collection_includes = options.fetch(:collection_includes, []) + self.resource_object_includes = options.fetch(:object_includes, []) + self.resource_flash_messages = options.fetch(:flash_messages, {}) + end + + def check_define_resource_actions(actions) + (RESOURCE_ACTIONS - actions).each do |action| + undef_method(action) + end + end + + def format_request_resource_actions(actions) + effected_actions = actions & %i[new edit] + only_turbo_stream_for(*effected_actions) if resource_modal_form + end + end + + def index + pagy, collection = pagy(instance_variable_get(self.class.resource_collection_variable)) + instance_variable_set(self.class.resource_pagy_variable, pagy) + instance_variable_set(self.class.resource_collection_variable, collection) + block_given? ? yield : render(:index) + end + + def show + render(:show) + end + + def new + render(:new) + end + + def create + object = instance_variable_get(self.class.resource_object_variable) + object.assign_attributes(resource_permitted_params) + + created = object.save + + return yield(created) if block_given? + + if created + set_flash_message(:notice, :created) + redirect_to resource_after_create_or_update_path + else + template = self.class.resource_modal_form ? :reform : :new + render(template, status: :unprocessable_entity) + end + end + + def edit + render(:edit) + end + + def update + object = instance_variable_get(self.class.resource_object_variable) + updated = object.update(resource_permitted_params) + + return yield(updated) if block_given? + + if updated + set_flash_message(:notice, :updated) + redirect_to resource_after_create_or_update_path + else + template = self.class.resource_modal_form ? :reform : :edit + render(template, status: :unprocessable_entity) + end + end + + def destroy + object = instance_variable_get(self.class.resource_object_variable) + object.destroy! + + return yield(object) if block_given? + + set_flash_message(:notice, :deleted) + redirect_to(resource_after_destroy_path) + end + + private + + def set_flash_message(type, key, flash_now: false) + message = self.class.resource_flash_messages.fetch(key, nil) + (flash_now ? flash.now : flash)[type] = message if message + end + + def resource_object_path + object = instance_variable_get(self.class.resource_object_variable) + custom_path_method = self.class.resource_object_path + custom_path_method ? send(custom_path_method, object) : polymorphic_path([*self.class.resource_route_namespaces, object]) + end + + def resource_collection_path + custom_path_method = self.class.resource_collection_path + custom_path_method ? send(custom_path_method) : polymorphic_path([*self.class.resource_route_namespaces, self.class.resource_class]) + end + + def resource_after_create_or_update_path + self.class.resource_modal_form ? resource_collection_path : resource_object_path + end + + def resource_after_destroy_path + resource_collection_path + end + + def resource_permitted_params + raise NotImplementedError, "You must define `resource_permitted_params` as instance method in #{self.class.name} class" + end + + def resource_base_scope + policy_scope(self.class.resource_class) + end + + def prepare_collection + q = resource_base_scope.ransack(params[:q]) + collection = q.result(distinct: true).includes(self.class.resource_collection_includes) + + instance_variable_set(self.class.resource_query_variable, q) + instance_variable_set(self.class.resource_collection_variable, collection) + authorize(collection) + end + + def prepare_new_object + object = self.class.resource_class.new + instance_variable_set(self.class.resource_object_variable, object) + authorize(object) + end + + def prepare_object + object = resource_base_scope.includes(self.class.resource_object_includes).find(params[:id]) + instance_variable_set(self.class.resource_object_variable, object) + authorize(object) + end +end diff --git a/app/frontend/controllers/shared/sidebar_controller.js b/app/frontend/controllers/shared/sidebar_controller.js new file mode 100644 index 0000000..6165330 --- /dev/null +++ b/app/frontend/controllers/shared/sidebar_controller.js @@ -0,0 +1,30 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['menu']; + + connect() { + // Check if we're on a large screen + if (window.innerWidth >= 1024) { + // Initialize from cookie + const isCollapsed = document.cookie.includes('sidebar_collapsed=true'); + if (isCollapsed) { + document.documentElement.classList.add('drawer-mini'); + } + } + } + + toggleCollapse(event) { + event.preventDefault(); + + const isCollapsed = document.documentElement.classList.contains('drawer-mini'); + + if (isCollapsed) { + document.cookie = 'sidebar_collapsed=false; path=/'; + document.documentElement.classList.remove('drawer-mini'); + } else { + document.cookie = 'sidebar_collapsed=true; path=/'; + document.documentElement.classList.add('drawer-mini'); + } + } +} diff --git a/app/frontend/entrypoints/admin.js b/app/frontend/entrypoints/admin.js index 207199f..4714043 100644 --- a/app/frontend/entrypoints/admin.js +++ b/app/frontend/entrypoints/admin.js @@ -1,4 +1,4 @@ -import '../stylesheets/admin/index.scss'; -import '../controllers/admin'; -import '../controllers/shared'; +import '@/stylesheets/admin/index.scss'; +import '@/controllers/admin'; +import '@/controllers/shared'; import '@hotwired/turbo-rails'; diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index 72ef6bb..bd804b5 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -1,4 +1,4 @@ import '~/stylesheets/application/index.scss'; -import '../controllers/application'; -import '../controllers/shared'; +import '@/controllers/application'; +import '@/controllers/shared'; import '@hotwired/turbo-rails'; diff --git a/app/helpers/date_time_helper.rb b/app/helpers/date_time_helper.rb index 2f9c190..6468450 100644 --- a/app/helpers/date_time_helper.rb +++ b/app/helpers/date_time_helper.rb @@ -1,45 +1,45 @@ module DateTimeHelper - def display_date(datetime = Date.current, format = nil) - strtime = format_date[format] || '%d/%m/%Y' - datetime.strftime(strtime) + def display_date(datetime = Date.current, format = :dmy_slash) + format_datetime_string(datetime, format, :date) end - def display_time(datetime = DateTime.current, format = nil) - strtime = format_time[format] || '%H:%M' - datetime.strftime(strtime) + def display_time(datetime = Time.zone.now, format = :hm24) + format_datetime_string(datetime, format, :time) end - def display_datetime(datetime = DateTime.current, format = nil) - strtime = format_datetime[format] || '%d/%m/%Y %H:%M' - datetime.strftime(strtime) + def display_datetime(datetime = Time.zone.now, format = :dmy_hm24) + format_datetime_string(datetime, format, :datetime) end private - def format_date - { - dmy_dashed: '%Y-%m-%d', - dmy_slash: '%d/%m/%Y', - dBy: '%d %B %Y', - dby: '%d %b %Y' - } - end + def format_datetime_string(datetime, format, type) + return '' if datetime.nil? - def format_time - { - hm24: '%H:%M', - hm12: '%I:%M', - hms24: '%H:%M:%S', - hms12: '%I:%M:%S' - } + format_string = datetime_formats[type][format] || datetime_formats[type].values.first + datetime.strftime(format_string) end - def format_datetime + def datetime_formats { - dmy_hm24: '%d/%m/%Y %H:%M', - dmy_hm12: '%d/%m/%Y %I:%M', - dmy_hms24: '%d/%m/%Y %H:%M:%S', - dmy_hms12: '%d/%m/%Y %I:%M:%S' + date: { + dmy_dashed: '%Y-%m-%d', + dmy_slash: '%d/%m/%Y', + dBy: '%d %B %Y', + dby: '%d %b %Y' + }, + time: { + hm24: '%H:%M', + hm12: '%I:%M %p', + hms24: '%H:%M:%S', + hms12: '%I:%M:%S %p' + }, + datetime: { + dmy_hm24: '%d/%m/%Y %H:%M', + dmy_hm12: '%d/%m/%Y %I:%M %p', + dmy_hms24: '%d/%m/%Y %H:%M:%S', + dmy_hms12: '%d/%m/%Y %I:%M:%S %p' + } } end end diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb index a18b277..8259fe4 100644 --- a/app/helpers/pagination_helper.rb +++ b/app/helpers/pagination_helper.rb @@ -1,31 +1,56 @@ module PaginationHelper - def pagy_nav(pagy) - html = %(