├── spec ├── dummy_rails │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── views │ │ │ └── layouts │ │ │ │ ├── mailer.text.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── application.html.erb │ │ ├── tiny_admin │ │ │ ├── admin_helper.rb │ │ │ ├── admin_utils.rb │ │ │ ├── page_not_found.rb │ │ │ ├── record_not_found.rb │ │ │ ├── root_page.rb │ │ │ ├── sample_page.rb │ │ │ ├── sample_member_action.rb │ │ │ ├── section_github_link.rb │ │ │ ├── sample_collection_action.rb │ │ │ ├── latest_posts_widget.rb │ │ │ └── latest_authors_widget.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── controllers │ │ │ └── application_controller.rb │ │ ├── models │ │ │ ├── application_record.rb │ │ │ ├── profile.rb │ │ │ ├── tag.rb │ │ │ ├── post_tag.rb │ │ │ ├── author.rb │ │ │ └── post.rb │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ └── mailers │ │ │ └── application_mailer.rb │ ├── bin │ │ ├── rake │ │ ├── rails │ │ └── setup │ ├── config │ │ ├── routes.rb │ │ ├── environment.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── initializers │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── permissions_policy.rb │ │ │ ├── tiny_admin.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── storage.yml │ │ ├── puma.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── test.rb │ │ │ └── production.rb │ │ └── tiny_admin.yml │ ├── config.ru │ ├── Rakefile │ └── db │ │ ├── migrate │ │ ├── 20180607053255_create_tags.rb │ │ ├── 20180607053251_create_authors.rb │ │ ├── 20180607053254_create_profiles.rb │ │ ├── 20180607053857_create_post_tags.rb │ │ └── 20180607053739_create_posts.rb │ │ └── schema.rb ├── fixtures │ └── files │ │ └── basic_config.yml ├── dummy_rails_app.rb ├── support │ ├── capybara_helpers.rb │ └── setup_data.rb ├── features │ ├── pages │ │ ├── page_not_found_spec.rb │ │ ├── pages_spec.rb │ │ ├── content_spec.rb │ │ └── root_spec.rb │ ├── components │ │ ├── navbar_spec.rb │ │ └── pagination_spec.rb │ ├── plugins │ │ ├── authenticator_spec.rb │ │ └── authorization_spec.rb │ └── resources_spec.rb ├── spec_helper.rb ├── lib │ ├── tiny_admin_spec.rb │ └── tiny_admin │ │ └── plugins │ │ └── no_auth_spec.rb └── rails_helper.rb ├── .rspec ├── extra ├── screenshot.png ├── sample_features_app │ ├── config.ru │ ├── admin │ │ ├── missing_page.rb │ │ ├── sample_content_page.rb │ │ ├── sample_page.rb │ │ ├── sample_page2.rb │ │ └── items.rb │ ├── Gemfile │ ├── app.rb │ └── tiny_admin.yml ├── standalone_app │ └── app.rb ├── hanami_app │ └── config.ru ├── roda_app │ └── app.rb ├── rails_app │ └── app.rb └── tiny_admin_settings.rb ├── lib ├── tiny_admin │ ├── version.rb │ ├── views │ │ ├── basic_widget.rb │ │ ├── components │ │ │ ├── basic_component.rb │ │ │ ├── head.rb │ │ │ ├── flash.rb │ │ │ ├── widgets.rb │ │ │ ├── field_value.rb │ │ │ ├── navbar.rb │ │ │ ├── pagination.rb │ │ │ └── filters_form.rb │ │ ├── pages │ │ │ ├── root.rb │ │ │ ├── page_not_found.rb │ │ │ ├── page_not_allowed.rb │ │ │ ├── record_not_found.rb │ │ │ ├── content.rb │ │ │ └── simple_auth_login.rb │ │ ├── basic_layout.rb │ │ ├── actions │ │ │ ├── show.rb │ │ │ └── index.rb │ │ └── default_layout.rb │ ├── context.rb │ ├── plugins │ │ ├── authorization.rb │ │ ├── base_repository.rb │ │ ├── no_auth.rb │ │ ├── simple_auth.rb │ │ └── active_record_repository.rb │ ├── section.rb │ ├── basic_app.rb │ ├── actions │ │ ├── basic_action.rb │ │ ├── show.rb │ │ └── index.rb │ ├── support.rb │ ├── authentication.rb │ ├── field.rb │ ├── utils.rb │ ├── store.rb │ ├── settings.rb │ └── router.rb └── tiny_admin.rb ├── sig ├── tiny_admin │ ├── views │ │ ├── basic_widget.rbs │ │ ├── components │ │ │ ├── basic_component.rbs │ │ │ ├── flash.rbs │ │ │ ├── widgets.rbs │ │ │ ├── filters_form.rbs │ │ │ ├── head.rbs │ │ │ ├── navbar.rbs │ │ │ ├── field_value.rbs │ │ │ └── pagination.rbs │ │ ├── basic_layout.rbs │ │ ├── actions │ │ │ ├── show.rbs │ │ │ └── index.rbs │ │ └── default_layout.rbs │ ├── basic_app.rbs │ ├── plugins │ │ ├── no_auth.rbs │ │ ├── authorization.rbs │ │ ├── simple_auth.rbs │ │ ├── base_repository.rbs │ │ ├── no_auth │ │ │ └── instance_methods.rbs │ │ ├── simple_auth │ │ │ └── instance_methods.rbs │ │ └── active_record_repository.rbs │ ├── actions │ │ ├── basic_action.rbs │ │ ├── show.rbs │ │ └── index.rbs │ ├── authentication.rbs │ ├── context.rbs │ ├── section.rbs │ ├── utils.rbs │ ├── field.rbs │ ├── settings.rbs │ ├── support.rbs │ ├── store.rbs │ └── router.rbs └── tiny_admin.rbs ├── .reviewdog.yml ├── Makefile ├── .gitignore ├── bin ├── rails ├── rbs ├── rspec └── rubocop ├── Gemfile ├── .rubocop.yml ├── .github ├── FUNDING.yml └── workflows │ ├── tests.yml │ └── linters.yml ├── LICENSE.txt ├── tiny_admin.gemspec ├── CHANGELOG.md └── README.md /spec/dummy_rails/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy_rails/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy_rails/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy_rails/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /spec/fixtures/files/basic_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | root: 3 | title: Test Admin! 4 | -------------------------------------------------------------------------------- /extra/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blocknotes/tiny_admin/HEAD/extra/screenshot.png -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/admin_helper.rb: -------------------------------------------------------------------------------- 1 | class AdminHelper < TinyAdmin::Support 2 | end 3 | -------------------------------------------------------------------------------- /lib/tiny_admin/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | VERSION = '0.10.1' 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | end 5 | -------------------------------------------------------------------------------- /extra/sample_features_app/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'app' 4 | 5 | run TinyAdmin::Router 6 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/basic_widget.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | class BasicWidget 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy_rails/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /sig/tiny_admin/basic_app.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | class BasicApp 3 | def self.authentication_plugin: () -> void 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.reviewdog.yml: -------------------------------------------------------------------------------- 1 | runner: 2 | fasterer: 3 | cmd: bin/fasterer 4 | level: info 5 | rubocop: 6 | cmd: bin/rubocop 7 | level: info 8 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | mount TinyAdmin::Router => '/admin' 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_rails_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['RAILS_ENV'] ||= 'test' 4 | require File.expand_path('dummy_rails/config/environment', __dir__) 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lint: 2 | bin/rubocop 3 | 4 | test: 5 | bin/rspec 6 | 7 | test_rbs: 8 | RUBYOPT='-rbundler/setup -rrbs/test/setup' RBS_TEST_TARGET='TinyAdmin::*' bin/rspec 9 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | primary_abstract_class 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/models/profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Profile < ApplicationRecord 4 | belongs_to :author, inverse_of: :profile, touch: true 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_rails/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/basic_widget.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | class BasicWidget < Phlex::HTML 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Channel < ActionCable::Channel::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/admin_utils.rb: -------------------------------------------------------------------------------- 1 | module AdminUtils 2 | module_function 3 | 4 | def datetime_formatter(value, options: []) 5 | value&.to_date&.to_s 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /sig/tiny_admin/plugins/no_auth.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Plugins 3 | module NoAuth 4 | def self.configure: (untyped, Hash[untyped, untyped]) -> void 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Connection < ActionCable::Connection::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /sig/tiny_admin/plugins/authorization.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Plugins 3 | class Authorization 4 | def self.allowed?: (untyped, untyped, ?untyped?) -> bool 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /sig/tiny_admin/plugins/simple_auth.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Plugins 3 | module SimpleAuth 4 | def self.configure: (untyped, Hash[untyped, untyped]) -> void 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: "from@example.com" 5 | layout "mailer" 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/page_not_found.rb: -------------------------------------------------------------------------------- 1 | class PageNotFound < TinyAdmin::Views::DefaultLayout 2 | def view_template 3 | super do 4 | h1 { 'Page not found!' } 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_rails/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /sig/tiny_admin/actions/basic_action.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Actions 3 | class BasicAction 4 | def attribute_options: (Array[untyped]?) -> Hash[untyped, untyped]? 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /sig/tiny_admin/actions/show.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Actions 3 | class Show 4 | def call: (app: BasicApp, context: Context, options: Hash[Symbol, untyped]) -> void 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/record_not_found.rb: -------------------------------------------------------------------------------- 1 | class RecordNotFound < TinyAdmin::Views::DefaultLayout 2 | def view_template 3 | super do 4 | h1 { 'Record not found!' } 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/models/tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Tag < ApplicationRecord 4 | has_many :post_tags, inverse_of: :tag, dependent: :destroy 5 | has_many :posts, through: :post_tags 6 | end 7 | -------------------------------------------------------------------------------- /sig/tiny_admin/authentication.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | class Authentication 3 | private 4 | 5 | def render_login: (?notices: Array[String]?, ?warnings: Array[String]?, ?errors: Array[String]?) -> void 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/root_page.rb: -------------------------------------------------------------------------------- 1 | class RootPage < TinyAdmin::Views::DefaultLayout 2 | def view_template 3 | super do 4 | h1 { 'Root page' } 5 | p { 'This is just a root page' } 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/sample_page.rb: -------------------------------------------------------------------------------- 1 | class SamplePage < TinyAdmin::Views::DefaultLayout 2 | def view_template 3 | super do 4 | h1 { 'Sample page' } 5 | p { 'This is just a sample page' } 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/models/post_tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PostTag < ApplicationRecord 4 | belongs_to :post, inverse_of: :post_tags, optional: false 5 | belongs_to :tag, inverse_of: :post_tags, optional: false 6 | end 7 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/components/basic_component.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Components 4 | class BasicComponent 5 | def update_attributes: (Hash[Symbol, untyped]) -> void 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /spec/dummy_rails/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /lib/tiny_admin/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | Context = Struct.new( 5 | :actions, 6 | :reference, 7 | :repository, 8 | :request, 9 | :router, 10 | :slug, 11 | keyword_init: true 12 | ) 13 | end 14 | -------------------------------------------------------------------------------- /sig/tiny_admin/plugins/base_repository.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Plugins 3 | class BaseRepository 4 | RecordNotFound: untyped 5 | 6 | attr_reader model: untyped 7 | 8 | def initialize: (untyped) -> void 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /spec/dummy_rails/db/migrate/20180607053255_create_tags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTags < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :tags do |t| 6 | t.string :name 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /extra/sample_features_app/admin/missing_page.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | TinyAdmin.configure do |settings| 4 | (settings.sections ||= []).push( 5 | slug: 'missing-page', 6 | name: 'Missing Page', 7 | type: :url, 8 | url: '/missing-page' 9 | ) 10 | end 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.rspec_failures 2 | /.rubocop-* 3 | /*.gem 4 | 5 | Gemfile.lock 6 | 7 | /_misc/ 8 | /coverage/ 9 | /log/ 10 | /tmp/ 11 | 12 | /extra/*/log 13 | /extra/*/tmp 14 | /spec/dummy_rails/log 15 | /spec/dummy_rails/tmp 16 | /spec/dummy_rails/db/*.sqlite3 17 | /spec/dummy_rails/db/*.sqlite3-* 18 | -------------------------------------------------------------------------------- /spec/support/capybara_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'Capybara helpers' do # rubocop:disable RSpec/ContextWording 4 | def log_in(password: 'changeme') 5 | page.fill_in 'secret', with: password 6 | page.find('form.form_login .button_login').click 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/tiny_admin/plugins/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Plugins 5 | class Authorization 6 | class << self 7 | def allowed?(_user, _action, _param = nil) 8 | true 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/components/flash.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Components 4 | class Flash 5 | attr_accessor messages: Hash[Symbol, Array[String]?]? 6 | 7 | def view_template: () ?{ (untyped) -> void } -> void 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /extra/sample_features_app/admin/sample_content_page.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | TinyAdmin.configure do |settings| 4 | (settings.sections ||= []).push( 5 | slug: 'test-content', 6 | name: 'Test content', 7 | type: :content, 8 | content: 'This is a test content page' 9 | ) 10 | end 11 | -------------------------------------------------------------------------------- /sig/tiny_admin.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | VERSION: String 3 | 4 | def configure: () { (Settings) -> untyped } -> untyped 5 | 6 | def configure_from_file: (String) -> void 7 | 8 | def route_for: (String, reference: String?, action: String?, query: String?) -> String 9 | 10 | def settings: -> Settings 11 | end 12 | -------------------------------------------------------------------------------- /sig/tiny_admin/context.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | class Context < Struct 3 | attr_accessor actions: untyped 4 | attr_accessor reference: untyped 5 | attr_accessor repository: untyped 6 | attr_accessor request: untyped 7 | attr_accessor router: untyped 8 | attr_accessor slug: String 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 3 | timeout: 5000 4 | 5 | development: 6 | <<: *default 7 | adapter: sqlite3 8 | database: db/development.sqlite3 9 | 10 | test: 11 | <<: *default 12 | adapter: sqlite3 13 | database: db/test.sqlite3 14 | -------------------------------------------------------------------------------- /extra/sample_features_app/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | gem 'faker' 7 | gem 'rack' 8 | gem 'rackup' 9 | gem 'webrick' 10 | 11 | gem 'pry' 12 | 13 | gem 'tiny_admin', path: '../../' 14 | # gem 'tiny_admin' 15 | -------------------------------------------------------------------------------- /sig/tiny_admin/plugins/no_auth/instance_methods.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Plugins 3 | module NoAuth 4 | module InstanceMethods 5 | def authenticate_user!: () -> void 6 | 7 | def current_user: () -> untyped 8 | 9 | def logout_user: () -> void 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /sig/tiny_admin/plugins/simple_auth/instance_methods.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Plugins 3 | module SimpleAuth 4 | module InstanceMethods 5 | def authenticate_user!: () -> void 6 | 7 | def current_user: () -> untyped 8 | 9 | def logout_user: () -> void 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/components/widgets.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Components 4 | class Widgets 5 | @widgets: Array[untyped]? 6 | 7 | def initialize: (Array[untyped]?) -> void 8 | 9 | def view_template: () ?{ (untyped) -> void } -> void 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /spec/dummy_rails/db/migrate/20180607053251_create_authors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateAuthors < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :authors do |t| 6 | t.string :name 7 | t.integer :age 8 | t.string :email 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tiny_admin/plugins/base_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Plugins 5 | class BaseRepository 6 | RecordNotFound = Class.new(StandardError) 7 | 8 | attr_reader :model 9 | 10 | def initialize(model) 11 | @model = model 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/tiny_admin/section.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | class Section 3 | attr_reader name: String 4 | attr_reader options: Hash[Symbol, String] 5 | attr_reader path: String 6 | attr_reader slug: String? 7 | 8 | def initialize: (name: String, ?slug: String?, ?path: String?, ?options: Hash[Symbol, String]) -> void 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/sample_member_action.rb: -------------------------------------------------------------------------------- 1 | class SampleMemberPage < Phlex::HTML 2 | def view_template 3 | p { 4 | 'Custom member action' 5 | } 6 | end 7 | end 8 | 9 | class SampleMemberAction < TinyAdmin::Actions::BasicAction 10 | def call(app:, context:, options:) 11 | SampleMemberPage 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/section_github_link.rb: -------------------------------------------------------------------------------- 1 | module SectionGithubLink 2 | def to_h 3 | { 4 | slug: :github, 5 | name: 'GitHub', 6 | type: :url, 7 | url: 'https://www.github.com', 8 | options: { 9 | target: '_blank' 10 | } 11 | } 12 | end 13 | 14 | module_function :to_h 15 | end 16 | -------------------------------------------------------------------------------- /spec/dummy_rails/db/migrate/20180607053254_create_profiles.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateProfiles < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :profiles do |t| 6 | t.text :description 7 | t.belongs_to :author, foreign_key: true 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/sample_collection_action.rb: -------------------------------------------------------------------------------- 1 | class SampleCollectionPage < Phlex::HTML 2 | def view_template 3 | p { 4 | 'Custom collection action' 5 | } 6 | end 7 | end 8 | 9 | class SampleCollectionAction < TinyAdmin::Actions::BasicAction 10 | def call(app:, context:, options:) 11 | SampleCollectionPage 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy_rails/db/migrate/20180607053857_create_post_tags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreatePostTags < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :post_tags do |t| 6 | t.belongs_to :post, foreign_key: true 7 | t.belongs_to :tag, foreign_key: true 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/components/filters_form.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Components 4 | class FiltersForm 5 | attr_accessor filters: (Hash[Field, Hash[Symbol, untyped]]) 6 | attr_accessor section_path: String 7 | 8 | def view_template: () ?{ (untyped) -> void } -> void 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /extra/sample_features_app/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # => bundle exec ruby app.rb 4 | 5 | require 'bundler' 6 | Bundler.require 7 | 8 | TinyAdmin.configure_from_file('./tiny_admin.yml') 9 | Dir[File.expand_path('admin/**/*.rb', __dir__)].each { |f| require f } 10 | 11 | Rackup::Server.new(app: TinyAdmin::Router, Port: 3000).start if __FILE__ == $PROGRAM_NAME 12 | -------------------------------------------------------------------------------- /lib/tiny_admin/section.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | class Section 5 | attr_reader :name, :options, :path, :slug 6 | 7 | def initialize(name:, slug: nil, path: nil, options: {}) 8 | @name = name 9 | @options = options 10 | @path = path || TinyAdmin.route_for(slug) 11 | @slug = slug 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/latest_posts_widget.rb: -------------------------------------------------------------------------------- 1 | class LatestPostsWidget < TinyAdmin::Views::BasicWidget 2 | def view_template 3 | h2 { 'Latest posts' } 4 | 5 | ul { 6 | Post.last(3).each do |post| 7 | li { 8 | a(href: TinyAdmin.route_for('posts', reference: post.id)) { post.to_s } 9 | } 10 | end 11 | } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /extra/sample_features_app/tiny_admin.yml: -------------------------------------------------------------------------------- 1 | --- 2 | root_path: '/' 3 | root: 4 | redirect: sample-page 5 | scripts: 6 | - src: https://cdn.jsdelivr.net/npm/chart.js 7 | extra_styles: > 8 | .navbar { 9 | background-color: var(--bs-teal); 10 | } 11 | .main-content { 12 | background-color: var(--bs-gray-100); 13 | } 14 | .main-content a { 15 | text-decoration: none; 16 | } 17 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/tiny_admin/latest_authors_widget.rb: -------------------------------------------------------------------------------- 1 | class LatestAuthorsWidget < TinyAdmin::Views::BasicWidget 2 | def view_template 3 | h2 { 'Latest authors' } 4 | 5 | ul { 6 | Author.last(3).each do |author| 7 | li { 8 | a(href: TinyAdmin.route_for('authors', reference: author.id)) { author.to_s } 9 | } 10 | end 11 | } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/basic_layout.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | class BasicLayout 4 | attr_accessor content: untyped 5 | attr_accessor params: untyped 6 | attr_accessor widgets: untyped 7 | 8 | def label_for: (String, options: Array[untyped]) -> String? 9 | 10 | def update_attributes: (Hash[Symbol, untyped]) -> void 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/components/head.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Components 4 | class Head 5 | attr_accessor extra_styles: String? 6 | attr_accessor page_title: String? 7 | attr_accessor style_links: Array[Hash[Symbol, String]] 8 | 9 | def view_template: () ?{ (untyped) -> void } -> void 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/components/basic_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Components 6 | class BasicComponent < Phlex::HTML 7 | def update_attributes(attributes) 8 | attributes.each do |key, value| 9 | send("#{key}=", value) 10 | end 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/tiny_admin/utils.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Utils 3 | def humanize: (String?) -> String 4 | 5 | def params_to_s: (Enumerable[untyped]) -> String 6 | 7 | def prepare_page: (untyped, ?slug: String?, ?attributes: Hash[untyped, untyped]?, ?options: Array[Symbol]?, ?params: Enumerable[untyped]?) ?{ (untyped) -> void } -> untyped 8 | 9 | def to_class: (untyped) -> untyped 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/components/navbar.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Components 4 | class Navbar 5 | attr_accessor current_slug: String? 6 | attr_accessor items: Array[Section] 7 | attr_accessor root_path: String 8 | attr_accessor root_title: String? 9 | 10 | def view_template: () ?{ (untyped) -> void } -> void 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/pages/root.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Pages 6 | class Root < DefaultLayout 7 | def view_template 8 | super do 9 | div(class: 'root') { 10 | render TinyAdmin::Views::Components::Widgets.new(widgets) 11 | } 12 | end 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/components/field_value.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Components 4 | class FieldValue 5 | attr_reader field: Field 6 | attr_reader record: untyped 7 | attr_reader value: untyped 8 | 9 | def initialize: (Field, untyped, record: untyped) -> void 10 | 11 | def view_template: () ?{ (untyped) -> void } -> void 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/features/pages/page_not_found_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe 'Page not found', type: :feature do 7 | before do 8 | visit '/admin/aaa' 9 | log_in 10 | end 11 | 12 | it 'loads the page not found', :aggregate_failures do 13 | expect(page).to have_current_path('/admin/aaa') 14 | expect(page).to have_content('Page not found') 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /spec/features/pages/pages_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe 'Pages', type: :feature do 7 | before do 8 | visit '/admin' 9 | log_in 10 | end 11 | 12 | it 'loads a sample page', :aggregate_failures do 13 | click_link('Sample page') 14 | expect(page).to have_current_path('/admin/sample') 15 | expect(page).to have_css('p', text: 'This is just a sample page') 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /extra/standalone_app/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # => ruby app.rb 4 | 5 | require 'bundler/inline' 6 | 7 | gemfile(true) do 8 | source 'https://rubygems.org' 9 | 10 | gem 'rackup' 11 | 12 | gem 'tiny_admin', path: '../../' 13 | end 14 | 15 | require_relative '../tiny_admin_settings' 16 | 17 | TinyAdmin.configure do |settings| 18 | settings.root_path = '/' 19 | end 20 | 21 | Rackup::Server.new(app: TinyAdmin::Router, Port: 3000).start if __FILE__ == $PROGRAM_NAME 22 | -------------------------------------------------------------------------------- /spec/features/pages/content_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe 'Content', type: :feature do 7 | before do 8 | visit '/admin' 9 | log_in 10 | end 11 | 12 | it 'loads a test content page', :aggregate_failures do 13 | click_link('Test content') 14 | expect(page).to have_current_path('/admin/test-content') 15 | expect(page).to have_css('p', text: 'Some test content') 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy_rails/db/migrate/20180607053739_create_posts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreatePosts < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :posts do |t| 6 | t.integer :state 7 | t.string :title 8 | t.text :description 9 | t.belongs_to :author, foreign_key: true 10 | t.string :category 11 | t.date :dt 12 | t.float :position 13 | t.boolean :published 14 | 15 | t.timestamps 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/pages/page_not_found.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Pages 6 | class PageNotFound < DefaultLayout 7 | def view_template 8 | super do 9 | div(class: 'page_not_found') { 10 | h1(class: 'title') { title } 11 | } 12 | end 13 | end 14 | 15 | def title 16 | 'Page not found' 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/pages/page_not_allowed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Pages 6 | class PageNotAllowed < DefaultLayout 7 | def view_template 8 | super do 9 | div(class: 'page_not_allowed') { 10 | h1(class: 'title') { title } 11 | } 12 | end 13 | end 14 | 15 | def title 16 | 'Page not allowed' 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/pages/record_not_found.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Pages 6 | class RecordNotFound < DefaultLayout 7 | def view_template 8 | super do 9 | div(class: 'record_not_found') { 10 | h1(class: 'title') { title } 11 | } 12 | end 13 | end 14 | 15 | def title 16 | 'Record not found' 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/tiny_admin/plugins/no_auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Plugins 5 | module NoAuth 6 | class << self 7 | def configure(_app, _opts = {}); end 8 | end 9 | 10 | module InstanceMethods 11 | def authenticate_user! 12 | true 13 | end 14 | 15 | def current_user 16 | 'admin' 17 | end 18 | 19 | def logout_user 20 | nil 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/pages/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Pages 6 | class Content < DefaultLayout 7 | def view_template 8 | super do 9 | div(class: 'content') { 10 | div(class: 'content-data') { 11 | unsafe_raw(content) 12 | } 13 | 14 | render TinyAdmin::Views::Components::Widgets.new(widgets) 15 | } 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /extra/hanami_app/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # => rackup -p 3000 4 | 5 | require 'bundler/inline' 6 | 7 | gemfile(true) do 8 | source 'https://rubygems.org' 9 | 10 | gem 'hanami-router' 11 | gem 'webrick' 12 | 13 | gem 'tiny_admin', path: '../../' 14 | end 15 | 16 | require 'hanami/router' 17 | 18 | require_relative '../tiny_admin_settings' 19 | 20 | app = Hanami::Router.new do 21 | root to: ->(_env) { [200, {}, ['Root page - go to /admin for TinyAdmin']] } 22 | 23 | mount TinyAdmin::Router, at: '/admin' 24 | end 25 | 26 | run app 27 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/basic_layout.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | class BasicLayout < Phlex::HTML 6 | include Utils 7 | 8 | attr_accessor :content, :params, :widgets 9 | 10 | def label_for(value, options: []) 11 | TinyAdmin.settings.helper_class.label_for(value, options: options) 12 | end 13 | 14 | def update_attributes(attributes) 15 | attributes.each do |key, value| 16 | send("#{key}=", value) 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/components/pagination.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Components 4 | class Pagination 5 | attr_accessor current: Integer 6 | attr_accessor pages: Integer 7 | attr_accessor query_string: String 8 | attr_accessor total_count: Integer 9 | 10 | def view_template: () ?{ (untyped) -> void } -> void 11 | 12 | private 13 | 14 | def dots: () -> void 15 | 16 | def pages_range: (Range[Integer], ?with_dots: bool) -> void 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /sig/tiny_admin/field.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | class Field 3 | attr_reader name: String 4 | attr_reader options: Hash[Symbol, untyped] 5 | attr_reader title: String 6 | attr_reader type: Symbol 7 | 8 | def initialize: (name: String, title: String, type: Symbol, options: Hash[Symbol, String]) -> void 9 | 10 | def apply_call_option: (untyped) -> void 11 | 12 | def translate_value: (untyped) -> String? 13 | 14 | def self.create_field: (name: String, ?title: String?, ?type: Symbol?, ?options: Hash[Symbol, untyped]) -> Field 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENV['RAILS_ENV'] ||= 'test' 6 | 7 | ENGINE_ROOT = File.expand_path('..', __dir__) 8 | APP_PATH = File.expand_path("../spec/dummy_rails/config/application", __dir__) 9 | 10 | # Set up gems listed in the Gemfile. 11 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 12 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 13 | 14 | require 'rails/all' 15 | require 'rails/engine/commands' 16 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/actions/show.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Actions 4 | class Show 5 | attr_accessor actions: Hash[String, untyped] 6 | attr_accessor fields: Hash[String, Field] 7 | attr_accessor prepare_record: Proc 8 | attr_accessor record: untyped 9 | attr_accessor reference: untyped 10 | attr_accessor slug: String 11 | 12 | def view_template: () ?{ (untyped) -> void } -> void 13 | 14 | private 15 | 16 | def actions_buttons: () -> void 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /sig/tiny_admin/settings.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | class Settings 3 | DEFAULTS: Hash[Array[Symbol], untyped] 4 | OPTIONS: Array[Symbol] 5 | 6 | @options: Hash[Array[Symbol], untyped] 7 | 8 | attr_reader store: Store 9 | 10 | def []: (*String | Symbol) -> untyped 11 | 12 | def []=: (*String | Symbol, untyped) -> untyped 13 | 14 | def load_settings: () -> void 15 | 16 | def reset!: () -> void 17 | 18 | private 19 | 20 | def convert_value: (untyped, untyped) -> void 21 | 22 | def fetch_setting: (Array[String | Symbol]) -> Array[untyped] 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/setup_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'with some data' do 4 | def setup_data(posts_count: 5) 5 | # Authors 6 | authors = Array.new(3) do 7 | author_ref = Author.count + 1 8 | Author.create!(name: "An author #{author_ref}", age: 24 + (author_ref * 3), email: "aaa#{author_ref}@bbb.ccc") 9 | end 10 | 11 | # Posts 12 | posts_count.times do |i| 13 | post_ref = Post.count + 1 14 | Post.create!(author: authors[i % 3], title: "A post #{post_ref + i}", description: 'Some post content') 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /extra/roda_app/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # => ruby app.rb 4 | 5 | require 'bundler/inline' 6 | 7 | gemfile(true) do 8 | source 'https://rubygems.org' 9 | 10 | gem 'rackup' 11 | gem 'roda' 12 | gem 'tiny_admin', path: '../../' 13 | end 14 | 15 | require_relative '../tiny_admin_settings' 16 | 17 | class RodaApp < Roda 18 | route do |r| 19 | r.root do 20 | 'Root page - go to /admin for TinyAdmin' 21 | end 22 | 23 | r.on 'admin' do 24 | r.run TinyAdmin::Router 25 | end 26 | end 27 | end 28 | 29 | Rackup::Server.new(app: RodaApp, Port: 3000).start if __FILE__ == $PROGRAM_NAME 30 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pry' 4 | require 'simplecov' 5 | 6 | SimpleCov.start do 7 | enable_coverage :branch 8 | 9 | add_filter '/spec/' 10 | end 11 | 12 | RSpec.configure do |config| 13 | config.color = true 14 | config.order = :random 15 | config.shared_context_metadata_behavior = :apply_to_host_groups 16 | config.tty = true 17 | 18 | config.expect_with :rspec do |expectations| 19 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 20 | end 21 | 22 | config.mock_with :rspec do |mocks| 23 | mocks.verify_partial_doubles = true 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/initializers/tiny_admin.rb: -------------------------------------------------------------------------------- 1 | Rails.application.config.to_prepare do 2 | config = Rails.root.join('config/tiny_admin.yml').to_s 3 | TinyAdmin.configure_from_file(config) 4 | 5 | # Settings can also be changed programmatically 6 | TinyAdmin.configure do |settings| 7 | settings.authentication[:password] = Digest::SHA512.hexdigest('changeme') 8 | # or 9 | # settings.authentication[:password] = 'f1891cea80fc05e433c943254c6bdabc159577a02a7395dfebbfbc4f7661d4af56f2d372131a45936de40160007368a56ef216a30cb202c66d3145fd24380906' 10 | 11 | settings.scripts = [ 12 | { src: '/bootstrap.bundle.min.js' } 13 | ] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /extra/sample_features_app/admin/sample_page.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Admin 4 | class SamplePage < TinyAdmin::Views::DefaultLayout 5 | def view_template 6 | super do 7 | h1 { 'Sample page' } 8 | p { 'This is a sample page' } 9 | end 10 | end 11 | end 12 | 13 | module SampleSection 14 | def to_h 15 | { 16 | slug: 'sample-page', 17 | name: 'Sample Page', 18 | type: :page, 19 | page: SamplePage 20 | } 21 | end 22 | 23 | module_function :to_h 24 | end 25 | end 26 | 27 | TinyAdmin.configure do |settings| 28 | (settings.sections ||= []).push(Admin::SampleSection) 29 | end 30 | -------------------------------------------------------------------------------- /sig/tiny_admin/support.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | class Support 3 | def self.call: (?untyped, options: Array[String]?) -> String? 4 | 5 | def self.downcase: (?untyped, options: Array[String]?) -> String? 6 | 7 | def self.format: (?untyped, options: Array[String]?) -> String? 8 | 9 | def self.label_for: (?untyped, options: Array[String]?) -> String? 10 | 11 | def self.round: (?untyped, options: Array[String]?) -> String? 12 | 13 | def self.strftime: (?untyped, options: Array[String]?) -> String? 14 | 15 | def self.to_date: (?untyped, options: Array[String]?) -> String? 16 | 17 | def self.upcase: (?untyped, options: Array[String]?) -> String? 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /extra/sample_features_app/admin/sample_page2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Admin 4 | class SamplePage2 < TinyAdmin::Views::DefaultLayout 5 | def view_template 6 | super do 7 | h1 { 'Sample page 2' } 8 | p { 'This is another sample page' } 9 | end 10 | end 11 | end 12 | 13 | module SampleSection2 14 | def to_h 15 | { 16 | slug: 'sample-page-2', 17 | name: 'Sample Page 2', 18 | type: :page, 19 | page: SamplePage2 20 | } 21 | end 22 | 23 | module_function :to_h 24 | end 25 | end 26 | 27 | TinyAdmin.configure do |settings| 28 | (settings.sections ||= []).push(Admin::SampleSection2) 29 | end 30 | -------------------------------------------------------------------------------- /spec/lib/tiny_admin_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'TinyAdmin' do 4 | describe '#configure_from_file' do 5 | subject(:configure_from_file) { TinyAdmin.configure_from_file(file) } 6 | 7 | let(:file) { file_fixture('basic_config.yml') } 8 | let(:root) { { title: 'Test' } } 9 | let(:settings) { instance_double(TinyAdmin::Settings, :[]= => nil, reset!: nil) } 10 | 11 | before do 12 | allow(TinyAdmin::Settings).to receive(:instance).and_return(settings) 13 | configure_from_file 14 | end 15 | 16 | it 'changes the settings' do 17 | expect(settings).to have_received(:[]=).with(:root, title: 'Test Admin!') 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | gemspec 7 | 8 | group :development, :test do 9 | gem 'rails', '~> 7.1' 10 | 11 | gem 'sqlite3', '< 2.0' 12 | gem 'tilt' 13 | gem 'warden' 14 | gem 'webrick' 15 | 16 | gem 'rbs' 17 | 18 | # Testing 19 | gem 'capybara' 20 | gem 'capybara-screenshot' 21 | gem 'rspec-rails' 22 | gem 'simplecov', require: false 23 | 24 | # Linters 25 | # gem 'fasterer' 26 | gem 'rubocop' 27 | gem 'rubocop-packaging' 28 | gem 'rubocop-performance' 29 | gem 'rubocop-rspec' 30 | 31 | # Tools 32 | # gem 'overcommit', '~> 0.59' 33 | gem 'pry-rails' 34 | end 35 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/components/head.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Components 6 | class Head < BasicComponent 7 | attr_accessor :extra_styles, :page_title, :style_links 8 | 9 | def view_template 10 | head { 11 | meta charset: 'utf-8' 12 | meta name: 'viewport', content: 'width=device-width, initial-scale=1' 13 | title { 14 | page_title 15 | } 16 | style_links.each do |style_link| 17 | link(**style_link) 18 | end 19 | style { extra_styles } if extra_styles 20 | } 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/tiny_admin/basic_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | class BasicApp < Roda 5 | include Utils 6 | 7 | class << self 8 | def authentication_plugin 9 | plugin = TinyAdmin.settings.authentication&.dig(:plugin) 10 | plugin_class = plugin.is_a?(String) ? Object.const_get(plugin) : plugin 11 | plugin_class || TinyAdmin::Plugins::NoAuth 12 | end 13 | end 14 | 15 | plugin :flash 16 | plugin :not_found 17 | plugin :render, engine: 'html' 18 | plugin :sessions, secret: SecureRandom.hex(64) 19 | 20 | plugin authentication_plugin, TinyAdmin.settings.authentication 21 | 22 | not_found { prepare_page(TinyAdmin.settings.page_not_found).call } 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/default_layout.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | class DefaultLayout 4 | attr_accessor flash_component: untyped? 5 | attr_accessor head_component: untyped? 6 | attr_accessor messages: Hash[Symbol, Array[String]?]? 7 | attr_accessor navbar_component: untyped? 8 | attr_accessor options: Array[Symbol]? 9 | attr_accessor title: String? 10 | 11 | def view_template: () ?{ (untyped) -> void } -> void 12 | 13 | private 14 | 15 | def body_class: () -> String 16 | 17 | def main_content: () { () -> void } -> void 18 | 19 | def render_scripts: () -> Array[untyped] 20 | 21 | def style_links: () -> Array[Hash[Symbol, String]] 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit_from: 3 | - https://relaxed.ruby.style/rubocop.yml 4 | 5 | require: 6 | - rubocop-packaging 7 | - rubocop-performance 8 | - rubocop-rspec 9 | 10 | AllCops: 11 | Exclude: 12 | - bin/* 13 | - spec/dummy_rails/**/* 14 | - vendor/**/* 15 | NewCops: enable 16 | SuggestExtensions: false 17 | TargetRubyVersion: 3.0 18 | 19 | Lint/MissingSuper: 20 | Exclude: 21 | - lib/tiny_admin/views/**/* 22 | 23 | Lint/UnusedMethodArgument: 24 | AllowUnusedKeywordArguments: true 25 | 26 | RSpec/ExampleLength: 27 | Max: 20 28 | 29 | RSpec/Rails/InferredSpecType: 30 | Enabled: false 31 | 32 | Style/ExplicitBlockArgument: 33 | Enabled: false 34 | 35 | Style/GuardClause: 36 | Exclude: 37 | - lib/tiny_admin/router.rb 38 | -------------------------------------------------------------------------------- /sig/tiny_admin/plugins/active_record_repository.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Plugins 3 | class ActiveRecordRepository 4 | def apply_filters: (untyped, Enumerable[untyped]) -> untyped 5 | 6 | def collection: () -> untyped 7 | 8 | def fields: (options: Hash[untyped, untyped]?) -> Hash[String, Field] 9 | 10 | def find: (untyped) -> untyped 11 | 12 | def index_record_attrs: (untyped, fields: Hash[untyped, untyped]?) -> Hash[untyped, untyped] 13 | 14 | def index_title: () -> String 15 | 16 | def list: (page: Integer, limit: Integer, sort: untyped?, filters: untyped?) -> Array[untyped] 17 | 18 | def show_title: (untyped) -> String 19 | 20 | alias show_record_attrs index_record_attrs 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/tiny_admin/actions/basic_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Actions 5 | class BasicAction 6 | include Utils 7 | 8 | def attribute_options(options) 9 | options&.each_with_object({}) do |field, result| 10 | field_data = 11 | if field.is_a?(Hash) 12 | if field.one? 13 | field, method = field.first 14 | { field.to_s => { field: field.to_s, method: method } } 15 | else 16 | { field[:field] => field } 17 | end 18 | else 19 | { field => { field: field } } 20 | end 21 | result.merge!(field_data) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/features/components/navbar_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe 'Navbar component', type: :feature do 7 | before do 8 | visit '/admin' 9 | log_in 10 | end 11 | 12 | it 'shows the navbar buttons', :aggregate_failures do 13 | expect(page).to have_link('Test Admin', href: '/admin', class: 'navbar-brand') 14 | expect(page).to have_link('Google.it', href: 'https://www.google.it', class: 'nav-link') 15 | expect(page).to have_link('Sample page', href: '/admin/sample', class: 'nav-link') 16 | expect(page).to have_link('Authors', href: '/admin/authors', class: 'nav-link') 17 | expect(page).to have_link('Posts', href: '/admin/posts', class: 'nav-link') 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [blocknotes] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /sig/tiny_admin/store.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | class Store 3 | attr_reader navbar: Array[Section] 4 | attr_reader pages: Hash[String, Hash[Symbol, untyped]] 5 | attr_reader resources: Hash[String, Hash[Symbol, untyped]] 6 | attr_reader settings: Settings 7 | 8 | def initialize: (Settings) -> void 9 | 10 | def prepare_sections: (Array[untyped], logout: TinyAdmin::Section?) -> void 11 | 12 | private 13 | 14 | def add_content_section: (String, Hash[Symbol, untyped]) -> TinyAdmin::Section 15 | 16 | def add_page_section: (String, Hash[Symbol, untyped]) -> TinyAdmin::Section 17 | 18 | def add_resource_section: (String, Hash[Symbol, untyped]) -> TinyAdmin::Section 19 | 20 | def add_url_section: (String, Hash[Symbol, untyped]) -> TinyAdmin::Section 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /sig/tiny_admin/views/actions/index.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Views 3 | module Actions 4 | class Index 5 | attr_accessor actions: Hash[String, untyped] 6 | attr_accessor fields: Hash[String, Field] 7 | attr_accessor filters: Hash[Field, Hash[Symbol, untyped]]? 8 | attr_accessor links: Array[String]? 9 | attr_accessor pagination_component: untyped 10 | attr_accessor prepare_record: Proc 11 | attr_accessor records: Enumerable[untyped] 12 | attr_accessor slug: String 13 | 14 | def view_template: () ?{ (untyped) -> void } -> void 15 | 16 | private 17 | 18 | def actions_buttons: () -> void 19 | 20 | def table_body: () -> void 21 | 22 | def table_header: () -> void 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /bin/rbs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rbs' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rbs", "rbs") 28 | -------------------------------------------------------------------------------- /sig/tiny_admin/router.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | class Router 3 | @store: Store 4 | 5 | private 6 | 7 | def authorization: () -> untyped 8 | 9 | def render_page: (untyped) -> void 10 | 11 | def root_route: (untyped) -> void 12 | 13 | def setup_collection_routes: (untyped, String, options: Hash[Symbol, untyped]) -> void 14 | 15 | def setup_custom_actions: (untyped, Array[Hash[Symbol, untyped]]?, options: Hash[Symbol, untyped], repository: untyped, slug: String, ?reference: untyped?) -> Hash[String, untyped] 16 | 17 | def setup_member_routes: (untyped, String, Hash[Symbol, untyped]) -> void 18 | 19 | def setup_page_route: (untyped, String, Hash[Symbol, untyped]) -> void 20 | 21 | def setup_resource_routes: (untyped, String, Hash[Symbol, untyped]) -> void 22 | 23 | def store: () -> Store 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rspec-core", "rspec") 28 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rubocop", "rubocop") 28 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/components/flash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Components 6 | class Flash < BasicComponent 7 | attr_accessor :messages 8 | 9 | def view_template 10 | @messages ||= {} 11 | notices = messages[:notices] 12 | warnings = messages[:warnings] 13 | errors = messages[:errors] 14 | 15 | div(class: 'flash') { 16 | div(class: 'notices alert alert-success', role: 'alert') { notices.join(', ') } if notices&.any? 17 | div(class: 'notices alert alert-warning', role: 'alert') { warnings.join(', ') } if warnings&.any? 18 | div(class: 'notices alert alert-danger', role: 'alert') { errors.join(', ') } if errors&.any? 19 | } 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /extra/rails_app/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # => ruby app.rb 4 | 5 | require 'bundler/inline' 6 | 7 | gemfile(true) do 8 | source 'https://rubygems.org' 9 | 10 | gem 'rails', '~> 7' 11 | gem 'tiny_admin', path: '../../' 12 | end 13 | 14 | require 'action_controller/railtie' 15 | require_relative '../tiny_admin_settings' 16 | 17 | class RailsApp < Rails::Application 18 | routes.append do 19 | root to: proc { [200, {}, ['Root page - go to /admin for TinyAdmin']] } 20 | 21 | mount TinyAdmin::Router => '/admin' 22 | end 23 | 24 | config.action_dispatch.show_exceptions = :none 25 | config.active_support.cache_format_version = 7.1 26 | config.consider_all_requests_local = false 27 | config.eager_load = false 28 | end 29 | 30 | RailsApp.initialize! 31 | 32 | Rack::Server.new(app: RailsApp, Port: 3000).start if __FILE__ == $PROGRAM_NAME 33 | -------------------------------------------------------------------------------- /sig/tiny_admin/actions/index.rbs: -------------------------------------------------------------------------------- 1 | module TinyAdmin 2 | module Actions 3 | class Index 4 | attr_reader context: untyped 5 | attr_reader current_page: Integer 6 | attr_reader fields_options: untyped 7 | attr_reader links: untyped 8 | attr_reader options: untyped 9 | attr_reader pagination: Integer 10 | attr_reader pages: untyped 11 | attr_reader params: untyped 12 | attr_reader query_string: String 13 | attr_reader repository: untyped 14 | 15 | def call: (app: BasicApp, context: Context, options: Hash[Symbol, untyped]) -> void 16 | 17 | private 18 | 19 | def evaluate_options: (Hash[Symbol, untyped]) -> void 20 | 21 | def prepare_filters: (Hash[untyped, untyped]) -> void 22 | 23 | def setup_pagination: (untyped, untyped, total_count: Integer) -> void 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/lib/tiny_admin/plugins/no_auth_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe TinyAdmin::Plugins::NoAuth do 7 | let(:some_class) do 8 | Class.new do 9 | include TinyAdmin::Plugins::NoAuth::InstanceMethods 10 | end 11 | end 12 | 13 | before { stub_const('SomeClass', some_class) } 14 | 15 | describe '#authenticate_user!' do 16 | subject(:authenticate_user!) { SomeClass.new.authenticate_user! } 17 | 18 | it { is_expected.to be_truthy } 19 | end 20 | 21 | describe '#current_user' do 22 | subject(:current_user) { SomeClass.new.current_user } 23 | 24 | it { is_expected.to eq 'admin' } 25 | end 26 | 27 | describe '#logout_user' do 28 | subject(:logout_user) { SomeClass.new.logout_user } 29 | 30 | it { is_expected.to be_nil } 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | config.load_defaults Rails::VERSION::STRING.to_f 12 | 13 | # For compatibility with applications that use this config 14 | config.action_controller.include_all_helpers = false 15 | 16 | # Configuration for the application, engines, and railties goes here. 17 | # 18 | # These settings can be overridden in specific environments using the files 19 | # in config/environments, which are processed later. 20 | # 21 | # config.time_zone = "Central Time (US & Canada)" 22 | # config.eager_load_paths << Rails.root.join("extras") 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/features/pages/root_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe 'Root', type: :feature do 7 | context 'when opening the root url' do 8 | before do 9 | visit '/admin' 10 | log_in 11 | end 12 | 13 | it 'loads the root page', :aggregate_failures do 14 | expect(page).to have_current_path('/admin') 15 | expect(page).to have_css('.root') 16 | end 17 | end 18 | 19 | context 'when redirect option is set' do 20 | before do 21 | allow(TinyAdmin.settings).to receive(:root).and_return(redirect: 'posts') 22 | visit '/admin' 23 | log_in 24 | end 25 | 26 | it 'loads the root page', :aggregate_failures do 27 | expect(page).to have_current_path('/admin/posts') 28 | expect(page).to have_css('h1.title', text: 'Posts') 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: 5 | pull_request: 6 | branches: [main] 7 | push: 8 | branches: [main] 9 | 10 | jobs: 11 | tests: 12 | name: RSpec 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | ruby: ['3.0', '3.1', '3.2'] 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | 29 | - name: Create database 30 | run: cd spec/dummy_rails && bundle exec rails db:create 31 | 32 | - name: Apply migrations 33 | run: cd spec/dummy_rails && bundle exec rails db:migrate 34 | 35 | - name: Run tests 36 | env: 37 | RUBYOPT: '-rbundler/setup -rrbs/test/setup' 38 | RBS_TEST_TARGET: 'TinyAdmin::*' 39 | run: bin/rspec --profile 40 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linters 3 | 4 | on: 5 | pull_request: 6 | branches: [main] 7 | push: 8 | branches: [main] 9 | 10 | jobs: 11 | reviewdog: 12 | name: Reviewdog 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: '3.0' 23 | bundler-cache: true 24 | 25 | - name: Set up Reviewdog 26 | uses: reviewdog/action-setup@v1 27 | with: 28 | reviewdog_version: latest 29 | 30 | - name: Run Reviewdog 31 | env: 32 | REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | run: | 34 | reviewdog -fail-on-error -reporter=github-pr-review -runners=rubocop 35 | 36 | # NOTE: check with: reviewdog -fail-on-error -reporter=github-pr-review -runners=fasterer -diff="git diff" -tee 37 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/components/widgets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Components 6 | class Widgets < BasicComponent 7 | def initialize(widgets) 8 | @widgets = widgets 9 | end 10 | 11 | def view_template 12 | return if @widgets.nil? || @widgets.empty? 13 | 14 | div(class: 'container widgets') { 15 | @widgets.each_slice(2).each do |row| 16 | div(class: 'row') { 17 | row.each do |widget| 18 | next unless widget < Phlex::HTML 19 | 20 | div(class: 'col') { 21 | div(class: 'card') { 22 | div(class: 'card-body') { 23 | render widget.new 24 | } 25 | } 26 | } 27 | end 28 | } 29 | end 30 | } 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /lib/tiny_admin/support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | class Support 5 | class << self 6 | def call(value, options: []) 7 | options.inject(value) { |result, message| result&.send(message) } if value && options&.any? 8 | end 9 | 10 | def downcase(value, options: []) 11 | value&.downcase 12 | end 13 | 14 | def format(value, options: []) 15 | Kernel.format(options.first, value) if value && options&.any? 16 | end 17 | 18 | def label_for(value, options: []) 19 | value 20 | end 21 | 22 | def round(value, options: []) 23 | value&.round(options&.first&.to_i || 2) 24 | end 25 | 26 | def strftime(value, options: []) 27 | value&.strftime(options&.first || '%Y-%m-%d %H:%M') 28 | end 29 | 30 | def to_date(value, options: []) 31 | value.to_date.to_s if value 32 | end 33 | 34 | def upcase(value, options: []) 35 | value&.upcase 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /extra/tiny_admin_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SamplePage < TinyAdmin::Views::DefaultLayout 4 | def view_template 5 | super do 6 | h1 { 'Sample page' } 7 | p { 'This is a sample page' } 8 | end 9 | end 10 | end 11 | 12 | class SamplePage2 < TinyAdmin::Views::DefaultLayout 13 | def view_template 14 | super do 15 | h1 { 'Sample page 2' } 16 | p { 'This is another sample page' } 17 | end 18 | end 19 | end 20 | 21 | TinyAdmin.configure do |settings| 22 | settings.root_path = '/admin' 23 | settings.root = { 24 | redirect: 'sample-page' 25 | } 26 | settings.sections = [ 27 | { 28 | slug: 'sample-page', 29 | name: 'Sample Page', 30 | type: :page, 31 | page: SamplePage 32 | }, 33 | { 34 | slug: 'sample-page-2', 35 | name: 'Sample Page 2', 36 | type: :page, 37 | page: SamplePage2 38 | } 39 | ] 40 | settings.extra_styles = <<~CSS 41 | .navbar { 42 | background-color: var(--bs-cyan); 43 | } 44 | CSS 45 | end 46 | -------------------------------------------------------------------------------- /lib/tiny_admin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'phlex' 4 | require 'roda' 5 | require 'zeitwerk' 6 | 7 | require 'forwardable' 8 | require 'singleton' 9 | require 'yaml' 10 | 11 | loader = Zeitwerk::Loader.for_gem 12 | loader.setup 13 | 14 | module TinyAdmin 15 | def configure(&block) 16 | block&.call(settings) || settings 17 | end 18 | 19 | def configure_from_file(file) 20 | settings.reset! 21 | config = YAML.load_file(file, symbolize_names: true) 22 | config.each do |key, value| 23 | settings[key] = value 24 | end 25 | end 26 | 27 | def route_for(section, reference: nil, action: nil, query: nil) 28 | root_path = settings.root_path == '/' ? nil : settings.root_path 29 | route = [root_path, section, reference, action].compact.join("/") 30 | route << "?#{query}" if query 31 | route[0] == '/' ? route : route.prepend('/') 32 | end 33 | 34 | def settings 35 | TinyAdmin::Settings.instance 36 | end 37 | 38 | module_function :configure, :configure_from_file, :route_for, :settings 39 | end 40 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Prevent database truncation if the environment is production 4 | abort("The Rails environment is running in production mode!") if Rails.env.production? 5 | 6 | require 'rspec/rails' 7 | require 'capybara/rails' 8 | require 'capybara-screenshot/rspec' 9 | 10 | Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } 11 | 12 | if defined? ActiveRecord 13 | # Checks for pending migrations and applies them before tests are run. 14 | # If you are not using ActiveRecord, you can remove these lines. 15 | begin 16 | ActiveRecord::Migration.maintain_test_schema! 17 | rescue ActiveRecord::PendingMigrationError => e 18 | puts e.to_s.strip 19 | exit 1 20 | end 21 | end 22 | 23 | RSpec.configure do |config| 24 | config.use_transactional_fixtures = true 25 | config.infer_spec_type_from_file_location! 26 | 27 | config.filter_rails_from_backtrace! 28 | config.disable_monkey_patching! 29 | 30 | config.include_context 'with some data' 31 | config.include_context 'Capybara helpers' 32 | end 33 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/components/field_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Components 6 | class FieldValue < BasicComponent 7 | attr_reader :field, :value, :record 8 | 9 | def initialize(field, value, record:) 10 | @field = field 11 | @value = value 12 | @record = record 13 | end 14 | 15 | def view_template 16 | translated_value = field.translate_value(value) 17 | value_class = field.options[:options]&.include?('value_class') ? "value-#{value}" : nil 18 | if field.options[:link_to] 19 | a(href: TinyAdmin.route_for(field.options[:link_to], reference: translated_value)) { 20 | span(class: value_class) { 21 | field.apply_call_option(record) || translated_value 22 | } 23 | } 24 | else 25 | span(class: value_class) { 26 | translated_value 27 | } 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Mattia Roccoberton 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/tiny_admin/actions/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Actions 5 | class Show < BasicAction 6 | def call(app:, context:, options:) 7 | fields_options = attribute_options(options[:attributes]) 8 | repository = context.repository 9 | record = repository.find(context.reference) 10 | prepare_record = ->(record_data) { repository.show_record_attrs(record_data, fields: fields_options) } 11 | attributes = { 12 | actions: context.actions, 13 | fields: repository.fields(options: fields_options), 14 | prepare_record: prepare_record, 15 | record: record, 16 | reference: context.reference, 17 | slug: context.slug, 18 | title: repository.show_title(record), 19 | widgets: options[:widgets] 20 | } 21 | 22 | prepare_page(Views::Actions::Show, slug: context.slug, attributes: attributes) 23 | rescue Plugins::BaseRepository::RecordNotFound => _e 24 | prepare_page(options[:record_not_found_page] || Views::Pages::RecordNotFound) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/models/author.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # - Create a profile: 4 | # author.build_profile description: 'Just a desc' 5 | # author.save 6 | class Author < ApplicationRecord 7 | # has_many :posts do 8 | # def filtered # Association Extensions 9 | # where('created_at < ?', Date.today) 10 | # end 11 | # end 12 | 13 | has_many :posts, dependent: :nullify 14 | has_many :published_posts, -> { published }, class_name: 'Post', dependent: :nullify, inverse_of: :author 15 | has_many :recent_posts, -> { recents }, class_name: 'Post', dependent: :nullify, inverse_of: :author 16 | 17 | # has_many :posts, inverse_of: :author, dependent: :nullify 18 | 19 | has_one :profile, inverse_of: :author, dependent: :destroy 20 | 21 | accepts_nested_attributes_for :profile, allow_destroy: true 22 | 23 | validates :email, format: { with: /\A[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\z/i, message: 'Invalid email' } 24 | 25 | validate -> { 26 | errors.add( :base, 'Invalid age' ) if !age || age.to_i % 3 == 1 27 | } 28 | 29 | # validate :custom 30 | 31 | def to_s 32 | "#{name} (#{age})" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/dummy_rails/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /spec/dummy_rails/app/models/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # - Create a post: 4 | # Post.create! title: 'A post', author: Author.first 5 | # Post.last.tags << Tag.last 6 | class Post < ApplicationRecord 7 | enum state: { available: 0, unavailable: 1, arriving: 2 } 8 | 9 | belongs_to :author, inverse_of: :posts, autosave: true 10 | # with autosave if you change an attribute using the association, calling a save on parent will propagate the changes 11 | 12 | has_one :author_profile, through: :author, source: :profile 13 | 14 | has_many :post_tags, inverse_of: :post, dependent: :destroy 15 | has_many :tags, through: :post_tags 16 | 17 | validates :title, allow_blank: false, presence: true 18 | 19 | scope :published, -> { where(published: true) } 20 | scope :recents, -> { where('created_at > ?', Date.current - 8.months) } 21 | 22 | # # override a field - can be dangerous 23 | # def title 24 | # "<<<#{super}>>>" 25 | # end 26 | 27 | def short_title(**args) 28 | title.truncate(args[:count] || 10) 29 | end 30 | 31 | def upper_title 32 | title.upcase 33 | end 34 | 35 | def old_method 36 | ActiveRecord::Base.allow_unsafe_raw_sql = true 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/pages/simple_auth_login.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Pages 6 | class SimpleAuthLogin < DefaultLayout 7 | def view_template 8 | super do 9 | div(class: 'simple_auth_login') { 10 | h1(class: 'title') { title } 11 | 12 | form(class: 'form_login', method: 'post') { 13 | div(class: 'mt-3') { 14 | label(for: 'secret', class: 'form-label') { 15 | label_for('Password', options: ['pages.simple_auth_login.inputs.password']) 16 | } 17 | input(type: 'password', name: 'secret', class: 'form-control', id: 'secret') 18 | } 19 | 20 | div(class: 'mt-3') { 21 | button(type: 'submit', class: 'button_login btn btn-primary') { 22 | label_for('Login', options: ['pages.simple_auth_login.buttons.submit']) 23 | } 24 | } 25 | } 26 | } 27 | end 28 | end 29 | 30 | def title 31 | 'Login' 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /tiny_admin.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $:.push File.expand_path('lib', __dir__) 4 | 5 | require 'tiny_admin/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.platform = Gem::Platform::RUBY 9 | spec.name = 'tiny_admin' 10 | spec.version = TinyAdmin::VERSION 11 | spec.summary = 'Tiny Admin' 12 | spec.description = 'A compact and composable dashboard component for Ruby' 13 | spec.license = 'MIT' 14 | 15 | spec.required_ruby_version = '>= 3.0.0' 16 | 17 | spec.author = 'Mattia Roccoberton' 18 | spec.email = 'mat@blocknot.es' 19 | spec.homepage = 'https://github.com/blocknotes/tiny_admin' 20 | 21 | spec.metadata = { 22 | 'homepage_uri' => spec.homepage, 23 | 'source_code_uri' => spec.homepage, 24 | 'changelog_uri' => 'https://github.com/blocknotes/tiny_admin/blob/main/CHANGELOG.md', 25 | 'rubygems_mfa_required' => 'true' 26 | } 27 | 28 | spec.files = Dir['{app,db,lib}/**/*', 'LICENSE.txt', 'README.md'] 29 | spec.require_paths = ['lib'] 30 | 31 | spec.add_runtime_dependency 'phlex', '~> 1', '>= 1.10.0' 32 | spec.add_runtime_dependency 'roda', '~> 3' 33 | spec.add_runtime_dependency 'tilt', '~> 2' 34 | spec.add_runtime_dependency 'zeitwerk', '~> 2' 35 | end 36 | -------------------------------------------------------------------------------- /lib/tiny_admin/authentication.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | class Authentication < BasicApp 5 | route do |r| 6 | r.get 'unauthenticated' do 7 | if current_user 8 | r.redirect TinyAdmin.settings.root_path 9 | else 10 | render_login 11 | end 12 | end 13 | 14 | r.post 'unauthenticated' do 15 | warning = TinyAdmin.settings.helper_class.label_for( 16 | 'Failed to authenticate', 17 | options: ['authentication.unauthenticated'] 18 | ) 19 | render_login(warnings: [warning]) 20 | end 21 | 22 | r.get 'logout' do 23 | logout_user 24 | r.redirect TinyAdmin.settings.root_path 25 | end 26 | end 27 | 28 | private 29 | 30 | def render_login(notices: nil, warnings: nil, errors: nil) 31 | login = TinyAdmin.settings.authentication[:login] 32 | return unless login 33 | 34 | page = prepare_page(login, options: %i[no_menu compact_layout]) 35 | page.messages = { 36 | notices: notices || flash['notices'], 37 | warnings: warnings || flash['warnings'], 38 | errors: errors || flash['errors'] 39 | } 40 | render(inline: page.call) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report CSP violations to a specified URI. See: 24 | # # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 25 | # # config.content_security_policy_report_only = true 26 | # end 27 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /lib/tiny_admin/plugins/simple_auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'digest' 4 | 5 | module TinyAdmin 6 | module Plugins 7 | module SimpleAuth 8 | class << self 9 | def configure(app, opts = {}) 10 | @@opts = opts || {} # rubocop:disable Style/ClassVars 11 | @@opts[:password] ||= ENV.fetch('ADMIN_PASSWORD_HASH', nil) # NOTE: fallback value 12 | 13 | Warden::Strategies.add(:secret) do 14 | def authenticate! 15 | secret = params['secret'] || '' 16 | return fail(:invalid_credentials) if Digest::SHA512.hexdigest(secret) != @@opts[:password] 17 | 18 | success!(app: 'TinyAdmin') 19 | end 20 | end 21 | 22 | app.opts[:login_form] = opts[:login_form] || TinyAdmin::Views::Pages::SimpleAuthLogin 23 | app.use Warden::Manager do |manager| 24 | manager.default_strategies :secret 25 | manager.failure_app = TinyAdmin::Authentication 26 | end 27 | end 28 | end 29 | 30 | module InstanceMethods 31 | def authenticate_user! 32 | env['warden'].authenticate! 33 | end 34 | 35 | def current_user 36 | env['warden'].user 37 | end 38 | 39 | def logout_user 40 | env['warden'].logout 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/tiny_admin/field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | class Field 5 | attr_reader :name, :options, :title, :type 6 | 7 | def initialize(name:, title:, type:, options: {}) 8 | @type = type 9 | @name = name 10 | @title = title 11 | @options = options 12 | end 13 | 14 | def apply_call_option(target) 15 | messages = (options[:call] || '').split(',').map(&:strip) 16 | messages.inject(target) { |result, msg| result&.send(msg) } if messages.any? 17 | end 18 | 19 | def translate_value(value) 20 | if options && options[:method] 21 | method, *args = options[:method].split(',').map(&:strip) 22 | if options[:converter] 23 | Object.const_get(options[:converter]).send(method, value, options: args || []) 24 | else 25 | TinyAdmin.settings.helper_class.send(method, value, options: args || []) 26 | end 27 | else 28 | value&.to_s 29 | end 30 | end 31 | 32 | class << self 33 | def create_field(name:, title: nil, type: nil, options: {}) 34 | field_name = name.to_s 35 | field_title = field_name.respond_to?(:humanize) ? field_name.humanize : field_name.tr('_', ' ').capitalize 36 | new(name: field_name, title: title || field_title, type: type || :string, options: options || {}) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # TinyAdmin 2 | 3 | A compact and composable dashboard component for Ruby 4 | 5 | ## 0.10.1 6 | 7 | - refactor: change Phlex template references to view_template 8 | 9 | ## 0.10.0 10 | 11 | - feat: authorization support 12 | - feat: RBS signatures 13 | 14 | ## 0.9.0 15 | 16 | - feat: expose pagination total count 17 | - feat: handle params in pages 18 | - feat: support a setup method for pages 19 | 20 | ## 0.8.0 21 | 22 | - feat: new label_for support method (useful for translations) 23 | - feat: value_class field option 24 | - feat: internal improvements 25 | 26 | ## 0.7.0 27 | 28 | - feat: widgets 29 | - feat: internal changes => context, store, section classes 30 | 31 | ## 0.6.0 32 | 33 | - feat: new content section type 34 | - feat: improve settings system 35 | 36 | ## 0.5.0 37 | 38 | - feat: customize index entries links 39 | - feat: support config Objects for sections 40 | - feat: internal improvements 41 | 42 | ## 0.4.0 43 | 44 | - feat: introduce a Support class for attributes' formatters 45 | - feat: improve routing method 46 | - feat: internal improvements 47 | 48 | ## 0.3.0 49 | 50 | - feat: pagination improvements 51 | - feat: components improvements 52 | - feat: improve index action 53 | 54 | ## 0.2.1 55 | 56 | - fix: correct missing password hash 57 | - fix: correct pages count for pagination in index action 58 | 59 | ## 0.2.0 60 | 61 | - feat: improve layout structure 62 | - feat: improve routing methods 63 | - feat: load authentication options from config 64 | - deps: add tilt dependency and update gemspec dependencies versions 65 | - docs: improve documentation and add more usage examples 66 | 67 | ## 0.1.0 68 | 69 | - First release 70 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/components/navbar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Components 6 | class Navbar < BasicComponent 7 | attr_accessor :current_slug, :items, :root_path, :root_title 8 | 9 | def view_template 10 | nav(class: 'navbar navbar-expand-lg') { 11 | div(class: 'container') { 12 | a(class: 'navbar-brand', href: root_path) { root_title } 13 | button( 14 | class: 'navbar-toggler', 15 | type: 'button', 16 | 'data-bs-toggle' => 'collapse', 17 | 'data-bs-target' => '#navbarNav', 18 | 'aria-controls' => 'navbarNav', 19 | 'aria-expanded' => 'false', 20 | 'aria-label' => 'Toggle navigation' 21 | ) { 22 | span(class: 'navbar-toggler-icon') 23 | } 24 | div(class: 'collapse navbar-collapse', id: 'navbarNav') { 25 | ul(class: 'navbar-nav') { 26 | items.each do |item| 27 | classes = %w[nav-link] 28 | classes << 'active' if item.slug == current_slug 29 | link_attributes = { class: classes.join(' '), href: item.path, 'aria-current' => 'page' } 30 | link_attributes.merge!(item.options) if item.options 31 | 32 | li(class: 'nav-item') { 33 | a(**link_attributes) { item.name } 34 | } 35 | end 36 | } 37 | } 38 | } 39 | } 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/features/components/pagination_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe 'Pagination component', type: :feature do 7 | def page_links 8 | find_all('a.page-link').map(&:text) 9 | end 10 | 11 | context 'with a collection with less than 10 pages' do 12 | before do 13 | setup_data(posts_count: 15) 14 | visit '/admin' 15 | log_in 16 | click_link('Posts') 17 | end 18 | 19 | it 'shows the pagination widget', :aggregate_failures do 20 | expect(page).to have_css('ul', class: 'pagination') 21 | expect(page).to have_link('1', class: 'page-link', href: '?p=1') 22 | expect(page_links).to eq(['1', '2', '3']) 23 | end 24 | end 25 | 26 | context 'with a collection with more than 10 pages' do 27 | before do 28 | setup_data(posts_count: 60) 29 | visit '/admin' 30 | log_in 31 | end 32 | 33 | it 'shows the pagination widget', :aggregate_failures do 34 | click_link('Posts') 35 | 36 | expect(page).to have_css('ul', class: 'pagination') 37 | expect(page).to have_link('1', class: 'page-link', href: '?p=12') 38 | expect(page_links).to eq(['1', '2', '3', '...', '10', '11', '12']) 39 | end 40 | 41 | context 'when opening a specific page' do 42 | before do 43 | visit '/admin/posts?p=6' 44 | end 45 | 46 | it 'shows the pagination widget', :aggregate_failures do 47 | expect(page).to have_css('ul', class: 'pagination') 48 | expect(page).to have_link('6', class: 'page-link', href: '?p=6') 49 | expect(page_links).to eq(['1', '...', '4', '5', '6', '7', '8', '...', '12']) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /extra/sample_features_app/admin/items.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Admin 4 | COLUMNS = %i[id name full_address phone_number].freeze 5 | 6 | Column = Struct.new(:name, :title, :type, :options) 7 | Item = Struct.new(*COLUMNS) 8 | 9 | RECORDS = 1.upto(100).map do |i| 10 | Item.new(i, Faker::Name.name, Faker::Address.full_address, Faker::PhoneNumber.phone_number) 11 | end 12 | 13 | class ItemsRepo < ::TinyAdmin::Plugins::BaseRepository 14 | def fields(options: nil) 15 | COLUMNS.each_with_object({}) do |name, result| 16 | result[name] = TinyAdmin::Field.create_field(name: name) 17 | end 18 | end 19 | 20 | def index_record_attrs(record, fields: nil) 21 | record.to_h 22 | end 23 | 24 | def index_title 25 | "Items" 26 | end 27 | 28 | def list(page: 1, limit: 10, filters: nil, sort: ['id']) 29 | page_offset = page.positive? ? (page - 1) * limit : 0 30 | [ 31 | RECORDS[page_offset...page_offset + limit], 32 | RECORDS.size 33 | ] 34 | end 35 | 36 | # --- 37 | 38 | def find(reference) 39 | RECORDS.find { _1.id == reference.to_i } || raise(TinyAdmin::Plugins::BaseRepository::RecordNotFound) 40 | end 41 | 42 | def show_record_attrs(record, fields: nil) 43 | record.to_h 44 | end 45 | 46 | def show_title(_record) 47 | "Item" 48 | end 49 | end 50 | 51 | module ItemSection 52 | def to_h 53 | { 54 | slug: 'items', 55 | name: 'Items', 56 | type: :resource, 57 | model: Item, 58 | repository: ItemsRepo 59 | } 60 | end 61 | 62 | module_function :to_h 63 | end 64 | end 65 | 66 | TinyAdmin.configure do |settings| 67 | (settings.sections ||= []).push(Admin::ItemSection) 68 | end 69 | -------------------------------------------------------------------------------- /lib/tiny_admin/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Utils 5 | def params_to_s(params) 6 | list = params.each_with_object([]) do |(param, value), result| 7 | if value.is_a?(Hash) 8 | values = value.map { |key, val| "#{param}[#{key}]=#{val}" } 9 | result.concat(values) 10 | else 11 | result.push(["#{param}=#{value}"]) 12 | end 13 | end 14 | list.join('&') 15 | end 16 | 17 | def prepare_page(page_class, slug: nil, attributes: nil, options: nil, params: nil) 18 | page_class.new.tap do |page| 19 | page.options = options 20 | page.head_component = TinyAdmin.settings.components[:head]&.new 21 | page.flash_component = TinyAdmin.settings.components[:flash]&.new 22 | page.navbar_component = TinyAdmin.settings.components[:navbar]&.new 23 | page.navbar_component&.update_attributes( 24 | current_slug: slug, 25 | root_path: TinyAdmin.settings.root_path, 26 | root_title: TinyAdmin.settings.root[:title], 27 | items: options&.include?(:no_menu) ? [] : TinyAdmin.settings.store&.navbar 28 | ) 29 | attrs = attributes || {} 30 | attrs[:params] = params if params 31 | attrs[:widgets] = attrs[:widgets].map { to_class(_1) } if attrs[:widgets] 32 | page.update_attributes(attrs) unless attrs.empty? 33 | yield(page) if block_given? 34 | page.setup if page.respond_to?(:setup) 35 | end 36 | end 37 | 38 | def to_class(klass) 39 | klass.is_a?(String) ? Object.const_get(klass) : klass 40 | end 41 | 42 | def humanize(string) 43 | return '' unless string 44 | 45 | string.respond_to?(:humanize) ? string.humanize : string.tr('_', ' ').capitalize 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/dummy_rails/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/features/plugins/authenticator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe 'Authenticator plugin', type: :feature do 7 | before do 8 | visit '/admin' 9 | end 10 | 11 | it 'shows the login form', :aggregate_failures do 12 | expect(page).to have_current_path('/admin') 13 | expect(page).to have_content('Login') 14 | expect(page).to have_css('form.form_login .button_login') 15 | end 16 | 17 | context 'when the authentication fails' do 18 | it 'shows the authentication error', :aggregate_failures do 19 | expect(page).not_to have_content('Failed to authenticate') 20 | log_in(password: 'wrong password') 21 | expect(page).to have_content('Failed to authenticate') 22 | expect(page).to have_current_path('/admin') 23 | end 24 | end 25 | 26 | context 'when the authentication succeed' do 27 | it 'proceeds with the login', :aggregate_failures do 28 | expect(page).to have_css('form.form_login') 29 | log_in 30 | expect(page).not_to have_content('Failed to authenticate') 31 | expect(page).not_to have_css('form.form_login') 32 | end 33 | end 34 | 35 | context 'when the user is logged in' do 36 | before { log_in } 37 | 38 | it 'proceeds with the logout', :aggregate_failures do 39 | expect(page).not_to have_css('form.form_login') 40 | click_link('logout') 41 | expect(page).to have_css('form.form_login') 42 | expect(page).to have_content('Login') 43 | end 44 | 45 | context 'when the user reopen the login page' do 46 | it 'redirects to the root page', :aggregate_failures do 47 | expect(page).not_to have_css('form.form_login') 48 | visit '/admin/auth/unauthenticated' 49 | expect(page).to have_current_path('/admin') 50 | expect(page).not_to have_css('form.form_login') 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /spec/dummy_rails/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy_rails/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/components/pagination.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Components 6 | class Pagination < BasicComponent 7 | attr_accessor :current, :pages, :query_string, :total_count 8 | 9 | def view_template 10 | div(class: 'container') { 11 | div(class: 'row') { 12 | div(class: 'col total-count') { 13 | "#{total_count} items" 14 | } 15 | div(class: 'col col-6 text-center pagination-div') { 16 | nav(class: 'd-inline-block', 'aria-label': 'Pagination') { 17 | ul(class: 'pagination') { 18 | if pages <= 10 19 | pages_range(1..pages) 20 | elsif current <= 4 || current >= pages - 3 21 | pages_range(1..(current <= 4 ? current + 2 : 4), with_dots: true) 22 | pages_range((current > pages - 4 ? current - 2 : pages - 2)..pages) 23 | else 24 | pages_range(1..1, with_dots: true) 25 | pages_range(current - 2..current + 2, with_dots: true) 26 | pages_range(pages..pages) 27 | end 28 | } 29 | } 30 | } 31 | div(class: 'col') 32 | } 33 | } 34 | end 35 | 36 | private 37 | 38 | def pages_range(range, with_dots: false) 39 | range.each do |page| 40 | li(class: page == current ? 'page-item active' : 'page-item') { 41 | href = query_string.empty? ? "?p=#{page}" : "?#{query_string}&p=#{page}" 42 | a(class: 'page-link', href: href) { page } 43 | } 44 | end 45 | dots if with_dots 46 | end 47 | 48 | def dots 49 | li(class: 'page-item disabled') { 50 | a(class: 'page-link') { '...' } 51 | } 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/actions/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Actions 6 | class Show < DefaultLayout 7 | attr_accessor :actions, 8 | :fields, 9 | :prepare_record, 10 | :record, 11 | :reference, 12 | :slug 13 | 14 | def view_template 15 | super do 16 | div(class: 'show') { 17 | div(class: 'row') { 18 | div(class: 'col-4') { 19 | h1(class: 'title') { title } 20 | } 21 | div(class: 'col-8') { 22 | actions_buttons 23 | } 24 | } 25 | 26 | prepare_record.call(record).each do |key, value| 27 | field = fields[key] 28 | div(class: "field-#{field.name} row lh-lg") { 29 | if field 30 | div(class: 'field-header col-2') { field.options[:header] || field.title } 31 | end 32 | div(class: 'field-value col-10') { 33 | render TinyAdmin.settings.components[:field_value].new(field, value, record: record) 34 | } 35 | } 36 | end 37 | 38 | render TinyAdmin::Views::Components::Widgets.new(widgets) 39 | } 40 | end 41 | end 42 | 43 | private 44 | 45 | def actions_buttons 46 | ul(class: 'nav justify-content-end') { 47 | (actions || {}).each do |action, action_class| 48 | li(class: 'nav-item mx-1') { 49 | href = TinyAdmin.route_for(slug, reference: reference, action: action) 50 | a(href: href, class: 'nav-link btn btn-outline-secondary') { 51 | action_class.respond_to?(:title) ? action_class.title : action 52 | } 53 | } 54 | end 55 | } 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/default_layout.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | class DefaultLayout < BasicLayout 6 | attr_accessor :flash_component, :head_component, :messages, :navbar_component, :options, :title 7 | 8 | def view_template(&block) 9 | extra_styles = TinyAdmin.settings.extra_styles 10 | flash_component&.messages = messages 11 | head_component&.update_attributes(page_title: title, style_links: style_links, extra_styles: extra_styles) 12 | 13 | doctype 14 | html { 15 | render head_component if head_component 16 | 17 | body(class: body_class) { 18 | render navbar_component if navbar_component 19 | 20 | main_content { 21 | render flash_component if flash_component 22 | 23 | yield_content(&block) 24 | } 25 | 26 | render_scripts 27 | } 28 | } 29 | end 30 | 31 | private 32 | 33 | def body_class 34 | "module-#{self.class.to_s.split('::').last.downcase}" 35 | end 36 | 37 | def main_content 38 | div(class: 'container main-content py-4') do 39 | if options&.include?(:compact_layout) 40 | div(class: 'row justify-content-center') { 41 | div(class: 'col-6') { 42 | yield 43 | } 44 | } 45 | else 46 | yield 47 | end 48 | end 49 | end 50 | 51 | def style_links 52 | TinyAdmin.settings.style_links || [ 53 | # Bootstrap CDN 54 | { 55 | href: 'https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css', 56 | rel: 'stylesheet', 57 | integrity: 'sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65', 58 | crossorigin: 'anonymous' 59 | } 60 | ] 61 | end 62 | 63 | def render_scripts 64 | (TinyAdmin.settings.scripts || []).each do |script_attrs| 65 | script(**script_attrs) 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/tiny_admin/store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | class Store 5 | include Utils 6 | 7 | attr_reader :navbar, :pages, :resources, :settings 8 | 9 | def initialize(settings) 10 | @pages = {} 11 | @resources = {} 12 | @settings = settings 13 | end 14 | 15 | def prepare_sections(sections, logout:) 16 | @navbar = sections.each_with_object([]) do |section, list| 17 | unless section.is_a?(Hash) 18 | section_class = to_class(section) 19 | next unless section_class.respond_to?(:to_h) 20 | 21 | section = section_class.to_h 22 | end 23 | 24 | slug = section[:slug].to_s 25 | case section[:type]&.to_sym 26 | when :content 27 | list << add_content_section(slug, section) 28 | when :page 29 | list << add_page_section(slug, section) 30 | when :resource 31 | list << add_resource_section(slug, section) 32 | when :url 33 | list << add_url_section(slug, section) 34 | end 35 | end 36 | navbar << logout if logout 37 | end 38 | 39 | private 40 | 41 | def add_content_section(slug, section) 42 | pages[slug] = { class: settings.content_page, content: section[:content], widgets: section[:widgets] } 43 | TinyAdmin::Section.new(name: section[:name], slug: slug) 44 | end 45 | 46 | def add_page_section(slug, section) 47 | pages[slug] = { class: to_class(section[:page]) } 48 | TinyAdmin::Section.new(name: section[:name], slug: slug) 49 | end 50 | 51 | def add_resource_section(slug, section) 52 | resource = section.slice(:resource, :only, :index, :show, :collection_actions, :member_actions) 53 | resource[:only] ||= %i[index show] 54 | resources[slug] = resource.merge( 55 | model: to_class(section[:model]), 56 | repository: to_class(section[:repository] || settings.repository) 57 | ) 58 | 59 | hidden = section[:options] && (section[:options].include?(:hidden) || section[:options].include?('hidden')) 60 | TinyAdmin::Section.new(name: section[:name], slug: slug) unless hidden 61 | end 62 | 63 | def add_url_section(slug, section) 64 | TinyAdmin::Section.new(name: section[:name], options: section[:options], path: section[:url], slug: slug) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/tiny_admin/plugins/active_record_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Plugins 5 | class ActiveRecordRepository < BaseRepository 6 | def index_record_attrs(record, fields: nil) 7 | return record.attributes.transform_values(&:to_s) unless fields 8 | 9 | fields.to_h { [_1, record.send(_1)] } 10 | end 11 | 12 | def index_title 13 | title = model.to_s 14 | title.respond_to?(:pluralize) ? title.pluralize : title 15 | end 16 | 17 | def fields(options: nil) 18 | if options 19 | types = model.columns.to_h { [_1.name, _1.type] } 20 | options.to_h do |name, field_options| 21 | [name, TinyAdmin::Field.create_field(name: name, type: types[name], options: field_options)] 22 | end 23 | else 24 | model.columns.to_h do |column| 25 | [column.name, TinyAdmin::Field.create_field(name: column.name, type: column.type)] 26 | end 27 | end 28 | end 29 | 30 | alias show_record_attrs index_record_attrs 31 | 32 | def show_title(record) 33 | "#{model} ##{record.id}" 34 | end 35 | 36 | def find(reference) 37 | model.find(reference) 38 | rescue ActiveRecord::RecordNotFound => e 39 | raise BaseRepository::RecordNotFound, e.message 40 | end 41 | 42 | def collection 43 | model.all 44 | end 45 | 46 | def list(page: 1, limit: 10, sort: nil, filters: nil) 47 | query = sort ? collection.order(sort) : collection 48 | query = apply_filters(query, filters) if filters 49 | page_offset = page.positive? ? (page - 1) * limit : 0 50 | records = query.offset(page_offset).limit(limit).to_a 51 | [records, query.count] 52 | end 53 | 54 | def apply_filters(query, filters) 55 | filters.each do |field, filter| 56 | value = filter&.dig(:value) 57 | next if value.nil? || value == '' 58 | 59 | query = 60 | case field.type 61 | when :string 62 | value = ActiveRecord::Base.sanitize_sql_like(value.strip) 63 | query.where("#{field.name} LIKE ?", "%#{value}%") 64 | else 65 | query.where(field.name => value) 66 | end 67 | end 68 | query 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/dummy_rails/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.1].define(version: 2018_06_07_053857) do 14 | create_table "authors", force: :cascade do |t| 15 | t.string "name" 16 | t.integer "age" 17 | t.string "email" 18 | t.datetime "created_at", precision: nil, null: false 19 | t.datetime "updated_at", precision: nil, null: false 20 | end 21 | 22 | create_table "post_tags", force: :cascade do |t| 23 | t.integer "post_id" 24 | t.integer "tag_id" 25 | t.datetime "created_at", precision: nil, null: false 26 | t.datetime "updated_at", precision: nil, null: false 27 | t.index ["post_id"], name: "index_post_tags_on_post_id" 28 | t.index ["tag_id"], name: "index_post_tags_on_tag_id" 29 | end 30 | 31 | create_table "posts", force: :cascade do |t| 32 | t.integer "state" 33 | t.string "title" 34 | t.text "description" 35 | t.integer "author_id" 36 | t.string "category" 37 | t.date "dt" 38 | t.float "position" 39 | t.boolean "published" 40 | t.datetime "created_at", precision: nil, null: false 41 | t.datetime "updated_at", precision: nil, null: false 42 | t.index ["author_id"], name: "index_posts_on_author_id" 43 | end 44 | 45 | create_table "profiles", force: :cascade do |t| 46 | t.text "description" 47 | t.integer "author_id" 48 | t.datetime "created_at", precision: nil, null: false 49 | t.datetime "updated_at", precision: nil, null: false 50 | t.index ["author_id"], name: "index_profiles_on_author_id" 51 | end 52 | 53 | create_table "tags", force: :cascade do |t| 54 | t.string "name" 55 | t.datetime "created_at", precision: nil, null: false 56 | t.datetime "updated_at", precision: nil, null: false 57 | end 58 | 59 | add_foreign_key "post_tags", "posts" 60 | add_foreign_key "post_tags", "tags" 61 | add_foreign_key "posts", "authors" 62 | add_foreign_key "profiles", "authors" 63 | end 64 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | # config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | # config.action_mailer.raise_delivery_errors = false 41 | 42 | # config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Suppress logger output for asset requests. 60 | # config.assets.quiet = true 61 | 62 | # Raises error for missing translations. 63 | # config.i18n.raise_on_missing_translations = true 64 | 65 | # Annotate rendered view with file names. 66 | # config.action_view.annotate_rendered_view_with_filenames = true 67 | 68 | # Uncomment if you wish to allow Action Cable access from any origin. 69 | # config.action_cable.disable_request_forgery_protection = true 70 | end 71 | -------------------------------------------------------------------------------- /lib/tiny_admin/actions/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Actions 5 | class Index < BasicAction 6 | attr_reader :context, 7 | :current_page, 8 | :fields_options, 9 | :links, 10 | :options, 11 | :pagination, 12 | :pages, 13 | :params, 14 | :query_string, 15 | :repository 16 | 17 | def call(app:, context:, options:) 18 | @context = context 19 | @options = options || {} 20 | evaluate_options(options) 21 | fields = repository.fields(options: fields_options) 22 | filters = prepare_filters(fields) 23 | records, count = repository.list(page: current_page, limit: pagination, filters: filters, sort: options[:sort]) 24 | attributes = { 25 | actions: context.actions, 26 | fields: fields, 27 | filters: filters, 28 | links: options[:links], 29 | prepare_record: ->(record) { repository.index_record_attrs(record, fields: fields_options) }, 30 | records: records, 31 | slug: context.slug, 32 | title: repository.index_title, 33 | widgets: options[:widgets] 34 | } 35 | 36 | prepare_page(Views::Actions::Index, slug: context.slug, attributes: attributes) do |page| 37 | setup_pagination(page, TinyAdmin.settings.components[:pagination], total_count: count) 38 | end 39 | end 40 | 41 | private 42 | 43 | def evaluate_options(options) 44 | @fields_options = attribute_options(options[:attributes]) 45 | @params = context.request.params 46 | @repository = context.repository 47 | @pagination = options[:pagination] || 10 48 | @current_page = (params['p'] || 1).to_i 49 | @query_string = params_to_s(params.except('p')) 50 | end 51 | 52 | def prepare_filters(fields) 53 | filters = (options[:filters] || []).map { _1.is_a?(Hash) ? _1 : { field: _1 } } 54 | filters = filters.each_with_object({}) { |filter, result| result[filter[:field]] = filter } 55 | values = (params['q'] || {}) 56 | fields.each_with_object({}) do |(name, field), result| 57 | result[field] = { value: values[name], filter: filters[name] } if filters.key?(name) 58 | end 59 | end 60 | 61 | def setup_pagination(page, pagination_component, total_count:) 62 | @pages = (total_count / pagination.to_f).ceil 63 | return if pages <= 1 || !pagination_component 64 | 65 | attributes = { current: current_page, pages: pages, query_string: query_string, total_count: total_count } 66 | page.pagination_component = pagination_component.new 67 | page.pagination_component.update_attributes(attributes) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | # config.active_storage.service = :test 38 | 39 | # config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | # config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | 61 | if ENV['RAILS_LOG_TO_STDOUT'].present? 62 | $stdout.sync = true 63 | logger = ActiveSupport::Logger.new($stdout) 64 | logger.formatter = config.log_formatter 65 | config.logger = ActiveSupport::TaggedLogging.new(logger) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/tiny_admin.yml: -------------------------------------------------------------------------------- 1 | --- 2 | authentication: 3 | plugin: TinyAdmin::Plugins::SimpleAuth 4 | # password: 'f1891cea80fc05e433c943254c6bdabc159577a02a7395dfebbfbc4f7661d4af56f2d372131a45936de40160007368a56ef216a30cb202c66d3145fd24380906' 5 | root: 6 | title: Test Admin 7 | # page: RootPage 8 | widgets: 9 | - LatestAuthorsWidget 10 | - LatestPostsWidget 11 | helper_class: AdminHelper 12 | # page_not_found: PageNotFound 13 | # record_not_found: RecordNotFound 14 | sections: 15 | - slug: google 16 | name: Google.it 17 | type: url 18 | url: https://www.google.it 19 | options: 20 | target: _blank 21 | - SectionGithubLink 22 | - slug: sample 23 | name: Sample page 24 | type: page 25 | page: SamplePage 26 | - slug: test-content 27 | name: Test content 28 | type: content 29 | content: > 30 |

Test content!

31 |

Some test content

32 | - slug: authors 33 | name: Authors 34 | type: resource 35 | model: Author 36 | collection_actions: 37 | - sample_col: SampleCollectionAction 38 | member_actions: 39 | - sample_mem: SampleMemberAction 40 | index: 41 | attributes: 42 | - id 43 | - name 44 | - email 45 | links: 46 | - show 47 | - sample_mem 48 | - slug: posts 49 | name: Posts 50 | type: resource 51 | model: Post 52 | index: 53 | pagination: 5 54 | attributes: 55 | - id 56 | - title: call, downcase, capitalize 57 | - field: author_id 58 | link_to: authors 59 | - category: upcase 60 | - state: downcase 61 | - published 62 | - position: round, 1 63 | - dt: to_date 64 | - field: created_at 65 | converter: AdminUtils 66 | method: datetime_formatter 67 | - updated_at: strftime, %Y%m%d %H:%M 68 | filters: 69 | - title 70 | - author_id 71 | - field: category 72 | type: select 73 | values: 74 | - news 75 | - sport 76 | - tech 77 | - published 78 | - dt 79 | - created_at 80 | show: 81 | attributes: 82 | - id 83 | - title 84 | - description 85 | - field: author_id 86 | link_to: authors 87 | call: author, name 88 | - category 89 | - published 90 | - position: format, %f 91 | - dt 92 | - created_at 93 | style_links: 94 | - href: /bootstrap.min.css 95 | rel: stylesheet 96 | scripts: 97 | - src: /bootstrap.bundle.min.js 98 | extra_styles: > 99 | .navbar { 100 | background-color: var(--bs-cyan); 101 | } 102 | .main-content { 103 | background-color: var(--bs-gray-100); 104 | } 105 | .main-content a { 106 | text-decoration: none; 107 | } 108 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/components/filters_form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Components 6 | class FiltersForm < BasicComponent 7 | attr_accessor :filters, :section_path 8 | 9 | def view_template 10 | form(class: 'form_filters', method: 'get') { 11 | filters.each do |field, filter| 12 | name = field.name 13 | filter_data = filter[:filter] 14 | div(class: 'mb-3') { 15 | label(for: "filter-#{name}", class: 'form-label') { field.title } 16 | case filter_data[:type]&.to_sym || field.type 17 | when :boolean 18 | select(class: 'form-select', id: "filter-#{name}", name: "q[#{name}]") { 19 | option(value: '') { '-' } 20 | option(value: '0', selected: filter[:value] == '0') { 21 | TinyAdmin.settings.helper_class.label_for('false', options: ['components.filters_form.boolean.false']) 22 | } 23 | option(value: '1', selected: filter[:value] == '1') { 24 | TinyAdmin.settings.helper_class.label_for('true', options: ['components.filters_form.boolean.true']) 25 | } 26 | } 27 | when :date 28 | input(type: 'date', class: 'form-control', id: "filter-#{name}", name: "q[#{name}]", value: filter[:value]) 29 | when :datetime 30 | input(type: 'datetime-local', class: 'form-control', id: "filter-#{name}", name: "q[#{name}]", value: filter[:value]) 31 | when :integer 32 | input(type: 'number', class: 'form-control', id: "filter-#{name}", name: "q[#{name}]", value: filter[:value]) 33 | when :select 34 | select(class: 'form-select', id: "filter-#{name}", name: "q[#{name}]") { 35 | option(value: '') { '-' } 36 | filter_data[:values].each do |value| 37 | option(selected: filter[:value] == value) { value } 38 | end 39 | } 40 | else 41 | input(type: 'text', class: 'form-control', id: "filter-#{name}", name: "q[#{name}]", value: filter[:value]) 42 | end 43 | } 44 | end 45 | 46 | div(class: 'mt-3') { 47 | a(href: section_path, class: 'button_clear btn btn-secondary text-white') { 48 | TinyAdmin.settings.helper_class.label_for('Clear', options: ['components.filters_form.buttons.clear']) 49 | } 50 | whitespace 51 | button(type: 'submit', class: 'button_filter btn btn-secondary') { 52 | TinyAdmin.settings.helper_class.label_for('Filter', options: ['components.filters_form.buttons.submit']) 53 | } 54 | } 55 | } 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/tiny_admin/settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | class Settings 5 | include Singleton 6 | 7 | DEFAULTS = { 8 | %i[authentication plugin] => Plugins::NoAuth, 9 | %i[authentication login] => Views::Pages::SimpleAuthLogin, 10 | %i[authorization_class] => Plugins::Authorization, 11 | %i[components field_value] => Views::Components::FieldValue, 12 | %i[components flash] => Views::Components::Flash, 13 | %i[components head] => Views::Components::Head, 14 | %i[components navbar] => Views::Components::Navbar, 15 | %i[components pagination] => Views::Components::Pagination, 16 | %i[content_page] => Views::Pages::Content, 17 | %i[helper_class] => Support, 18 | %i[page_not_allowed] => Views::Pages::PageNotAllowed, 19 | %i[page_not_found] => Views::Pages::PageNotFound, 20 | %i[record_not_found] => Views::Pages::RecordNotFound, 21 | %i[repository] => Plugins::ActiveRecordRepository, 22 | %i[root_path] => '/admin', 23 | %i[root page] => Views::Pages::Root, 24 | %i[root title] => 'TinyAdmin', 25 | %i[sections] => [] 26 | }.freeze 27 | 28 | OPTIONS = %i[ 29 | authentication 30 | authorization_class 31 | components 32 | content_page 33 | extra_styles 34 | helper_class 35 | page_not_allowed 36 | page_not_found 37 | record_not_found 38 | repository 39 | root 40 | root_path 41 | sections 42 | scripts 43 | style_links 44 | ].freeze 45 | 46 | attr_reader :store 47 | 48 | OPTIONS.each do |option| 49 | define_method(option) do 50 | self[option] 51 | end 52 | 53 | define_method("#{option}=") do |value| 54 | self[option] = value 55 | end 56 | end 57 | 58 | def [](*path) 59 | key, option = fetch_setting(path) 60 | option[key] 61 | end 62 | 63 | def []=(*path, value) 64 | key, option = fetch_setting(path) 65 | option[key] = value 66 | convert_value(key, value) 67 | end 68 | 69 | def load_settings 70 | # default values 71 | DEFAULTS.each do |(option, param), default| 72 | if param 73 | self[option] ||= {} 74 | self[option][param] ||= default 75 | else 76 | self[option] ||= default 77 | end 78 | end 79 | 80 | @store ||= TinyAdmin::Store.new(self) 81 | self.root_path = '/' if root_path == '' 82 | 83 | if authentication[:plugin] <= Plugins::SimpleAuth 84 | logout_path = "#{root_path}/auth/logout" 85 | authentication[:logout] ||= TinyAdmin::Section.new(name: 'logout', slug: 'logout', path: logout_path) 86 | end 87 | store.prepare_sections(sections, logout: authentication[:logout]) 88 | end 89 | 90 | def reset! 91 | @options = {} 92 | end 93 | 94 | private 95 | 96 | def fetch_setting(path) 97 | @options ||= {} 98 | *parts, last = path.map(&:to_sym) 99 | [last, parts.inject(@options) { |result, part| result[part] ||= {} }] 100 | end 101 | 102 | def convert_value(key, value) 103 | if value.is_a?(Hash) 104 | value.each_key do |key2| 105 | path = [key, key2] 106 | if (DEFAULTS[path].is_a?(Class) || DEFAULTS[path].is_a?(Module)) && self[key][key2].is_a?(String) 107 | self[key][key2] = Object.const_get(self[key][key2]) 108 | end 109 | end 110 | elsif value.is_a?(String) && (DEFAULTS[[key]].is_a?(Class) || DEFAULTS[[key]].is_a?(Module)) 111 | self[key] = Object.const_get(self[key]) 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/features/resources_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe 'Resources', type: :feature do 7 | before { setup_data } 8 | 9 | context 'when clicking on Authors menu item' do 10 | before do 11 | visit '/admin' 12 | log_in 13 | click_link('Authors') 14 | end 15 | 16 | it 'loads the index page', :aggregate_failures do 17 | author = Author.last 18 | 19 | expect(page).to have_current_path('/admin/authors') 20 | expect(page).to have_css('h1.title', text: 'Authors') 21 | expect(page).to have_css('td', text: author.name) 22 | expect(page).to have_css('td', text: author.email) 23 | expect(page).to have_link('Show', href: "/admin/authors/#{author.id}") 24 | end 25 | end 26 | 27 | context "when clicking on an author's show link" do 28 | let(:author) { Author.first } 29 | 30 | before do 31 | visit '/admin' 32 | log_in 33 | click_link('Authors') 34 | find('tr.row_1 a', text: 'Show').click 35 | end 36 | 37 | it 'loads the show page', :aggregate_failures do 38 | expect(page).to have_css('h1.title', text: "Author ##{author.id}") 39 | expect(page).to have_css('div', text: author.email) 40 | expect(page).to have_css('div', text: author.name) 41 | end 42 | end 43 | 44 | context "when filtering by title in the post's listing page" do 45 | before do 46 | setup_data(posts_count: 15) 47 | visit '/admin/posts?some_var=1' 48 | log_in 49 | end 50 | 51 | it 'shows the filtered index page', :aggregate_failures do 52 | expect(page).not_to have_css('td', text: Post.last.title) 53 | fill_in('q[title]', with: Post.last.title) 54 | fill_in('q[author_id]', with: Post.last.author_id) 55 | page.find('form.form_filters .button_filter').click 56 | expect(page).to have_css('td', text: Post.last.title) 57 | end 58 | end 59 | 60 | context "when clicking on a post's show link" do 61 | let(:post) { Post.first } 62 | 63 | before do 64 | visit '/admin' 65 | log_in 66 | click_link('Posts') 67 | find('tr.row_1 a', text: 'Show').click 68 | end 69 | 70 | it 'loads the show page', :aggregate_failures do 71 | expect(page).to have_css('h1.title', text: "Post ##{post.id}") 72 | expect(page).to have_css('div', text: post.title) 73 | end 74 | end 75 | 76 | context 'when the url of a missing author is loaded' do 77 | before do 78 | visit '/admin/authors/0123456789' 79 | log_in 80 | end 81 | 82 | it 'loads the record not found page', :aggregate_failures do 83 | expect(page).to have_current_path('/admin/authors/0123456789') 84 | expect(page).to have_css('h1.title', text: 'Record not found') 85 | end 86 | end 87 | 88 | context 'when opening a custom collection action url' do 89 | before do 90 | visit '/admin' 91 | log_in 92 | visit '/admin/authors/sample_col' 93 | end 94 | 95 | it 'loads the custom action page', :aggregate_failures do 96 | expect(page).to have_current_path('/admin/authors/sample_col') 97 | expect(page).to have_content('Custom collection action') 98 | end 99 | end 100 | 101 | context 'when opening a custom member action url' do 102 | let(:author) { Author.first } 103 | 104 | before do 105 | visit '/admin' 106 | log_in 107 | visit "/admin/authors/#{author.id}/sample_mem" 108 | end 109 | 110 | it 'loads the custom action page', :aggregate_failures do 111 | expect(page).to have_current_path("/admin/authors/#{author.id}/sample_mem") 112 | expect(page).to have_content('Custom member action') 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/features/plugins/authorization_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dummy_rails_app' 4 | require 'rails_helper' 5 | 6 | RSpec.describe 'Authorization plugin', type: :feature do 7 | let(:root_content) { "Latest authors\nLatest posts" } 8 | 9 | around do |example| 10 | prev_value = TinyAdmin.settings.authorization_class 11 | TinyAdmin.settings.authorization_class = some_class 12 | example.run 13 | TinyAdmin.settings.authorization_class = prev_value 14 | end 15 | 16 | before do 17 | visit '/admin' 18 | log_in 19 | end 20 | 21 | context 'with an Authorization class that restrict the root page' do 22 | let(:some_class) do 23 | Class.new(TinyAdmin::Plugins::Authorization) do 24 | class << self 25 | def allowed?(_user, action, _param = nil) 26 | return false if action == :root 27 | 28 | true 29 | end 30 | end 31 | end 32 | end 33 | 34 | it 'disallows the access to the root page when opened', :aggregate_failures do 35 | expect(page).to have_content 'Page not allowed' 36 | expect { click_on 'Sample page' } 37 | .to change { page.find('.main-content').text }.to("Sample page\nThis is just a sample page") 38 | end 39 | end 40 | 41 | context 'with an Authorization class that restrict a specific page' do 42 | let(:some_class) do 43 | Class.new(TinyAdmin::Plugins::Authorization) do 44 | class << self 45 | def allowed?(_user, action, param = nil) 46 | return false if action == :page && param == 'sample' 47 | 48 | true 49 | end 50 | end 51 | end 52 | end 53 | 54 | it 'disallows the access to the page when opened' do 55 | expect { click_on 'Sample page' } 56 | .to change { page.find('.main-content').text }.from(root_content).to('Page not allowed') 57 | end 58 | end 59 | 60 | context 'with an Authorization class that restrict resource index' do 61 | let(:some_class) do 62 | Class.new(TinyAdmin::Plugins::Authorization) do 63 | class << self 64 | def allowed?(_user, action, _param = nil) 65 | return false if action == :resource_index 66 | 67 | true 68 | end 69 | end 70 | end 71 | end 72 | 73 | it 'disallows the access to the page when opened' do 74 | expect { click_on 'Posts' } 75 | .to change { page.find('.main-content').text }.from(root_content).to('Page not allowed') 76 | end 77 | end 78 | 79 | context 'with an Authorization class that restrict resource show' do 80 | let(:some_class) do 81 | Class.new(TinyAdmin::Plugins::Authorization) do 82 | class << self 83 | def allowed?(_user, action, _param = nil) 84 | return false if action == :resource_show 85 | 86 | true 87 | end 88 | end 89 | end 90 | end 91 | 92 | before { setup_data(posts_count: 1) } 93 | 94 | it 'disallows the access to the page when opened' do 95 | click_on 'Posts' 96 | expect { click_on 'Show' } 97 | .to change { page.find('.main-content').text }.to('Page not allowed') 98 | end 99 | end 100 | 101 | context 'with an Authorization class that restrict a specific custom action' do 102 | let(:some_class) do 103 | Class.new(TinyAdmin::Plugins::Authorization) do 104 | class << self 105 | def allowed?(_user, action, param = nil) 106 | return false if action == :custom_action && param == 'sample_col' 107 | 108 | true 109 | end 110 | end 111 | end 112 | end 113 | 114 | it 'disallows the access to the page when opened' do 115 | click_on 'Authors' 116 | expect { click_on 'sample_col' } 117 | .to change { page.find('.main-content').text }.to('Page not allowed') 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/dummy_rails/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | #config.active_storage.service = :local 42 | config.active_storage.service = :db 43 | 44 | # Mount Action Cable outside main process or domain. 45 | # config.action_cable.mount_path = nil 46 | # config.action_cable.url = "wss://example.com/cable" 47 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 48 | 49 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 50 | # config.force_ssl = true 51 | 52 | # Include generic and useful information about system operation, but avoid logging too much 53 | # information to avoid inadvertent exposure of personally identifiable information (PII). 54 | config.log_level = :info 55 | 56 | # Prepend all log lines with the following tags. 57 | config.log_tags = [ :request_id ] 58 | 59 | # Use a different cache store in production. 60 | # config.cache_store = :mem_cache_store 61 | 62 | # Use a real queuing backend for Active Job (and separate queues per environment). 63 | # config.active_job.queue_adapter = :resque 64 | # config.active_job.queue_name_prefix = "dummy_production" 65 | 66 | config.action_mailer.perform_caching = false 67 | 68 | # Ignore bad email addresses and do not raise email delivery errors. 69 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 70 | # config.action_mailer.raise_delivery_errors = false 71 | 72 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 73 | # the I18n.default_locale when a translation cannot be found). 74 | config.i18n.fallbacks = true 75 | 76 | # Don't log any deprecations. 77 | config.active_support.report_deprecations = false 78 | 79 | # Use default logging formatter so that PID and timestamp are not suppressed. 80 | config.log_formatter = ::Logger::Formatter.new 81 | 82 | # Use a different logger for distributed setups. 83 | # require "syslog/logger" 84 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 85 | 86 | if ENV["RAILS_LOG_TO_STDOUT"].present? 87 | logger = ActiveSupport::Logger.new(STDOUT) 88 | logger.formatter = config.log_formatter 89 | config.logger = ActiveSupport::TaggedLogging.new(logger) 90 | end 91 | 92 | # Do not dump schema after migrations. 93 | config.active_record.dump_schema_after_migration = false 94 | end 95 | -------------------------------------------------------------------------------- /lib/tiny_admin/views/actions/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | module Views 5 | module Actions 6 | class Index < DefaultLayout 7 | attr_accessor :actions, 8 | :fields, 9 | :filters, 10 | :links, 11 | :pagination_component, 12 | :prepare_record, 13 | :records, 14 | :slug 15 | 16 | def view_template 17 | super do 18 | div(class: 'index') { 19 | div(class: 'row') { 20 | div(class: 'col-4') { 21 | h1(class: 'title') { 22 | title 23 | } 24 | } 25 | div(class: 'col-8') { 26 | actions_buttons 27 | } 28 | } 29 | 30 | div(class: 'row') { 31 | div_class = filters&.any? ? 'col-9' : 'col-12' 32 | div(class: div_class) { 33 | table(class: 'table') { 34 | table_header if fields.any? 35 | 36 | table_body 37 | } 38 | 39 | render pagination_component if pagination_component 40 | } 41 | 42 | if filters&.any? 43 | div(class: 'col-3') { 44 | filters_form = TinyAdmin::Views::Components::FiltersForm.new 45 | filters_form.update_attributes(section_path: TinyAdmin.route_for(slug), filters: filters) 46 | render filters_form 47 | } 48 | end 49 | } 50 | 51 | render TinyAdmin::Views::Components::Widgets.new(widgets) 52 | } 53 | end 54 | end 55 | 56 | private 57 | 58 | def table_header 59 | thead { 60 | tr { 61 | fields.each_value do |field| 62 | td(class: "field-header-#{field.name} field-header-type-#{field.type}") { 63 | field.options[:header] || field.title 64 | } 65 | end 66 | td { whitespace } 67 | } 68 | } 69 | end 70 | 71 | def table_body 72 | tbody { 73 | records.each_with_index do |record, index| 74 | tr(class: "row_#{index + 1}") { 75 | attributes = prepare_record.call(record) 76 | attributes.each do |key, value| 77 | field = fields[key] 78 | td(class: "field-value-#{field.name} field-value-type-#{field.type}") { 79 | render TinyAdmin.settings.components[:field_value].new(field, value, record: record) 80 | } 81 | end 82 | 83 | td(class: 'actions p-1') { 84 | div(class: 'btn-group btn-group-sm') { 85 | link_class = 'btn btn-outline-secondary' 86 | if links 87 | links.each do |link| 88 | whitespace 89 | if link == 'show' 90 | a(href: TinyAdmin.route_for(slug, reference: record.id), class: link_class) { 91 | label_for('Show', options: ['actions.index.links.show']) 92 | } 93 | else 94 | a(href: TinyAdmin.route_for(slug, reference: record.id, action: link), class: link_class) { 95 | fallback = humanize(link) 96 | label_for(fallback, options: ["actions.index.links.#{link}"]) 97 | } 98 | end 99 | end 100 | else 101 | a(href: TinyAdmin.route_for(slug, reference: record.id), class: link_class) { 102 | label_for('Show', options: ['actions.index.links.show']) 103 | } 104 | end 105 | } 106 | } 107 | } 108 | end 109 | } 110 | end 111 | 112 | def actions_buttons 113 | ul(class: 'nav justify-content-end') { 114 | (actions || {}).each do |action, action_class| 115 | li(class: 'nav-item mx-1') { 116 | href = TinyAdmin.route_for(slug, action: action) 117 | a(href: href, class: 'nav-link btn btn-outline-secondary') { 118 | action_class.respond_to?(:title) ? action_class.title : action 119 | } 120 | } 121 | end 122 | } 123 | end 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/tiny_admin/router.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TinyAdmin 4 | class Router < BasicApp 5 | extend Forwardable 6 | 7 | def_delegator TinyAdmin, :route_for 8 | 9 | route do |r| 10 | TinyAdmin.settings.load_settings 11 | 12 | r.on 'auth' do 13 | r.run Authentication 14 | end 15 | 16 | authenticate_user! 17 | 18 | r.root do 19 | root_route(r) 20 | end 21 | 22 | r.is do 23 | # :nocov: 24 | root_route(r) 25 | # :nocov: 26 | end 27 | 28 | r.post '' do 29 | r.redirect TinyAdmin.settings.root_path 30 | end 31 | 32 | store.pages.each do |slug, page_data| 33 | setup_page_route(r, slug, page_data) 34 | end 35 | 36 | store.resources.each do |slug, options| 37 | setup_resource_routes(r, slug, options: options || {}) 38 | end 39 | 40 | nil # NOTE: needed to skip the last line (each) return value 41 | end 42 | 43 | private 44 | 45 | def store 46 | @store ||= TinyAdmin.settings.store 47 | end 48 | 49 | def render_page(page) 50 | if page.respond_to?(:messages=) 51 | page.messages = { notices: flash['notices'], warnings: flash['warnings'], errors: flash['errors'] } 52 | end 53 | render(inline: page.call) 54 | end 55 | 56 | def root_route(req) 57 | if authorization.allowed?(current_user, :root) 58 | if TinyAdmin.settings.root[:redirect] 59 | req.redirect route_for(TinyAdmin.settings.root[:redirect]) 60 | else 61 | page_class = to_class(TinyAdmin.settings.root[:page]) 62 | attributes = TinyAdmin.settings.root.slice(:content, :title, :widgets) 63 | render_page prepare_page(page_class, attributes: attributes, params: request.params) 64 | end 65 | else 66 | render_page prepare_page(TinyAdmin.settings.page_not_allowed) 67 | end 68 | end 69 | 70 | def setup_page_route(req, slug, page_data) 71 | req.get slug do 72 | if authorization.allowed?(current_user, :page, slug) 73 | attributes = page_data.slice(:content, :title, :widgets) 74 | render_page prepare_page(page_data[:class], slug: slug, attributes: attributes, params: request.params) 75 | else 76 | render_page prepare_page(TinyAdmin.settings.page_not_allowed) 77 | end 78 | end 79 | end 80 | 81 | def setup_resource_routes(req, slug, options:) 82 | req.on slug do 83 | setup_collection_routes(req, slug, options: options) 84 | setup_member_routes(req, slug, options: options) 85 | end 86 | end 87 | 88 | def setup_collection_routes(req, slug, options:) 89 | repository = options[:repository].new(options[:model]) 90 | action_options = options[:index] || {} 91 | 92 | # Custom actions 93 | custom_actions = setup_custom_actions( 94 | req, 95 | options[:collection_actions], 96 | options: action_options, 97 | repository: repository, 98 | slug: slug 99 | ) 100 | 101 | # Index 102 | if options[:only].include?(:index) || options[:only].include?('index') 103 | req.is do 104 | if authorization.allowed?(current_user, :resource_index, slug) 105 | context = Context.new( 106 | actions: custom_actions, 107 | repository: repository, 108 | request: request, 109 | router: req, 110 | slug: slug 111 | ) 112 | index_action = TinyAdmin::Actions::Index.new 113 | render_page index_action.call(app: self, context: context, options: action_options) 114 | else 115 | render_page prepare_page(TinyAdmin.settings.page_not_allowed) 116 | end 117 | end 118 | end 119 | end 120 | 121 | def setup_member_routes(req, slug, options:) 122 | repository = options[:repository].new(options[:model]) 123 | action_options = (options[:show] || {}).merge(record_not_found_page: TinyAdmin.settings.record_not_found) 124 | 125 | req.on String do |reference| 126 | # Custom actions 127 | custom_actions = setup_custom_actions( 128 | req, 129 | options[:member_actions], 130 | options: action_options, 131 | repository: repository, 132 | slug: slug, 133 | reference: reference 134 | ) 135 | 136 | # Show 137 | if options[:only].include?(:show) || options[:only].include?('show') 138 | req.is do 139 | if authorization.allowed?(current_user, :resource_show, slug) 140 | context = Context.new( 141 | actions: custom_actions, 142 | reference: reference, 143 | repository: repository, 144 | request: request, 145 | router: req, 146 | slug: slug 147 | ) 148 | show_action = TinyAdmin::Actions::Show.new 149 | render_page show_action.call(app: self, context: context, options: action_options) 150 | else 151 | render_page prepare_page(TinyAdmin.settings.page_not_allowed) 152 | end 153 | end 154 | end 155 | end 156 | end 157 | 158 | def setup_custom_actions(req, custom_actions = nil, options:, repository:, slug:, reference: nil) 159 | (custom_actions || []).each_with_object({}) do |custom_action, result| 160 | action_slug, action = custom_action.first 161 | action_class = to_class(action) 162 | 163 | req.get action_slug.to_s do 164 | if authorization.allowed?(current_user, :custom_action, action_slug.to_s) 165 | context = Context.new( 166 | actions: {}, 167 | reference: reference, 168 | repository: repository, 169 | request: request, 170 | router: req, 171 | slug: slug 172 | ) 173 | custom_action = action_class.new 174 | render_page custom_action.call(app: self, context: context, options: options) 175 | else 176 | render_page prepare_page(TinyAdmin.settings.page_not_allowed) 177 | end 178 | end 179 | 180 | result[action_slug.to_s] = action_class 181 | end 182 | end 183 | 184 | def authorization 185 | TinyAdmin.settings.authorization_class 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tiny Admin 2 | 3 | [![Gem Version](https://badge.fury.io/rb/tiny_admin.svg)](https://badge.fury.io/rb/tiny_admin) 4 | [![Gem Downloads](https://badgen.net/rubygems/dt/tiny_admin)](https://rubygems.org/gems/tiny_admin) 5 | [![Linters](https://github.com/blocknotes/tiny_admin/actions/workflows/linters.yml/badge.svg)](https://github.com/blocknotes/tiny_admin/actions/workflows/linters.yml) 6 | [![Specs](https://github.com/blocknotes/tiny_admin/actions/workflows/tests.yml/badge.svg)](https://github.com/blocknotes/tiny_admin/actions/workflows/tests.yml) 7 | 8 | A compact and composable dashboard component for Ruby. 9 | 10 | Main features: 11 | - a Rack app that can be mounted in any Rack-enabled framework or used standalone; 12 | - structured with plugins also for main components that can be replaced with little effort; 13 | - routing is provided by Roda which is small and performant; 14 | - views are Phlex components, so plain Ruby objects for views, no assets are needed. 15 | 16 | Please ⭐ if you like it. 17 | 18 | ![screenshot](extra/screenshot.png) 19 | 20 | ## Install 21 | 22 | - Add to your Gemfile: `gem 'tiny_admin', '~> 0.10'` 23 | - Mount the app in a route (check some examples with: Hanami, Rails, Roda and standalone in [extra](extra)) 24 | + in Rails, update _config/routes.rb_: `mount TinyAdmin::Router => '/admin'` 25 | - Configure the dashboard using `TinyAdmin.configure` and/or `TinyAdmin.configure_from_file` with a YAML config file (see [configuration](#configuration) below): 26 | 27 | ```rb 28 | TinyAdmin.configure do |settings| 29 | settings.root = { 30 | title: 'Home', 31 | page: Admin::PageRoot 32 | } 33 | end 34 | ``` 35 | 36 | ## Plugins and components 37 | 38 | ### Authentication 39 | 40 | Plugins available: 41 | 42 | - **SimpleAuth**: a session authentication based on Warden (`warden` gem must be included in the Gemfile) using a password hash provided via config or via environment variable (`ADMIN_PASSWORD_HASH`). _Disclaimer: this plugin is provided as example, if you need a secure authentication I suggest to create your own._ 43 | 44 | - **NoAuth**: no authentication. 45 | 46 | ### Authorization 47 | 48 | Plugins available: 49 | 50 | - **Authorization**: base class to provide an authorization per action, the host application should inherit from it and override the class method `allowed?`. 51 | 52 | ### Repository 53 | 54 | Plugin available: 55 | 56 | - **ActiveRecordRepository**: isolates the query layer to expose the resources in the admin interface. 57 | 58 | ### View pages 59 | 60 | Pages available: 61 | 62 | - **Root**: define how to present the content in the main page of the interface; 63 | - **Content**: define how to present page with inline content; 64 | - **PageNotFound**: define how to present pages not found; 65 | - **RecordNotFound**: define how to present record not found page; 66 | - **SimpleAuthLogin**: define how to present the login form for SimpleAuth plugin; 67 | - **Index**: define how to present a collection of items; 68 | - **Show**: define how to present the details of an item. 69 | 70 | ### View components 71 | 72 | Components available: 73 | 74 | - **FiltersForm**: define how to present the filters form in the resource collection pages; 75 | - **Flash**: define how to present the flash messages; 76 | - **Head**: define how to present the Head tag; 77 | - **Navbar**: define how to present the navbar (the default one uses the Bootstrap structure); 78 | - **Pagination**: define how to present the pagination of a collection. 79 | 80 | ## Configuration 81 | 82 | TinyAdmin can be configured using a YAML file and/or programmatically. 83 | See [extra](extra) folder for some usage examples. 84 | 85 | The following options are supported: 86 | 87 | `root` (Hash): define the root section of the admin, properties: 88 | 89 | - `title` (String): root section's title; 90 | - `page` (String): a view object to render; 91 | - `redirect` (String): alternative to _page_ option - redirects to a specific slug; 92 | - `widgets` (Array): list of widgets (as View components) to present. 93 | 94 | > 📚 [Wiki Root page](https://github.com/blocknotes/tiny_admin/wiki/Root) available 95 | 96 | Example: 97 | 98 | ```yml 99 | root: 100 | title: MyAdmin 101 | redirect: posts 102 | widgets: 103 | - LatestAuthorsWidget 104 | - LatestPostsWidget 105 | ``` 106 | 107 | `helper_class` (String): class or module with helper methods, used for attributes' formatters. 108 | 109 | > 📚 [Wiki Helper methods page](https://github.com/blocknotes/tiny_admin/wiki/Helper-methods) available 110 | 111 | `page_not_found` (String): a view object to render when a missing page is requested. 112 | 113 | `record_not_found` (String): a view object to render when a missing record is requested. 114 | 115 | `style_links` (Array of hashes): list of styles files to include, properties: 116 | 117 | - `href` (String): URL for the style file; 118 | - `rel` (String): type of style file. 119 | 120 | `scripts` (Array of hashes): list of scripts to include, properties: 121 | 122 | - `src` (String): source URL for the script. 123 | 124 | `extra_styles` (String): inline CSS styles. 125 | 126 | > 📚 [Wiki Styles and scripts page](https://github.com/blocknotes/tiny_admin/wiki/Styles-and-scripts) available 127 | 128 | `authentication` (Hash): define the authentication method, properties: 129 | 130 | - `plugin` (String): a plugin class to use (ex. `TinyAdmin::Plugins::SimpleAuth`); 131 | - `password` (String): a password hash used by _SimpleAuth_ plugin (generated with `Digest::SHA512.hexdigest("some password")`). 132 | 133 | > 📚 [Wiki Authentication page](https://github.com/blocknotes/tiny_admin/wiki/Authentication) available 134 | 135 | Example: 136 | 137 | ```yml 138 | authentication: 139 | plugin: TinyAdmin::Plugins::SimpleAuth 140 | password: 'f1891cea80fc05e433c943254c6bdabc159577a02a7395dfebbfbc4f7661d4af56f2d372131a45936de40160007368a56ef216a30cb202c66d3145fd24380906' 141 | ``` 142 | 143 | `authorization_class` (String): a plugin class to use; 144 | 145 | > 📚 [Wiki Authentication page](https://github.com/blocknotes/tiny_admin/wiki/Authorization) available 146 | 147 | `sections` (Array of hashes): define the admin sections, properties: 148 | 149 | - `slug` (String): section reference identifier; 150 | - `name` (String): section's title; 151 | - `type` (String): the type of section: `content`, `page`, `resource` or `url`; 152 | - `widgets` (Array): list of widgets (as View components) to present; 153 | - other properties depends on the section's type. 154 | 155 | > 📚 [Wiki Pages page](https://github.com/blocknotes/tiny_admin/wiki/Pages) available 156 | 157 | For _content_ sections: 158 | 159 | - `content` (String): the HTML content to present. 160 | 161 | Example: 162 | 163 | ```yml 164 | slug: test-content 165 | name: Test content 166 | type: content 167 | content: > 168 |

Test content!

169 |

Some test content

170 | widgets: 171 | - LatestAuthorsWidget 172 | - LatestPostsWidget 173 | ``` 174 | 175 | For _url_ sections: 176 | 177 | - `url` (String): the URL to load when clicking on the section's menu item; 178 | - `options` (Hash): properties: 179 | + `target` (String): link _target_ attributes (ex. `_blank`). 180 | 181 | Example: 182 | 183 | ```yml 184 | slug: google 185 | name: Google.it 186 | type: url 187 | url: https://www.google.it 188 | options: 189 | target: '_blank' 190 | ``` 191 | 192 | For _page_ sections: 193 | 194 | - `page` (String): a view object to render. 195 | 196 | Example: 197 | 198 | ```yml 199 | slug: stats 200 | name: Stats 201 | type: page 202 | page: Admin::Stats 203 | ``` 204 | 205 | For _resource_ sections: 206 | 207 | - `model` (String): the class to use to fetch the data on an item of a collection; 208 | - `repository` (String): the class to get the properties related to the model; 209 | 210 | > 📚 [Wiki Repository page](https://github.com/blocknotes/tiny_admin/wiki/Repository) available 211 | 212 | - `index` (Hash): collection's action options (see below); 213 | - `show` (Hash): detail's action options (see below); 214 | - `collection_actions` (Array of hashes): custom collection's actions; 215 | - `member_actions` (Array of hashes): custom details's actions; 216 | - `widgets` (Array): list of widgets (as View components) to present; 217 | - `only` (Array of strings): list of supported actions (ex. `index`); 218 | - `options` (Array of strings): resource options (ex. `hidden`). 219 | 220 | Example: 221 | 222 | ```yml 223 | slug: posts 224 | name: Posts 225 | type: resource 226 | model: Post 227 | ``` 228 | 229 | #### Resource index options 230 | 231 | > 📚 [Wiki Resource index page](https://github.com/blocknotes/tiny_admin/wiki/Resource-index) available 232 | 233 | The Index hash supports the following options: 234 | 235 | - `attributes` (Array): fields to expose in the resource list page; 236 | - `filters` (Array): filter the current listing; 237 | - `links` (Array): custom member actions to expose for each list's entry (defined in _member_actions_); 238 | - `pagination` (Integer): max pages size; 239 | - `sort` (Array): sort options to pass to the listing query. 240 | 241 | Example: 242 | 243 | ```yml 244 | index: 245 | sort: 246 | - id DESC 247 | pagination: 10 248 | attributes: 249 | - id 250 | - author: call, name 251 | - position: round, 1 252 | - field: author_id 253 | header: The author 254 | link_to: authors 255 | call: author, name 256 | filters: 257 | - title 258 | - field: state 259 | type: select 260 | values: 261 | - published 262 | - draft 263 | - archived 264 | links: 265 | - show 266 | - author_posts 267 | - csv_export 268 | ``` 269 | 270 | #### Resource show options 271 | 272 | > 📚 [Wiki Resource show page](https://github.com/blocknotes/tiny_admin/wiki/Resource-show) available 273 | 274 | The Show hash supports the following options: 275 | 276 | - `attributes` (Array): fields to expose in the resource details page. 277 | 278 | Example: 279 | 280 | ```yml 281 | show: 282 | attributes: 283 | # Expose the id column 284 | - id 285 | # Expose the title column, calling `downcase` support method 286 | - title: downcase 287 | # Expose the category column, calling `upcase` support method 288 | - category: upcase 289 | # Expose the position column, calling `format` support method with argument %f 290 | - position: format, %f 291 | # Expose the position created_at, calling `strftime` support method with argument %Y%m%d %H:%M 292 | - created_at: strftime, %Y%m%d %H:%M 293 | # Expose the author_id column, with a custom header label, linked to authors section and calling author.name to get the value 294 | - field: author_id 295 | header: The author 296 | link_to: authors 297 | call: author, name 298 | widgets: 299 | - LatestAuthorsWidget 300 | - LatestPostsWidget 301 | ``` 302 | 303 | ### Sample 304 | 305 | ```rb 306 | # config/initializers/tiny_admin.rb 307 | 308 | config = Rails.root.join('config/tiny_admin.yml').to_s 309 | TinyAdmin.configure_from_file(config) 310 | 311 | # Change some settings programmatically 312 | TinyAdmin.configure do |settings| 313 | settings.authentication[:password] = Digest::SHA512.hexdigest('changeme') 314 | end 315 | ``` 316 | 317 | ```yml 318 | # config/tiny_admin.yml 319 | --- 320 | authentication: 321 | plugin: TinyAdmin::Plugins::SimpleAuth 322 | # password: 'f1891cea80fc05e433c943254c6bdabc159577a02a7395dfebbfbc4f7661d4af56f2d372131a45936de40160007368a56ef216a30cb202c66d3145fd24380906' 323 | root: 324 | title: Test Admin 325 | widgets: 326 | - LatestAuthorsWidget 327 | - LatestPostsWidget 328 | # page: RootPage 329 | helper_class: AdminHelper 330 | page_not_found: PageNotFound 331 | record_not_found: RecordNotFound 332 | sections: 333 | - slug: google 334 | name: Google.it 335 | type: url 336 | url: https://www.google.it 337 | options: 338 | target: _blank 339 | - slug: sample 340 | name: Sample page 341 | type: page 342 | page: SamplePage 343 | - slug: authors 344 | name: Authors 345 | type: resource 346 | model: Author 347 | collection_actions: 348 | - sample_col: SampleCollectionAction 349 | member_actions: 350 | - sample_mem: SampleMemberAction 351 | - slug: posts 352 | name: Posts 353 | type: resource 354 | model: Post 355 | index: 356 | pagination: 5 357 | attributes: 358 | - id 359 | - title 360 | - field: author_id 361 | link_to: authors 362 | - category: upcase 363 | - state: downcase 364 | - published 365 | - position: round, 1 366 | - dt: to_date 367 | - field: created_at 368 | converter: AdminUtils 369 | method: datetime_formatter 370 | - updated_at: strftime, %Y%m%d %H:%M 371 | filters: 372 | - title 373 | - author_id 374 | - field: category 375 | type: select 376 | values: 377 | - news 378 | - sport 379 | - tech 380 | - published 381 | - dt 382 | - created_at 383 | show: 384 | attributes: 385 | - id 386 | - title 387 | - description 388 | - field: author_id 389 | link_to: authors 390 | - category 391 | - published 392 | - position: format, %f 393 | - dt 394 | - created_at 395 | style_links: 396 | - href: /bootstrap.min.css 397 | rel: stylesheet 398 | scripts: 399 | - src: /bootstrap.bundle.min.js 400 | extra_styles: > 401 | .navbar { 402 | background-color: var(--bs-cyan); 403 | } 404 | .main-content { 405 | background-color: var(--bs-gray-100); 406 | } 407 | .main-content a { 408 | text-decoration: none; 409 | } 410 | ``` 411 | 412 | ## Do you like it? Star it! 413 | 414 | If you use this component just star it. A developer is more motivated to improve a project when there is some interest. 415 | 416 | Or consider offering me a coffee, it's a small thing but it is greatly appreciated: [about me](https://www.blocknot.es/about-me). 417 | 418 | ## Contributors 419 | 420 | - [Mattia Roccoberton](https://blocknot.es/): author 421 | 422 | ## License 423 | 424 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 425 | --------------------------------------------------------------------------------