├── 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 | [](https://badge.fury.io/rb/tiny_admin)
4 | [](https://rubygems.org/gems/tiny_admin)
5 | [](https://github.com/blocknotes/tiny_admin/actions/workflows/linters.yml)
6 | [](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 | 
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 |
--------------------------------------------------------------------------------