Repository: biolab/orange3

Issue:
Allow custom (generic) names in Transpose Widget
When I transpose a dataset with no meta attributes, I end up with variables named Feature 1, Feature 2, Feature 3, ...
It would be nice if I could specify my own prefix (such as User, Transaction, Cell, House, ...).

The text box with the prefix (the default can stay Feature, although I would vote for Row) could be placed below the Generic options, the way variable combo is placed below the From meta attribute option.
![screen shot 2017-11-02 at 11 09 31](https://user-images.githubusercontent.com/588601/32320544-5f62aeaa-bfbe-11e7-9e16-e7bc03cdf5c5.png)


Discussion:
+1 for Row.

Resolving patch:
diff --git a/Orange/data/table.py b/Orange/data/table.py
--- a/Orange/data/table.py
+++ b/Orange/data/table.py
@@ -642,7 +642,6 @@ def _set_row(self, example, row):
                     self._Y[row] = example._y
                 self.metas[row] = example._metas
                 return
-            c = self.domain.get_conversion(example.domain)
 
             self.X[row], self._Y[row], self.metas[row] = \
                 self.domain.convert(example)
@@ -936,7 +935,7 @@ def concatenate(tables, axis=1):
         """Return concatenation of `tables` by `axis`."""
         if not tables:
             raise ValueError('need at least one table to concatenate')
-        if 1 == len(tables):
+        if len(tables) == 1:
             return tables[0].copy()
         CONCAT_ROWS, CONCAT_COLS = 0, 1
         if axis == CONCAT_ROWS:
@@ -1543,8 +1542,8 @@ def _compute_contingency(self, col_vars=None, row_var=None):
         return contingencies, unknown_rows
 
     @classmethod
-    def transpose(cls, table, feature_names_column="",
-                  meta_attr_name="Feature name"):
+    def transpose(cls, table, feature_names_column="", meta_attr_name="Feature name",
+                  feature_name="Feature"):
         """
         Transpose the table.
 
@@ -1566,10 +1565,10 @@ def transpose(cls, table, feature_names_column="",
         self.X = table.X.T
         attributes = [ContinuousVariable(str(row[feature_names_column]))
                       for row in table] if feature_names_column else \
-            [ContinuousVariable("Feature " + str(i + 1).zfill(
+            [ContinuousVariable(feature_name + " " + str(i + 1).zfill(
                 int(np.ceil(np.log10(n_cols))))) for i in range(n_cols)]
         if old_domain and feature_names_column:
-            for i in range(len(attributes)):
+            for i, _ in enumerate(attributes):
                 if attributes[i].name in old_domain:
                     var = old_domain[attributes[i].name]
                     attr = ContinuousVariable(var.name) if var.is_continuous \
diff --git a/Orange/widgets/data/owtranspose.py b/Orange/widgets/data/owtranspose.py
--- a/Orange/widgets/data/owtranspose.py
+++ b/Orange/widgets/data/owtranspose.py
@@ -19,11 +19,16 @@ class Inputs:
     class Outputs:
         data = Output("Data", Table, dynamic=False)
 
+    GENERIC, FROM_META_ATTR = range(2)
+
     resizing_enabled = False
     want_main_area = False
 
+    DEFAULT_PREFIX = "Feature"
+
     settingsHandler = DomainContextHandler()
-    feature_type = ContextSetting(0)
+    feature_type = ContextSetting(GENERIC)
+    feature_name = ContextSetting("")
     feature_names_column = ContextSetting(None)
     auto_apply = Setting(True)
 
@@ -34,27 +39,40 @@ def __init__(self):
         super().__init__()
         self.data = None
 
-        # GUI
-        box = gui.vBox(self.controlArea, "Feature names")
-        self.feature_radio = gui.radioButtonsInBox(
-            box, self, "feature_type", callback=lambda: self.apply(),
-            btnLabels=["Generic", "From meta attribute:"])
+        box = gui.radioButtons(
+            self.controlArea, self, "feature_type", box="Feature names",
+            callback=lambda: self.apply())
+
+        button = gui.appendRadioButton(box, "Generic")
+        edit = gui.lineEdit(
+            gui.indentedBox(box, gui.checkButtonOffsetHint(button)), self,
+            "feature_name",
+            placeholderText="Type a prefix ...", toolTip="Custom feature name")
+        edit.editingFinished.connect(self._apply_editing)
 
+        self.meta_button = gui.appendRadioButton(box, "From meta attribute:")
         self.feature_model = DomainModel(
             order=DomainModel.METAS, valid_types=StringVariable,
             alphabetical=True)
         self.feature_combo = gui.comboBox(
-            gui.indentedBox(
-                box, gui.checkButtonOffsetHint(self.feature_radio.buttons[0])),
-            self, "feature_names_column", callback=self._feature_combo_changed,
+            gui.indentedBox(box, gui.checkButtonOffsetHint(button)), self,
+            "feature_names_column", callback=self._feature_combo_changed,
             model=self.feature_model)
 
         self.apply_button = gui.auto_commit(
             self.controlArea, self, "auto_apply", "&Apply",
             box=False, commit=self.apply)
+        self.apply_button.button.setAutoDefault(False)
+
+        self.set_controls()
+
+    def _apply_editing(self):
+        self.feature_type = self.GENERIC
+        self.feature_name = self.feature_name.strip()
+        self.apply()
 
     def _feature_combo_changed(self):
-        self.feature_type = 1
+        self.feature_type = self.FROM_META_ATTR
         self.apply()
 
     @Inputs.data
@@ -64,21 +82,19 @@ def set_data(self, data):
         if self.feature_model:
             self.closeContext()
         self.data = data
-        self.update_controls()
-        if self.data is not None and self.feature_model:
+        self.set_controls()
+        if self.feature_model:
             self.openContext(data)
         self.apply()
 
-    def update_controls(self):
-        self.feature_model.set_domain(None)
-        if self.data:
-            self.feature_model.set_domain(self.data.domain)
-            if self.feature_model:
-                self.feature_names_column = self.feature_model[0]
-        enabled = bool(self.feature_model)
-        self.feature_radio.buttons[1].setEnabled(enabled)
-        self.feature_combo.setEnabled(enabled)
-        self.feature_type = int(enabled)
+    def set_controls(self):
+        self.feature_model.set_domain(self.data and self.data.domain)
+        self.meta_button.setEnabled(bool(self.feature_model))
+        if self.feature_model:
+            self.feature_names_column = self.feature_model[0]
+            self.feature_type = self.FROM_META_ATTR
+        else:
+            self.feature_names_column = None
 
     def apply(self):
         self.clear_messages()
@@ -86,20 +102,26 @@ def apply(self):
         if self.data:
             try:
                 transposed = Table.transpose(
-                    self.data, self.feature_type and self.feature_names_column)
+                    self.data,
+                    self.feature_type == self.FROM_META_ATTR and self.feature_names_column,
+                    feature_name=self.feature_name or self.DEFAULT_PREFIX)
             except ValueError as e:
                 self.Error.value_error(e)
         self.Outputs.data.send(transposed)
 
     def send_report(self):
-        text = "from meta attribute: {}".format(self.feature_names_column) \
-            if self.feature_type else "generic"
-        self.report_items("", [("Feature names", text)])
+        if self.feature_type == self.GENERIC:
+            names = self.feature_name or self.DEFAULT_PREFIX
+        else:
+            names = "from meta attribute"
+            if self.feature_names_column:
+                names += "  '{}'".format(self.feature_names_column.name)
+        self.report_items("", [("Feature names", names)])
         if self.data:
             self.report_data("Data", self.data)
 
 
-if __name__ == "__main__":
+if __name__ == "__main__":  # pragma: no cover
     from AnyQt.QtWidgets import QApplication
 
     app = QApplication([])


Test patch:
diff --git a/Orange/widgets/data/tests/test_owtranspose.py b/Orange/widgets/data/tests/test_owtranspose.py
--- a/Orange/widgets/data/tests/test_owtranspose.py
+++ b/Orange/widgets/data/tests/test_owtranspose.py
@@ -1,9 +1,11 @@
 # Test methods with long descriptive names can omit docstrings
 # pylint: disable=missing-docstring
 
+import unittest
+
 import numpy as np
 
-from Orange.data import Table
+from Orange.data import Table, Domain
 from Orange.widgets.data.owtranspose import OWTranspose
 from Orange.widgets.tests.base import WidgetTest
 
@@ -13,27 +15,131 @@ def setUp(self):
         self.widget = self.create_widget(OWTranspose)
         self.zoo = Table("zoo")
 
-    def test_data(self):
-        """Check widget's data and the output with data on the input"""
-        self.assertEqual(self.widget.data, None)
-        self.send_signal(self.widget.Inputs.data, self.zoo)
-        self.assertEqual(self.widget.data, self.zoo)
-        output = self.get_output(self.widget.Outputs.data)
+    def test_transpose(self):
+        widget = self.widget
+        self.assertIsNone(self.get_output(widget.Outputs.data))
+        self.assertEqual(widget.data, None)
+        self.send_signal(widget.Inputs.data, self.zoo)
+        output = self.get_output(widget.Outputs.data)
         transpose = Table.transpose(self.zoo)
         np.testing.assert_array_equal(output.X, transpose.X)
         np.testing.assert_array_equal(output.Y, transpose.Y)
         np.testing.assert_array_equal(output.metas, transpose.metas)
-        self.send_signal(self.widget.Inputs.data, None)
-        self.assertEqual(self.widget.data, None)
-        self.assertIsNone(self.get_output(self.widget.Outputs.data))
+        self.send_signal(widget.Inputs.data, None)
+        self.assertIsNone(self.get_output(widget.Outputs.data))
+
+    def test_feature_type(self):
+        widget = self.widget
+        data = Table("conferences.tab")
+        metas = data.domain.metas
+        domain = data.domain
+        # Put one non-string column to metas, so widget must skip it
+        domain2 = Domain(domain.attributes[:-1],
+                         domain.class_vars,
+                         (domain.attributes[0], ) + domain.metas)
+        data2 = Table(domain2, data)
+
+        widget.feature_type = widget.GENERIC
+        self.send_signal(widget.Inputs.data, data2)
+
+        # By default, the widget switches from GENERIC to the first string meta
+        self.assertEqual(widget.feature_type, widget.FROM_META_ATTR)
+        self.assertIs(widget.feature_names_column, metas[0])
+        output = self.get_output(widget.Outputs.data)
+        self.assertListEqual(
+            [a.name for a in output.domain.attributes],
+            [metas[0].to_val(m) for m in data.metas[:, 0]])
 
-    def test_parameters(self):
-        """Check widget's output for all possible values of parameters"""
-        self.send_signal(self.widget.Inputs.data, self.zoo)
+        # Test that the widget takes the correct column
+        widget.feature_names_column = metas[1]
+        widget.apply()
+        output = self.get_output(widget.Outputs.data)
         self.assertListEqual(
-            [a.name for a in self.get_output(self.widget.Outputs.data).domain.attributes],
-            [self.zoo.domain.metas[0].to_val(m) for m in self.zoo.metas[:, 0]])
-        self.widget.feature_radio.buttons[0].click()
-        self.widget.apply()
-        self.assertTrue(all(["Feature" in x.name for x
-                             in self.get_output(self.widget.Outputs.data).domain.attributes]))
+            [a.name for a in output.domain.attributes],
+            [metas[1].to_val(m) for m in data.metas[:, 1]])
+
+        # Switch to generic
+        self.assertEqual(widget.DEFAULT_PREFIX, "Feature")
+        widget.feature_type = widget.GENERIC
+        widget.apply()
+        output = self.get_output(widget.Outputs.data)
+        self.assertTrue(
+            all(x.name.startswith(widget.DEFAULT_PREFIX)
+                for x in output.domain.attributes))
+
+        # Check that the widget uses the supplied name
+        widget.feature_name = "Foo"
+        widget.apply()
+        output = self.get_output(widget.Outputs.data)
+        self.assertTrue(
+            all(x.name.startswith("Foo ") for x in output.domain.attributes))
+
+        # Check that the widget uses default when name is not given
+        widget.feature_name = ""
+        widget.apply()
+        output = self.get_output(widget.Outputs.data)
+        self.assertTrue(
+            all(x.name.startswith(widget.DEFAULT_PREFIX)
+                for x in output.domain.attributes))
+
+    def test_send_report(self):
+        widget = self.widget
+        widget.feature_type = widget.FROM_META_ATTR
+        widget.report_button.click()
+        widget.feature_type = widget.GENERIC
+        widget.report_button.click()
+
+        self.send_signal(widget.Inputs.data, self.zoo)
+        widget.feature_type = widget.FROM_META_ATTR
+        widget.report_button.click()
+        widget.feature_type = widget.GENERIC
+        widget.report_button.click()
+
+    def test_gui_behaviour(self):
+        widget = self.widget
+        widget.apply = unittest.mock.Mock()
+
+        # widget.apply must be called
+        widget.auto_apply = False
+
+        # No data: type is generic, meta radio disabled
+        self.assertEqual(widget.feature_type, widget.GENERIC)
+        self.assertFalse(widget.meta_button.isEnabled())
+        self.assertFalse(widget.apply.called)
+
+        # Data with metas: default type is meta, radio enabled
+        self.send_signal(widget.Inputs.data, self.zoo)
+        self.assertTrue(widget.meta_button.isEnabled())
+        self.assertEqual(widget.feature_type, widget.FROM_META_ATTR)
+        self.assertIs(widget.feature_names_column, widget.feature_model[0])
+        self.assertTrue(widget.apply.called)
+
+        # Editing the line edit changes the radio button to generic
+        widget.apply.reset_mock()
+        widget.controls.feature_name.editingFinished.emit()
+        self.assertEqual(widget.feature_type, widget.GENERIC)
+        self.assertTrue(widget.apply.called)
+
+        # Changing combo changes the radio button to meta
+        widget.apply.reset_mock()
+        widget.feature_combo.activated.emit(0)
+        self.assertEqual(widget.feature_type, widget.FROM_META_ATTR)
+        self.assertTrue(widget.apply.called)
+
+    def test_all_whitespace(self):
+        widget = self.widget
+        widget.feature_name = "  "
+        widget.controls.feature_name.editingFinished.emit()
+        self.assertEqual(widget.feature_name, "")
+
+    def test_error(self):
+        widget = self.widget
+        with unittest.mock.patch("Orange.data.Table.transpose",
+                                 side_effect=ValueError("foo")):
+            self.send_signal(widget.Inputs.data, self.zoo)
+            self.assertTrue(widget.Error.value_error.is_shown())
+        self.send_signal(widget.Inputs.data, self.zoo)
+        self.assertFalse(widget.Error.value_error.is_shown())
+
+if __name__ == "__main__":
+    unittest.main()