clevic 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +371 -0
- data/INSTALL +10 -0
- data/Manifest.txt +30 -0
- data/README.txt +94 -0
- data/Rakefile +100 -0
- data/TODO +131 -0
- data/accounts_models.rb +122 -0
- data/bin/clevic +64 -0
- data/lib/active_record/dirty.rb +87 -0
- data/lib/clevic.rb +4 -0
- data/lib/clevic/browser.rb +195 -0
- data/lib/clevic/cache_table.rb +281 -0
- data/lib/clevic/db_options.rb +21 -0
- data/lib/clevic/delegates.rb +383 -0
- data/lib/clevic/extensions.rb +133 -0
- data/lib/clevic/field.rb +181 -0
- data/lib/clevic/item_delegate.rb +62 -0
- data/lib/clevic/model_builder.rb +171 -0
- data/lib/clevic/model_column.rb +23 -0
- data/lib/clevic/search_dialog.rb +77 -0
- data/lib/clevic/table_model.rb +431 -0
- data/lib/clevic/table_view.rb +479 -0
- data/lib/clevic/ui/browser.ui +201 -0
- data/lib/clevic/ui/browser_ui.rb +176 -0
- data/lib/clevic/ui/icon.png +0 -0
- data/lib/clevic/ui/search_dialog.ui +216 -0
- data/lib/clevic/ui/search_dialog_ui.rb +106 -0
- data/sql/accounts.sql +302 -0
- data/sql/times.sql +197 -0
- data/times_models.rb +163 -0
- metadata +93 -0
data/lib/clevic/field.rb
ADDED
@@ -0,0 +1,181 @@
|
|
1
|
+
require 'qtext/flags.rb'
|
2
|
+
|
3
|
+
module Clevic
|
4
|
+
|
5
|
+
=begin rdoc
|
6
|
+
This defines a field in the UI, and how it hooks up to a field in the DB.
|
7
|
+
=end
|
8
|
+
class Field
|
9
|
+
include QtFlags
|
10
|
+
|
11
|
+
attr_accessor :attribute, :path, :label, :delegate, :class_name, :alignment, :format, :tooltip
|
12
|
+
attr_writer :sample
|
13
|
+
|
14
|
+
# attribute is the symbol for the attribute on the model_class
|
15
|
+
def initialize( attribute, model_class, options )
|
16
|
+
# sanity checking
|
17
|
+
unless model_class.has_attribute?( attribute )
|
18
|
+
msg = <<EOF
|
19
|
+
#{attribute} not found in #{model_class.name}. Possibilities are:
|
20
|
+
#{model_class.attribute_names.join("\n")}
|
21
|
+
EOF
|
22
|
+
raise msg
|
23
|
+
end
|
24
|
+
|
25
|
+
# set values
|
26
|
+
@attribute = attribute
|
27
|
+
@model_class = model_class
|
28
|
+
|
29
|
+
options.each do |key,value|
|
30
|
+
self.send( "#{key}=", value ) if respond_to?( key )
|
31
|
+
end
|
32
|
+
|
33
|
+
# default the label
|
34
|
+
@label ||= attribute.to_s.humanize
|
35
|
+
|
36
|
+
# default formats
|
37
|
+
if @format.nil?
|
38
|
+
case meta.type
|
39
|
+
when :time; @format = '%H:%M'
|
40
|
+
when :date; @format = '%d-%h-%y'
|
41
|
+
when :datetime; @format = '%d-%h-%y %H:%M:%S'
|
42
|
+
when :decimal, :float; @format = "%.2f"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# default alignments
|
47
|
+
if @alignment.nil?
|
48
|
+
@alignment =
|
49
|
+
case meta.type
|
50
|
+
when :decimal, :integer, :float; qt_alignright
|
51
|
+
when :boolean; qt_aligncenter
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# return true if it's a date, a time or a datetime
|
57
|
+
# cache result because the type won't change in the lifetime of the field
|
58
|
+
def is_date_time?
|
59
|
+
@is_date_time ||= [:time, :date, :datetime].include?( meta.type )
|
60
|
+
end
|
61
|
+
|
62
|
+
# return ActiveRecord::Base.columns_hash[attribute]
|
63
|
+
# in other words an ActiveRecord::ConnectionAdapters::Column object
|
64
|
+
def meta
|
65
|
+
@model_class.columns_hash[attribute.to_s] || @model_class.reflections[attribute]
|
66
|
+
end
|
67
|
+
|
68
|
+
# return the name of the field for this Field, quoted for the dbms
|
69
|
+
def quoted_field
|
70
|
+
@model_class.connection.quote_column_name( meta.name )
|
71
|
+
end
|
72
|
+
|
73
|
+
def column
|
74
|
+
[attribute.to_s, path].compact.join('.')
|
75
|
+
end
|
76
|
+
|
77
|
+
# return an array of the various attribute parts
|
78
|
+
def attribute_path
|
79
|
+
pieces = [ attribute.to_s ]
|
80
|
+
pieces.concat( path.split( /\./ ) ) unless path.nil?
|
81
|
+
pieces.map{|x| x.to_sym}
|
82
|
+
end
|
83
|
+
|
84
|
+
# format this value. Use strftime for date_time types, or % for everything else
|
85
|
+
def do_format( value )
|
86
|
+
if self.format != nil
|
87
|
+
if is_date_time?
|
88
|
+
value.strftime( format )
|
89
|
+
else
|
90
|
+
self.format % value
|
91
|
+
end
|
92
|
+
else
|
93
|
+
value
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# return a sample for the field which can be used to size a column in the table
|
98
|
+
def sample
|
99
|
+
if @sample.nil?
|
100
|
+
self.sample =
|
101
|
+
case meta.type
|
102
|
+
# max width of 40 chars
|
103
|
+
when :string, :text
|
104
|
+
string_sample( 'n'*40 )
|
105
|
+
|
106
|
+
when :date, :time, :datetime
|
107
|
+
date_time_sample
|
108
|
+
|
109
|
+
when :numeric, :decimal, :integer, :float
|
110
|
+
numeric_sample
|
111
|
+
|
112
|
+
# TODO return a width, or something like that
|
113
|
+
when :boolean; 'W'
|
114
|
+
|
115
|
+
else
|
116
|
+
puts "#{@model_class.name}.#{attribute} is a #{meta.type}"
|
117
|
+
end
|
118
|
+
|
119
|
+
if $options[:debug]
|
120
|
+
puts "@sample for #{@model_class.name}.#{attribute} #{meta.type}: #{@sample.inspect}"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
# if we don't know how to figure it out from the data, just return the label size
|
124
|
+
@sample || self.label
|
125
|
+
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
def format_result( result_set )
|
130
|
+
unless result_set.size == 0
|
131
|
+
obj = result_set[0][attribute]
|
132
|
+
unless obj.nil?
|
133
|
+
do_format( obj )
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def string_sample( max_sample = nil )
|
139
|
+
result_set = @model_class.connection.execute <<-EOF
|
140
|
+
select distinct #{quoted_field}
|
141
|
+
from #{@model_class.table_name}
|
142
|
+
where
|
143
|
+
length( #{quoted_field} ) = (
|
144
|
+
select max( length( #{quoted_field} ) )
|
145
|
+
from #{@model_class.table_name}
|
146
|
+
)
|
147
|
+
EOF
|
148
|
+
unless result_set.entries.size == 0
|
149
|
+
result = result_set[0][0]
|
150
|
+
if max_sample.nil?
|
151
|
+
result
|
152
|
+
else
|
153
|
+
result.length < max_sample.length ? result : max_sample
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def date_time_sample
|
159
|
+
result_set = @model_class.find_by_sql <<-EOF
|
160
|
+
select #{quoted_field}
|
161
|
+
from #{@model_class.table_name}
|
162
|
+
where #{quoted_field} is not null
|
163
|
+
limit 1
|
164
|
+
EOF
|
165
|
+
format_result( result_set )
|
166
|
+
end
|
167
|
+
|
168
|
+
def numeric_sample
|
169
|
+
# TODO Use precision from metadata, not for integers
|
170
|
+
# returns nil for floats. So it's probably not useful
|
171
|
+
#~ puts "meta.precision: #{meta.precision.inspect}"
|
172
|
+
result_set = @model_class.find_by_sql <<-EOF
|
173
|
+
select max( #{quoted_field} )
|
174
|
+
from #{@model_class.table_name}
|
175
|
+
EOF
|
176
|
+
format_result( result_set )
|
177
|
+
end
|
178
|
+
|
179
|
+
end
|
180
|
+
|
181
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'Qt4'
|
2
|
+
|
3
|
+
module Qt
|
4
|
+
class KeyEvent
|
5
|
+
def inspect
|
6
|
+
"<Qt::KeyEvent text=#{text} key=#{key}"
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
module Clevic
|
12
|
+
|
13
|
+
class ItemDelegate < Qt::ItemDelegate
|
14
|
+
|
15
|
+
def initialize( parent )
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
19
|
+
# This catches the event that begins the edit process.
|
20
|
+
# Not used at the moment.
|
21
|
+
def editorEvent ( event, model, style_option_view_item, model_index )
|
22
|
+
puts "editorEvent"
|
23
|
+
puts "event: #{event.inspect}"
|
24
|
+
puts "model: #{model.inspect}"
|
25
|
+
puts "style_option_view_item: #{style_option_view_item.inspect}"
|
26
|
+
puts "model_index: #{model_index.inspect}"
|
27
|
+
super
|
28
|
+
end
|
29
|
+
|
30
|
+
def createEditor( parent_widget, style_option_view_item, model_index )
|
31
|
+
puts "model_index.metadata.type: #{model_index.metadata.type.inspect}"
|
32
|
+
if model_index.metadata.type == :date
|
33
|
+
# not going to work here because being triggered by
|
34
|
+
# an alphanumeric keystroke (as opposed to F4)
|
35
|
+
# will result in the calendar widget being opened.
|
36
|
+
#~ Qt::CalendarWidget.new( parent_widget )
|
37
|
+
super
|
38
|
+
else
|
39
|
+
super
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
#~ def setEditorData( editor, model_index )
|
44
|
+
#~ editor.value = model_index.gui_value
|
45
|
+
#~ end
|
46
|
+
|
47
|
+
#~ def setModelData( editor, abstract_item_model, model_index )
|
48
|
+
#~ model_index.gui_value = editor.value
|
49
|
+
#~ emit abstract_item_model.dataChanged( model_index, model_index )
|
50
|
+
#~ end
|
51
|
+
|
52
|
+
def updateEditorGeometry( editor, style_option_view_item, model_index )
|
53
|
+
# figure out where to put the editor widget, taking into
|
54
|
+
# account the sizes of the headers
|
55
|
+
rect = style_option_view_item.rect
|
56
|
+
rect.set_width( [editor.size_hint.width,rect.width].max )
|
57
|
+
rect.set_height( editor.size_hint.height )
|
58
|
+
editor.set_geometry( rect )
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
@@ -0,0 +1,171 @@
|
|
1
|
+
require 'clevic/table_model.rb'
|
2
|
+
require 'clevic/delegates.rb'
|
3
|
+
require 'clevic/cache_table.rb'
|
4
|
+
require 'clevic/field.rb'
|
5
|
+
|
6
|
+
module Clevic
|
7
|
+
|
8
|
+
=begin rdoc
|
9
|
+
This is used to define a set of fields in a UI, any related tables,
|
10
|
+
restrictions on data entry, formatting and that kind of thing.
|
11
|
+
|
12
|
+
Optional specifiers are:
|
13
|
+
* :sample is used to size the columns. Will default to some hopefully sensible value from the db.
|
14
|
+
* :format is something that can be understood by strftime (for time and date
|
15
|
+
fields) or understood by % (for everything else)
|
16
|
+
* :alignment is one of Qt::TextAlignmentRole, ie Qt::AlignRight, Qt::AlignLeft, Qt::AlignCenter
|
17
|
+
* :set is the set of strings that are accepted by a RestrictedDelegate
|
18
|
+
|
19
|
+
In the case of relational fields, all other options are passed to ActiveRecord::Base#find
|
20
|
+
|
21
|
+
For example, a the UI for a model called Entry would be defined like this:
|
22
|
+
|
23
|
+
Clevic::TableView.new( Entry, parent ).create_model do
|
24
|
+
# :format is optional
|
25
|
+
plain :date, :format => '%d-%h-%y'
|
26
|
+
plain :start, :format => '%H:%M'
|
27
|
+
plain :amount, :format => '%.2f'
|
28
|
+
# :set is mandatory
|
29
|
+
restricted :vat, :label => 'VAT', :set => %w{ yes no all }, :tooltip => 'Is VAT included?'
|
30
|
+
distinct :description, :conditions => 'now() - date <= interval( 1 year )'
|
31
|
+
relational :debit, 'name', :class_name => 'Account', :conditions => 'active = true', :order => 'lower(name)'
|
32
|
+
relational :credit, 'name', :class_name => 'Account', :conditions => 'active = true', :order => 'lower(name)'
|
33
|
+
|
34
|
+
# this is optional
|
35
|
+
records :order => 'date,start'
|
36
|
+
|
37
|
+
# could also be like this, where a..e are instances of Entry
|
38
|
+
records [ a,b,c,d,e ]
|
39
|
+
end
|
40
|
+
=end
|
41
|
+
class ModelBuilder
|
42
|
+
# The collection of Clevic::Field objects
|
43
|
+
attr_reader :fields
|
44
|
+
|
45
|
+
def initialize( table_view )
|
46
|
+
@table_view = table_view
|
47
|
+
@fields = []
|
48
|
+
end
|
49
|
+
|
50
|
+
# return the index of the named field
|
51
|
+
def index( field_name_sym )
|
52
|
+
retval = nil
|
53
|
+
fields.each_with_index{|x,i| retval = i if x.attribute == field_name_sym.to_sym }
|
54
|
+
retval
|
55
|
+
end
|
56
|
+
|
57
|
+
# the AR class for this table
|
58
|
+
def model_class
|
59
|
+
@table_view.model_class
|
60
|
+
end
|
61
|
+
|
62
|
+
# an ordinary field, edited in place with a text box
|
63
|
+
def plain( attribute, options = {} )
|
64
|
+
@fields << Clevic::Field.new( attribute.to_sym, model_class, options )
|
65
|
+
end
|
66
|
+
|
67
|
+
# edited with a combo box containing all previous entries in this field
|
68
|
+
def distinct( attribute, options = {} )
|
69
|
+
field = Clevic::Field.new( attribute.to_sym, model_class, options )
|
70
|
+
field.delegate = DistinctDelegate.new( @table_view, attribute, @table_view.model_class, options )
|
71
|
+
@fields << field
|
72
|
+
end
|
73
|
+
|
74
|
+
# edited with a combo box, but restricted to a specified set
|
75
|
+
def restricted( attribute, options = {} )
|
76
|
+
raise "restricted must have a set" unless options.has_key?( :set )
|
77
|
+
field = Clevic::Field.new( attribute.to_sym, model_class, options )
|
78
|
+
field.delegate = RestrictedDelegate.new( @table_view, attribute, @table_view.model_class, options )
|
79
|
+
@fields << field
|
80
|
+
end
|
81
|
+
|
82
|
+
# for foreign keys. Edited with a combo box using values from the specified
|
83
|
+
# path on the foreign key model object
|
84
|
+
def relational( attribute, path, options = {} )
|
85
|
+
unless options.has_key? :class_name
|
86
|
+
options[:class_name] = attribute.to_s.classify
|
87
|
+
end
|
88
|
+
field = Clevic::Field.new( attribute.to_sym, model_class, options )
|
89
|
+
field.path = path
|
90
|
+
field.delegate = RelationalDelegate.new( @table_view, field.attribute_path, options )
|
91
|
+
@fields << field
|
92
|
+
end
|
93
|
+
|
94
|
+
# add AR :include options for foreign keys, but it takes up too much memory,
|
95
|
+
# and actually takes longer to load a data set
|
96
|
+
def add_include_options
|
97
|
+
@fields.each do |field|
|
98
|
+
if field.delegate.class == RelationalDelegate
|
99
|
+
@options[:include] ||= []
|
100
|
+
@options[:include] << field.attribute
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# mostly used in the create_model block, but may also be
|
106
|
+
# used as an accessor for records
|
107
|
+
def records( *args )
|
108
|
+
if args.size == 0
|
109
|
+
get_records
|
110
|
+
else
|
111
|
+
set_records( args[0] )
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# This is intended to be called from the view class which instantiated
|
116
|
+
# this builder object.
|
117
|
+
def build
|
118
|
+
# build the model with all it's collections
|
119
|
+
# using @model here because otherwise the view's
|
120
|
+
# reference to this very same model is mysteriously
|
121
|
+
# set to nil
|
122
|
+
@model = Clevic::TableModel.new( self )
|
123
|
+
@model.object_name = @table_view.model_class.name
|
124
|
+
@model.dots = @fields.map {|x| x.column }
|
125
|
+
@model.labels = @fields.map {|x| x.label }
|
126
|
+
@model.attributes = @fields.map {|x| x.attribute }
|
127
|
+
@model.attribute_paths = @fields.map { |x| x.attribute_path }
|
128
|
+
|
129
|
+
# the data
|
130
|
+
@model.collection = records
|
131
|
+
# fill in an empty record
|
132
|
+
@model.collection << model_class.new if @model.collection.size == 0
|
133
|
+
|
134
|
+
# now set delegates
|
135
|
+
@table_view.item_delegate = Clevic::ItemDelegate.new( @table_view )
|
136
|
+
@fields.each_with_index do |field, index|
|
137
|
+
@table_view.set_item_delegate_for_column( index, field.delegate )
|
138
|
+
end
|
139
|
+
|
140
|
+
# give the built model back to the view class
|
141
|
+
# see above comment about @model
|
142
|
+
@table_view.model = @model
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
# The collection of model objects to display in a table
|
148
|
+
# arg can either be a Hash, in which case a new CacheTable
|
149
|
+
# is created, or it can be an array
|
150
|
+
# called by records( *args )
|
151
|
+
def set_records( arg )
|
152
|
+
if arg.class == Hash
|
153
|
+
# need to defer this until all fields are collected
|
154
|
+
@options = arg
|
155
|
+
else
|
156
|
+
@records = arg
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# return a collection of records. Usually this will be a CacheTable.
|
161
|
+
# called by records( *args )
|
162
|
+
def get_records
|
163
|
+
if @records.nil?
|
164
|
+
#~ add_include_options
|
165
|
+
@records = CacheTable.new( model_class, @options )
|
166
|
+
end
|
167
|
+
@records
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
=begin rdoc
|
2
|
+
This responds to the same methods as ActiveRecord::ConnectionAdapter::Column.
|
3
|
+
It exists to pretend that the accessors generated by association methods
|
4
|
+
have a column type of :association, which lets us make a sensible paste
|
5
|
+
decision using PasteRole, and makes the field/attribute distinction possible
|
6
|
+
in ModelIndex.
|
7
|
+
=end
|
8
|
+
class ModelColumn
|
9
|
+
attr_accessor :primary, :scale, :sql_type, :name, :precision, :default, :limit, :type, :meta
|
10
|
+
|
11
|
+
def initialize( name, type, meta )
|
12
|
+
@name = name
|
13
|
+
@type = type
|
14
|
+
@meta = meta
|
15
|
+
end
|
16
|
+
|
17
|
+
# return the underlying field name, ie "#{attribute}_id" if
|
18
|
+
# it's an association
|
19
|
+
alias_method :old_name, :name
|
20
|
+
def name
|
21
|
+
@meta.name
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'Qt4'
|
2
|
+
require 'clevic/ui/search_dialog_ui.rb'
|
3
|
+
require 'qtext/flags.rb'
|
4
|
+
|
5
|
+
class SearchDialog
|
6
|
+
include QtFlags
|
7
|
+
attr_reader :match_flags, :layout
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@layout = Ui_SearchDialog.new
|
11
|
+
@dialog = Qt::Dialog.new
|
12
|
+
@layout.setupUi( @dialog )
|
13
|
+
end
|
14
|
+
|
15
|
+
def from_start?
|
16
|
+
layout.from_start.value
|
17
|
+
end
|
18
|
+
|
19
|
+
def from_start=( value )
|
20
|
+
layout.from_start.value = value
|
21
|
+
end
|
22
|
+
|
23
|
+
def regex?
|
24
|
+
layout.regex.value
|
25
|
+
end
|
26
|
+
|
27
|
+
def whole_words?
|
28
|
+
layout.whole_words.value
|
29
|
+
end
|
30
|
+
|
31
|
+
def search_combo
|
32
|
+
layout.search_combo
|
33
|
+
end
|
34
|
+
|
35
|
+
def forwards?
|
36
|
+
@layout.forwards.checked?
|
37
|
+
end
|
38
|
+
|
39
|
+
def backwards?
|
40
|
+
@layout.backwards.checked?
|
41
|
+
end
|
42
|
+
|
43
|
+
# return either :backwards or :forwards
|
44
|
+
def direction
|
45
|
+
return :forwards if forwards?
|
46
|
+
return :backwards if backwards?
|
47
|
+
raise "direction not known"
|
48
|
+
end
|
49
|
+
|
50
|
+
def exec( text = '' )
|
51
|
+
search_combo.edit_text = text.to_s
|
52
|
+
search_combo.set_focus
|
53
|
+
retval = @dialog.exec
|
54
|
+
|
55
|
+
# remember previous searches
|
56
|
+
if search_combo.find_text( search_combo.current_text ) == -1
|
57
|
+
search_combo.add_item( search_combo.current_text )
|
58
|
+
end
|
59
|
+
|
60
|
+
#~ Qt::MatchExactly 0 Performs QVariant-based matching.
|
61
|
+
#~ Qt::MatchFixedString 8 Performs string-based matching. String-based comparisons are case-insensitive unless the MatchCaseSensitive flag is also specified.
|
62
|
+
#~ Qt::MatchContains 1 The search term is contained in the item.
|
63
|
+
#~ Qt::MatchStartsWith 2 The search term matches the start of the item.
|
64
|
+
#~ Qt::MatchEndsWith 3 The search term matches the end of the item.
|
65
|
+
#~ Qt::MatchCaseSensitive 16 The search is case sensitive.
|
66
|
+
#~ Qt::MatchRegExp 4 Performs string-based matching using a regular expression as the search term.
|
67
|
+
#~ Qt::MatchWildcard 5 Performs string-based matching using a string with wildcards as the search term.
|
68
|
+
#~ Qt::MatchWrap 32 Perform a search that wraps around, so that when the search reaches the last item in the model, it begins again at the first item and continues until all items have been examined.
|
69
|
+
|
70
|
+
retval
|
71
|
+
end
|
72
|
+
|
73
|
+
def search_text
|
74
|
+
search_combo.current_text
|
75
|
+
end
|
76
|
+
|
77
|
+
end
|