SketchUp / htmldialog-inputbox

UI::HtmlDialog example recreating UI.inputbox functionality in the SketchUp Ruby API
MIT License
6 stars 5 forks source link

No Current Way to Specify Return Type Without a Default Value #7

Open DanRathbun opened 2 years ago

DanRathbun commented 2 years ago

No Current Way to Specify Return Type Without a Default Value.

As discussed in the last PR comments, when a coder passes nil or otherwise does not specify a default value (the Textbox class constructor uses nil as a default,) and the result returned is a String object by default. This is the same behavior had the coder purposefully passed "" (an empty String) as the argument for the input's default. But in this latter case (the purposeful empty String) is an explicit directive that the coder wants a String object returned by type_convert().

But a coder may not want a String result but also may wish for the input to be blank, so they cannot pass a default value.

In those previous comments, I had entertained the idea that specific subclasses of Textbox such as TextboxLength, TextboxFloat, TextboxInteger, TextboxTime, etc. But this would be clunky, and the easiest solution came to me whilst sitting on the "thinking throne". ;)

1 - There needs to be a documentation note that informs coders that no default or nil for a default will produce a String (just as if they had passed "".)

2 - The answer for result typing without a default value is simple. (It's a "Doh!" forehead smacker.)

We make changes that allow coders to pass class identifiers in place of a default value. Ie ...

  def self.prompt_without_options
    title = 'Tell me about yourself'
    prompts = ['What is your name?', 'What is your age?', 'Pet?']
    defaults = ["", Integer, 'None']
    results = HtmlUI.inputbox(prompts, defaults, title)
    p results
  end

Advanced inputs ...

  def self.prompt_advanced
    options = {
      title: 'HtmlDialog Options',
      accept_button: 'Ok',
      cancel_button: 'Cancel',
      inputs: [
        HtmlUI::Textbox.new('Name'), # will be a blank field, String assumed
        HtmlUI::Textbox.new('Age', Integer),
        HtmlUI::Dropdown.new('Pet', 'Cat', [
          'None', 'Cat', 'Dog', 'Parrot (Resting)', 'Other'
        ]),
        HtmlUI::Listbox.new('Profession', 'Architect', [
          'None', 'Architect', 'Urban Planner', 'Model Railroad Designer', 'Other'
        ]),
      ]
    }
    dialog = HtmlUI::InputBox.new(options)
    results = dialog.prompt
    p results
  end

If the end user leaves these "typed" fields blank, then the value will be 0 (for Integer), 0.0 (for Float) and 0.000" (for Length).

The coder is then responsible for dealing with the zero values.

DanRathbun commented 2 years ago

Dealing with Class Identifiers in the type_convert() method:

EDIT (10-01-2021): The snippet below is not the final version of this method. There are input value conversion exceptions also to deal with. For more, see:


Example:

  module HtmlUI

    class InputBox

      def type_convert(options, values)
        values.each_with_index.map { |value, index|
          default = options[:inputs][index][:default]
          # Check for a class identifier:
          if default.is_a?(Class)
            if default == Length
              value.to_l
            elsif default == Float
              value.to_f
            elsif default == Integer
              value.to_i
            elsif default == TrueClass || default == FalseClass
              value
            else # String assumed
              value.to_s
            end
          else # it's not, so "sniff" the given default type:
            case default
            when Length
              value.to_l
            when Float
              value.to_f
            when Integer
              value.to_i
            when TrueClass, FalseClass
              # We're getting true/false values from Vue, no need to post-process.
              value
            else
              value.to_s
            end
          end
        }
      end

    end # class Inputbox

    # ... the compatibility method defintion ...

  end # module HtmlUI
DanRathbun commented 2 years ago

Hmmm... I think I can reduce the above to ... (but I think the above would eval faster and be more understandable) ...

EDIT (10-01-2021): Note that I did not go with the paradigm below as it is a confusing read and the quirky nature of the === methods might easily lead to breakage in the future if someone makes changes. I went with the pattern in the post above.

  module HtmlUI

    class InputBox

      def type_convert(options, values)
        values.each_with_index.map { |value, index|
          default = options[:inputs][index][:default]
          # Allow class identifier || literal values as defaults:
          if Length === default || (default.is_a?(Class) && default == Length)
            value.to_l
          elsif Float === default || default == Float
            value.to_f
          elsif Integer === default || default == Integer
            value.to_i
          elsif TrueClass === default || default == TrueClass ||
               FalseClass === default || default == FalseClass
            value
          else # String assumed
            value.to_s
          end
        }
      end

    end # class Inputbox

    # ... the compatibility method defintion ...

  end # module HtmlUI

Length requires special handling because of the non-standard implementation of Length#==. It raises an ArgumentError if a class object is passed in (weirdly not a TypeError.) ie ... "Error: #<ArgumentError: comparison of Length with Class failed>"

Normally all class #== methods accept a class object and return false for instance receiver objects. Ie, in the case of a Float:

0.0 == Float
#=> false

... and ...

Float === 0.0
#=> true
DanRathbun commented 2 years ago

Also TODO:

get_options_args() in class ArgumentParser needs some work to accept class identifiers in the following conditional:

Beginning line 97 ... after line 93 has initialized input[:value] to an empty String ...

        # Default
        if default = arguments[:defaults][index]
          input[:default] = default
          input[:value] = default
        end

This is currently testing for a nil value (which happens if the coder does not pass a default value or explicitly passes nil.)

So if it is nil (falsie), input[:value] will be left as an empty String, and input[:default] will not be set at all which will return nil if the options hash is asked for it.

We will want class identifiers as the value for the input[:default] key (so type_convert() can test for them,) ... but in that case we do not want to set input[:value]. We want it to remain blank, (ie no default but a return type.)

UPDATED (10-01-2021): In the snippets below, I've broken out the arguments hash out into local variables. So, defaults was arguments[:defaults] and lists was arguments[:list].

        # Default and Value:
        default = defaults[index]
        if default.nil?
          input[:default] = '' # Leave :value empty
        else
          # Set input[:default] to a literal value or a class identifier:
          input[:default] = default
          # Only set input[:value] if it's not a class identifier:
          input[:value] = default unless default.is_a?(Class)
        end

And also in the next section setting the input[:default] and input[:value] for dropdown controls ...

        # Options
        list = lists[index]
        if list && !list.empty?
          input[:type] = 'dropdown'
          if input[:default] != '' && !default.is_a?(Class)
            # Check if we should leave render control as unchosen:
            if !input[:value].empty? && !list.include?(default)
              # NOTE: The non-matching warning is generated above.
              input[:value] = ''
            end
          end
          input[:options] = list
        end
DanRathbun commented 2 years ago

I'm still developing this idea as time permits. (Finalized per posts below)

... thinking out loud ...

The dialog JS side does nothing with the defaults. The Ruby-side sets the value to the default before the json is passed over. And then the VueJS is just returning the values as existing upon Accept.

So I think, we don't need to serialize @default in the JSON (HtmlUI::Input#as_json) for the dialog, as it is not used. But get_options_hash() uses HtmlUI::Input#as_json() to map each of the :inputs arrays into a hash of input control properties. So we still need @default included in those hashes as the :default property.

However, it does look like JSON serializes a Ruby class identifier as it's string name. This is because in the absence of a ::json_create class method for an object, the object's #to_s method is called. For a class or module object this defaults to #name, so we get the class names.

hash = {"a" => Float, "b" => Integer, "c" => Length}
json = hash.to_json
json.inspect
#=> "{\"a\":\"Float\",\"b\":\"Integer\",\"c\":\"Length\"}"

But the JSON library does not convert them back into Ruby Class objects. ...

new_hash = JSON.parse(json)
#=> {"a"=>"Float", "b"=>"Integer", "c"=>"Length"}

I was worried that since SketchUp API classes are not yet JSON compatible, that either we'd not get through JSON serialization or that JavaScript would see an undefined Length object identifier and give us an error.

So, the takeaway is that a Length class identifier as a default should not upset the dialog's JS because it is passed over as a string name. True also for any other acceptable class identifier.

DanRathbun commented 2 years ago

OK. Update. I finalized this last evening. (which was 30-SEP-2021)

For the compatibility signature in ArgumentParser#get_options_args(), I updated the setting of :default and :value per the snippets shown (and updated 10-01-2021) two posts above.


Also, I was getting the class identifier strings coming through into the input values and displaying in the dialog when using the advanced hash arguments. So I needed to prevent this and the best place was the as_json() method of the Input superclass:

require 'json'

module Example::HtmlInputBox
  module HtmlUI

    # The superclass of all HtmlUI input control classes.
    class Input

      # Called by the class constructor after instantiating the new
      # instance object to set instance variables, etc.
      def initialize(label: '', default: nil, options: [])
        @label   = label
        @default = default
        @options = options
      end

      # Returns a hash, that can be turned into a JSON String object
      # representing this object's properties.
      #
      # Any class identifier in `@default` are converted to class name
      # strings by the JSON library. These class names must not be copied
      # into the `:value` of the output hash.
      #
      # @return [Hash]
      def as_json(*)
        default = @default.nil? ? '' : @default
        value   = default.is_a?(Class) ? '' : default
        {
          label:   @label,
          default: default,
          value:   value,
          options: @options,
          type: self.class.name.downcase.split('::').last
        }
      end

      # Returns a JSON String representing this instance object's properties.
      #
      # @return [String] The object properties in JavaScript Object Notation.
      def to_json(*args)
        as_json.to_json(*args)
      end

    end # class

  end # module
end # module