Class: Clevic::ModelBuilder

Inherits:
Object show all
Defined in:
lib/clevic/model_builder.rb,
lib/clevic/qt/model_builder.rb,
lib/clevic/swing/model_builder.rb

Overview

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]

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(entity_view, &block) ⇒ 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.



376
377
378
379
380
381
382
# File 'lib/clevic/model_builder.rb', line 376

def initialize( entity_view, &block )
  @entity_view = entity_view
  @auto_new = true
  @read_only = false
  @fields = Hash.new
  exec_ui_block( &block )
end

Instance Attribute Details

#entity_viewObject

Returns the value of attribute entity_view.



384
385
386
# File 'lib/clevic/model_builder.rb', line 384

def entity_view
  @entity_view
end

#find_optionsObject

Returns the value of attribute find_options.



385
386
387
# File 'lib/clevic/model_builder.rb', line 385

def find_options
  @find_options
end

Instance Method Details

#add_field(field) ⇒ Object



445
446
447
# File 'lib/clevic/model_builder.rb', line 445

def add_field( field )
  fields[field.id || field.attribute] = field
end

#auto_new(bool) ⇒ Object

should this table automatically show a new blank record?



433
434
435
# File 'lib/clevic/model_builder.rb', line 433

def auto_new( bool )
  @auto_new = bool
end

#auto_new?Boolean

should this table automatically show a new blank record?

Returns:

  • (Boolean)


438
# File 'lib/clevic/model_builder.rb', line 438

def auto_new?; @auto_new; end

#build(parent) ⇒ Object

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.



607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
# File 'lib/clevic/model_builder.rb', line 607

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

#check(attribute, options = {}, &block) ⇒ Object

force a checkbox



521
522
523
524
525
526
# File 'lib/clevic/model_builder.rb', line 521

def check( attribute, options = {}, &block )
  read_only_default!( attribute, options )
  field = Clevic::Field.new( attribute.to_sym, entity_class, options, &block )
  field.delegate = BooleanDelegate.new( field )
  add_field field
end

#combo(attribute, options = {}, &block) ⇒ Object

a combo box with a set of supplied values



478
479
480
481
482
483
484
485
486
487
488
489
# File 'lib/clevic/model_builder.rb', line 478

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 )
  add_field field
end

#datasetObject

specify the dataset but just calling and chaining, thusly

dataset.order( :some_field ).filter( :active => true )


530
531
532
# File 'lib/clevic/model_builder.rb', line 530

def dataset
  @dataset_roller = DatasetRoller.new( entity_class.dataset )
end

#default_uiObject

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



568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
# File 'lib/clevic/model_builder.rb', line 568

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

#distinct(attribute, options = {}, &block) ⇒ Object

Returns a Clevic::Field with a DistinctDelegate, in other words a combo box containing all values for this field from the table.



471
472
473
474
475
# File 'lib/clevic/model_builder.rb', line 471

def distinct( attribute, options = {}, &block )
  field = Clevic::Field.new( attribute.to_sym, entity_class, options, &block )
  field.delegate = DistinctDelegate.new( field )
  add_field field
end

#entity_classObject

The ORM class



423
424
425
# File 'lib/clevic/model_builder.rb', line 423

def entity_class
  @entity_view.entity_class
end

#exec_ui_block(arg = nil, &block) ⇒ Object

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.



391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/clevic/model_builder.rb', line 391

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 RUBY_VERSION && RUBY_VERSION >= '1.9.0' && block.arity == 0
      instance_eval( &block )
    elsif block.arity == -1
      instance_eval( &block )
    else
      block.call( self )
    end
  end
  self
end

#field(attribute) ⇒ Object

return the named Clevic::Field object



600
601
602
# File 'lib/clevic/model_builder.rb', line 600

def field( attribute )
  fields.find {|id,field| field.attribute == attribute }
end

#fieldsObject

The collection of Clevic::Field instances where visible == true. the visible may go away.



410
411
412
413
# File 'lib/clevic/model_builder.rb', line 410

def fields
  #~ @fields.reject{|id,field| !field.visible}
  @fields
end

#hide(attribute) ⇒ Object

Tell this field not to show up in the UI. Mainly intended to be called after default_ui has been called.



543
544
545
# File 'lib/clevic/model_builder.rb', line 543

def hide( attribute )
  field( attribute ).visible = false
end

#index(field_name_sym) ⇒ Object

return the index of the named field in the collection of fields.



416
417
418
419
420
# File 'lib/clevic/model_builder.rb', line 416

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

#plain(attribute, options = {}, &block) ⇒ Object

an ordinary field, edited in place with a text box



450
451
452
453
454
455
456
457
458
459
# File 'lib/clevic/model_builder.rb', line 450

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 )
  add_field field
end

#plain_delegate(field) ⇒ Object

Not sure if this is the right place to put it, but try here and see if it works out.



5
6
# File 'lib/clevic/qt/model_builder.rb', line 5

def plain_delegate( field )
end

#read_only!Object

set read_only to true



428
429
430
# File 'lib/clevic/model_builder.rb', line 428

def read_only!
  @read_only = true
end

#records(*args) ⇒ Object



534
535
536
537
538
539
# File 'lib/clevic/model_builder.rb', line 534

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

#relational(attribute, options = {}, &block) ⇒ Object

For many_to_one relationships. Edited with a combo box using values from the specified path on the foreign key model object if options has a value, it’s used either as a block or as a dotted path



502
503
504
505
506
# File 'lib/clevic/model_builder.rb', line 502

def relational( attribute, options = {}, &block )
  field = Clevic::Field.new( attribute.to_sym, entity_class, options, &block )
  field.delegate = RelationalDelegate.new( field )
  add_field field
end

#restricted(attribute, options = {}, &block) ⇒ Object

Returns a Clevic::Field with a restricted SetDelegate,



492
493
494
495
# File 'lib/clevic/model_builder.rb', line 492

def restricted( attribute, options = {}, &block )
  options[:restricted] = true
  combo( attribute, options, &block )
end

#tags(attribute, options = {}, &block) ⇒ Object



508
509
510
511
512
513
514
515
516
517
518
# File 'lib/clevic/model_builder.rb', line 508

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 )
  add_field field
end

#text(attribute, options = {}, &block) ⇒ Object

an ordinary field like plain, except that a larger edit area can be used



462
463
464
465
466
467
# File 'lib/clevic/model_builder.rb', line 462

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 )
  add_field field
end

#title(value) ⇒ Object

DSL for changing the title



441
442
443
# File 'lib/clevic/model_builder.rb', line 441

def title( value )
  entity_view.title = value
end