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.

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_view ⇒ Object

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_options ⇒ Object

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

#dataset ⇒ Object

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_ui ⇒ Object

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_class ⇒ Object

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

#fields ⇒ Object

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