if RUBY_VERSION > '1.9.0' OrderedHash = Hash else require 'hashery/orderedhash' end require 'clevic/table_model.rb' require 'clevic/cache_table.rb' require 'clevic/field.rb' module Clevic =begin rdoc == View definition Clevic::ModelBuilder defines the DSL used to create a UI definition (which is actually a set of Clevic::Field instances), including any related tables, restrictions on data entry, formatting and so on. The intention was to make specifying a UI as painless as possible, with framework overhead only where you need it. To that end, there are 2 ways to define UIs: - an Embedded View as part of the model (Sequel::Model) object (which is useful if you want minimal framework overhead). Just show me the data, dammit. - a Separate View in a separate class (which is useful when you want several diffent views of the same underlying table). I want a neato-nifty UI that does (relatively) complex things. I've tried to consistently refer to an instance of an Sequel::Model subclass as an 'entity'. ==Embedded View Minimal embedded definition is class Position < Sequel::Model include Clevic::Record end which will build a fairly sensible default UI from the entity's metadata. Obviously you can use open classes to do class Position < Sequel::Model one_to_many :transactions many_to_one :account end class Position include Clevic::Record end A full-featured UI for an entity called Entry (part of an accounting database) could be defined like this: class Entry < Sequel::Model belongs_to :invoice belongs_to :activity belongs_to :project include Clevic::Record # spans of time more than 8 ours are coloured violet # because they're often the result of typos. def time_color return if self.end.nil? || start.nil? 'darkviolet' if self.end - start > 8.hours end # tooltip for spans of time > 8 hours def time_tooltip return if self.end.nil? || start.nil? 'Time interval greater than 8 hours' if self.end - start > 8.hours end define_ui do plain :date, :sample => '28-Dec-08' # The project field relational :project do |field| field.display = 'project' # see Sequel::Dataset docs field.dataset.filter( :active => true ).order{ lower(project) } # handle data changed events. In this case, # auto-fill-in the invoice field. field.notify_data_changed do |entity_view, table_view, model_index| if model_index.entity.invoice.nil? entity_view.invoice_from_project( table_view, model_index ) do # move here next if the invoice was changed table_view.override_next_index model_index.choppy( :column => :start ) end end end end relational :invoice, :display => 'invoice_number', :conditions => "status = 'not sent'", :order => 'invoice_number' # call time_color method for foreground color value plain :start, :foreground => :time_color, :tooltip => :time_tooltip # another way to call time_color method for foreground color value plain :end, :foreground => lambda{|x| x.time_color}, :tooltip => :time_tooltip # multiline text text :description, :sample => 'This is a long string designed to hold lots of data and description' relational :activity do display 'activity' order 'lower(activity)' sample 'Troubleshooting' conditions 'active = true' end distinct :module, :tooltip => 'Module or sub-project' plain :charge, :tooltip => 'Is this time billable?' distinct :person, :default => 'John', :tooltip => 'The person who did the work' records :order => 'date, start, id' end def self.define_actions( view, action_builder ) action_builder.action :smart_copy, 'Smart Copy', :shortcut => 'Ctrl+"' do smart_copy( view ) end action_builder.action :invoice_from_project, 'Invoice from Project', :shortcut => 'Ctrl+Shift+I' do invoice_from_project( view, view.current_index ) do # execute the block if the invoice is changed # save this before selection model is cleared current_index = view.current_index view.selection_model.clear view.current_index = current_index.choppy( :column => :start ) end end end # do a smart copy from the previous line def self.smart_copy( view ) view.sanity_check_read_only view.sanity_check_ditto # need a reference to current_index here, because selection_model.clear will # invalidate view.current_index. And anyway, its shorter and easier to read. current_index = view.current_index if current_index.row >= 1 # fetch previous item previous_item = view.model.collection[current_index.row - 1] # copy the relevant fields current_index.entity.date = previous_item.date if current_index.entity.date.blank? # depends on previous line current_index.entity.start = previous_item.end if current_index.entity.date == previous_item.date # copy rest of fields [:project, :invoice, :activity, :module, :charge, :person].each do |attr| current_index.entity.send( "#{attr.to_s}=", previous_item.send( attr ) ) end # tell view to update view.model.data_changed do |change| change.top_left = current_index.choppy( :column => 0 ) change.bottom_right = current_index.choppy( :column => view.model.fields.size - 1 ) end # move to the first empty time field next_field = if current_index.entity.start.blank? :start else :end end # next cursor location view.selection_model.clear view.current_index = current_index.choppy( :column => next_field ) end end # Auto-complete invoice number field from project. # &block will be executed if an invoice was assigned # If block takes one parameter, pass the new invoice. def self.invoice_from_project( table_view, current_index, &block ) if current_index.entity.project != nil # most recent entry, ordered in reverse invoice = current_index.entity.project.latest_invoice unless invoice.nil? # make a reference to the invoice current_index.entity.invoice = invoice # update view from top_left to bottom_right table_view.model.data_changed( current_index.choppy( :column => :invoice ) ) unless block.nil? if block.arity == 1 block.call( invoice ) else block.call end end end end end end == Separate View To define a separate ui class, do something like this: class Prospect < Clevic::View # This is the Sequel::Model descendant entity_class Position # This must return a ModelBuilder instance, which is made easier # by putting the block in a call to model_builder. # # With no parameter, the block # will be evaluated in the context of a Clevic::ModelBuilder instance, # otherwise the parameter will have the Clevic::ModelBuilder instance # so you can still access the surrounding scope. def define_ui model_builder do |mb| # use the define_ui block from Position mb.exec_ui_block( Position ) # any other ModelBuilder code can go here too # use a different recordset mb.records :conditions => "status in ('prospect','open')", :order => 'date desc,code' end end end And you can even inherit UIs: class Extinct < Prospect def define_ui # reuse all UI definitions from Prospect super # and again another recordset model_builder do |mb| mb.records :conditions => "status in ('dead')", :order => 'date desc,code' end end end Obviously you can use any of the Clevic::ModelBuilder calls described above, and exemplified in the embedded example, inside of the model_builder block. == DSL detail This section describes the syntax of the DSL. === Field Types and specifiers There are only a few field types, with lots of options. All field definitions start with a field type, have an attribute, and take either a hash of options, or a block for options. If the block specifies a parameter, an instance of Clevic::Field will be passed. If the block has no parameter, it will be evaluated in the context of a Clevic::Field instance. All the options specified can use DSL-style acessors (no assignment =) or assignment statement. plain is an ordinary editable field. Boolean values are displayed as checkboxes. text is a multiline editable field. relational displays a set of values pulled from a many-to-one relationship. In other words all the possible related entities that this one could be related to. Some concise representation of the related entities are displayed in a combo box. :display is mandatory. distinct fetches the set of values already in the field, so you don't have to re-type them. New values are added in the text field part of the combo box. There is some prefix matching. restricted is a combo box that is not editable in the text field part - the user must select a value from the :set (an array of strings) supplied. If :set has a hash as its value, the field will display the hash values, and the hash keys will be stored in the db. hide you won't see this field. Actually, it's only useful after a default_ui, or pulling the definition from somewhere else. It may go away and be replaced by remove. === Attribute The attribute symbol is required, and is the first parameter after the field type. It must refer to a method already defined in the entity. In other words any of: - a db column - a relationship (one_to_many, etc) - a plain method that takes no parameters. will work. You can do things like this: plain :entries, :label => 'First Entry', :display => 'first.date', :format => '%d-%b-%y' plain :entries, :label => 'Last Entry', :display => 'last.date', :format => '%d-%b-%y' Where the attribute fetches a collection of related entities, and :display will cause exactly one of those values to be passed to :format. === Options Optional specifiers follow the attribute, as hash parameters, or as a block. Many of them will accept as a value one of: - String, some kind of value - Symbol, referring to a method on the entity - Proc which takes the entity as a parameter See Clevic::Field properties for available options. === Menu Items You can define view/model specific menu items. These will be added to the Edit menu, show up on context-click in the table display, and can have optional keyboard shortcuts: def define_actions( table_view, action_builder ) action_builder.action :smart_copy, 'Smart Copy', :shortcut => 'Ctrl+"' do # a method in the class containing define_actions # view.current_index.entity will return the entity instance. smart_copy( view ) end action_builder.action :invoice_from_project, 'Invoice from Project', :shortcut => 'Ctrl+Shift+I' do # a method in the class containing define_actions invoice_from_project( view.current_index, view ) end end === Notifications Key presses will be sent here: # may also be defined as class methods on an entity class. def notify_key_press( table_view, key_press_event, current_model_index ) end Fields have a property called notify_data_changed, which is called whenever the field value changes. There is also an view method: def notify_data_changed( table_view, top_left_model_index, bottom_right_model_index ) end But note that this will override the delegation to the field notify_data_changed unless super is called. === Tab Order Using an embedded definition, tab order in the browser is defined by the order in which view definitions are encountered. Which is really useful if you want to have several view definitions in one file and just execute clevic on that file. For more complex situations where your code needs to be separated into multiple files, as is traditional and useful for most non-trivial projects, the order can be accessed in Clevic::View.order, and specified by Clevic::View.order = [Position, Target, Account] =end class ModelBuilder # Create a definition for entity_view (subclass of Clevic::View). # Then execute block using self.instance_eval. # entity_view must respond to entity_class, and if title is called, it # must respond to title. def initialize( entity_view, &block ) @entity_view = entity_view @auto_new = true @read_only = false # TODO not needed for 1.9 @fields = OrderedHash.new exec_ui_block( &block ) end attr_accessor :entity_view attr_accessor :find_options # execute a block containing method calls understood by Clevic::ModelBuilder # arg can be something that responds to define_ui_block, # or just the block will be executed. If both are present, # values in the block will overwrite values in arg's block. def exec_ui_block( arg = nil, &block ) if !arg.nil? and arg.respond_to?( :define_ui_block ) exec_ui_block( &arg.define_ui_block ) end unless block.nil? if block.arity == -1 instance_eval( &block ) else block.call( self ) end end self end # The collection of Clevic::Field instances where visible == true. # the visible may go away. def fields #~ @fields.reject{|id,field| !field.visible} @fields end # return the index of the named field in the collection of fields. def index( field_name_sym ) retval = nil fields.each_with_index{|id,field,i| retval = i if field.attribute == field_name_sym.to_sym } retval end # The ORM class def entity_class @entity_view.entity_class end # set read_only to true def read_only! @read_only = true end # should this table automatically show a new blank record? def auto_new( bool ) @auto_new = bool end # should this table automatically show a new blank record? def auto_new?; @auto_new; end # DSL for changing the title def title( value ) entity_view.title = value end # an ordinary field, edited in place with a text box def plain( attribute, options = {}, &block ) read_only_default!( attribute, options ) field = Clevic::Field.new( attribute.to_sym, entity_class, options, &block ) # plain_delegate will be defined in a framework-specific file. # This is becoming a kind of poor man's inheritance. I don't # think I like that. field.delegate = plain_delegate( field ) @fields[attribute] = field end # an ordinary field like plain, except that a larger edit area can be used def text( attribute, options = {}, &block ) read_only_default!( attribute, options ) field = Clevic::Field.new( attribute.to_sym, entity_class, options, &block ) field.delegate = TextAreaDelegate.new( field ) @fields[attribute] = field end # Returns a Clevic::Field with a DistinctDelegate, in other words # a combo box containing all values for this field from the table. def distinct( attribute, options = {}, &block ) field = Clevic::Field.new( attribute.to_sym, entity_class, options, &block ) field.delegate = DistinctDelegate.new( field ) @fields[attribute] = field end # a combo box with a set of supplied values def combo( attribute, options = {}, &block ) field = Clevic::Field.new( attribute.to_sym, entity_class, options, &block ) # TODO this really belongs in a separate 'map' field? # or maybe put it in SetDelegate? if field.set.is_a? Hash field.format ||= lambda{|x| field.set[x]} end field.delegate = SetDelegate.new( field ) @fields[attribute] = field end # Returns a Clevic::Field with a restricted SetDelegate, def restricted( attribute, options = {}, &block ) options[:restricted] = true combo( attribute, options, &block ) end # For many_to_one relationships. # Edited with a combo box using values from the specified # path on the foreign key model object # if options[:format] has a value, it's used either as a block # or as a dotted path def relational( attribute, options = {}, &block ) field = Clevic::Field.new( attribute.to_sym, entity_class, options, &block ) field.delegate = RelationalDelegate.new( field ) @fields[attribute] = field end def tags( attribute, options = {}, &block ) field = Clevic::Field.new( attribute.to_sym, entity_class, options, &block ) # build a collection setter if necessary unless entity_class.instance_methods.include? "#{attribute}=" raise NotImplementedError, "Need to build a collection setter for '#{attribute}='" end field.delegate = TagDelegate.new( field ) @fields[attribute] = field end # force a checkbox def check( attribute, options = {}, &block ) read_only_default!( attribute, options ) field = @fields[attribute] = Clevic::Field.new( attribute.to_sym, entity_class, options, &block ) field.delegate = BooleanDelegate.new( field ) end # specify the dataset but just calling and chaining, thusly # dataset.order( :some_field ).filter( :active => true ) def dataset @dataset_roller = DatasetRoller.new( entity_class.dataset ) end def records( *args ) puts "ModelBuilder#records is deprecated. Use ModelBuilder#dataset instead" require 'clevic/sequel_ar_adapter.rb' entity_class.plugin :ar_methods @cache_table = CacheTable.new( entity_class, entity_class.translate( args.first ) ) end # Tell this field not to show up in the UI. # Mainly intended to be called after default_ui has been called. def hide( attribute ) field( attribute ).visible = false end # Build a default UI. All fields except the primary key are displayed # as editable in the table. Any belongs_to relations are used to build # combo boxes. Default ordering is the primary key. # Subscriber is already defined elsewhere as a subclass # of an ORM class ie Sequel::Model: # class Subscriber # include Clevic::Record # define_ui do # default_ui # plain :password # this field does not exist in the DB # hide :password_salt # these should be hidden # hide :password_hash # end # end # # An attempt to use a sensible :display option for the related class. In order: # * the name of the class # * :name # * :title # * :username # * :to_s def default_ui # don't create an empty record, because sometimes there are # validations that will cause trouble auto_new false # build columns entity_class.attributes.each do |column,model_column| begin if model_column.association? relational column do |f| # TODO this should be tableize or equivalent %W{#{model_column.related_class.name.downcase} name title username}.each do |name| if model_column.related_class.instance_methods.include?( name ) f.display = name.to_sym break end end end else plain column end rescue puts $!.message puts $!.backtrace # just do a plain puts "Doing plain for #{entity_class}.#{column}" plain column end end end # return the named Clevic::Field object def field( attribute ) @fields.find {|id,field| field.attribute == attribute } end # This takes all the information collected # by the other methods, and returns a new TableModel # with the given parent (usually a TableView) as its parent. def build( parent ) # build the model with all it's collections # using @model here because otherwise the view's # reference to this very same model is garbage collected. @model = Clevic::TableModel.new @model.builder = self @model.entity_view = entity_view @model.fields = @fields.values @model.read_only = @read_only @model.auto_new = auto_new? # set view name parent.object_name = @object_name if parent.respond_to? :object_name # set UI parent for all delegates # and model for each field fields.each do |id,field| field.delegate.parent = parent unless field.delegate.nil? field.model = @model end # the data @model.collection = create_cache_table @model end protected # set a sensible read-only value if it isn't already specified in options def read_only_default!( attribute, options ) # sensible defaults for read-only-ness options[:read_only] ||= case when options[:display].respond_to?( :call ) # it's a Proc or a Method, so we can't set it true when entity_class.column_names.include?( options[:display].to_s ) # it's a DB column, so it's not read only false when entity_class.reflections.include?( attribute ) # one-to-one relationships can be edited. many-to-one certainly can't entity_class.meta[attribute].type != :many_to_one when entity_class.instance_methods.include?( attribute.to_s ) # read-only if there's no setter for the attribute !entity_class.instance_methods.include?( "#{attribute.to_s}=" ) else # default to not read-only false end end def create_cache_table if @dataset_roller @cache_table = CacheTable.new( entity_class, @dataset_roller.dataset ) end # otherwise just default it @cache_table ||= CacheTable.new( entity_class ) end end end