Allow users to specify label->color mapping (#3879)

Users can define `label_colors` in a dashboard's JSON metadata that
enforces a label to color mapping.

This also makes the function that maps labels to colors case insensitive.

(cherry picked from commit a82bb588f4647c36161d8d9a5dcfc49128288605)
diff --git a/docs/faq.rst b/docs/faq.rst
index d825ef5..0ca341e 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -221,3 +221,19 @@
 new columns in by using the "Refresh Metadata" action in the
 ``Source -> Tables`` page. Simply check the box next to the tables
 you want the schema refreshed, and click ``Actions -> Refresh Metadata``.
+
+Is there a way to force the use specific colors?
+------------------------------------------------
+
+It is possible on a per-dashboard basis by providing a mapping of
+labels to colors in the ``JSON Metadata`` attribute using the
+``label_colors`` key.
+
+..code::
+
+	{
+	  "label_colors": {
+		"Girls": "#FF69B4",
+		"Boys": "#ADD8E6"
+	  }
+	}
diff --git a/superset/assets/javascripts/dashboard/reducers.js b/superset/assets/javascripts/dashboard/reducers.js
index 8d7b7f4..57475a5 100644
--- a/superset/assets/javascripts/dashboard/reducers.js
+++ b/superset/assets/javascripts/dashboard/reducers.js
@@ -6,6 +6,7 @@
 import { getParam } from '../modules/utils';
 import { alterInArr, removeFromArr } from '../reduxUtils';
 import { applyDefaultFormData } from '../explore/stores/store';
+import { getColorFromScheme } from '../modules/colors';
 
 export function getInitialState(bootstrapData) {
   const { user_id, datasources, common } = bootstrapData;
@@ -25,6 +26,15 @@
     //
   }
 
+  // Priming the color palette with user's label-color mapping provided in
+  // the dashboard's JSON metadata
+  if (dashboard.metadata && dashboard.metadata.label_colors) {
+    const colorMap = dashboard.metadata.label_colors;
+    for (const label in colorMap) {
+      getColorFromScheme(label, null, colorMap[label]);
+    }
+  }
+
   dashboard.posDict = {};
   dashboard.layout = [];
   if (dashboard.position_json) {
diff --git a/superset/assets/javascripts/modules/colors.js b/superset/assets/javascripts/modules/colors.js
index 0c3d06a..03c3bb2 100644
--- a/superset/assets/javascripts/modules/colors.js
+++ b/superset/assets/javascripts/modules/colors.js
@@ -103,17 +103,36 @@
   ],
 };
 
+/**
+ * Get a color from a scheme specific palette (scheme)
+ * The function cycles through the palette while memoizing labels
+ * association to colors. If the function is called twice with the
+ * same string, it will return the same color.
+ *
+ * @param {string} s - The label for which we want to get a color
+ * @param {string} scheme - The palette name, or "scheme"
+ * @param {string} forcedColor - A color that the caller wants to
+    forcibly associate to a label.
+ */
 export const getColorFromScheme = (function () {
-  // Color factory
   const seen = {};
-  return function (s, scheme) {
+  const forcedColors = {};
+  return function (s, scheme, forcedColor) {
     if (!s) {
       return;
     }
     const selectedScheme = scheme ? ALL_COLOR_SCHEMES[scheme] : ALL_COLOR_SCHEMES.bnbColors;
-    let stringifyS = String(s);
+    let stringifyS = String(s).toLowerCase();
     // next line is for superset series that should have the same color
     stringifyS = stringifyS.replace('---', '');
+
+    if (forcedColor && !forcedColors[stringifyS]) {
+      forcedColors[stringifyS] = forcedColor;
+    }
+    if (forcedColors[stringifyS]) {
+      return forcedColors[stringifyS];
+    }
+
     if (seen[selectedScheme] === undefined) {
       seen[selectedScheme] = {};
     }
diff --git a/superset/assets/spec/javascripts/modules/colors_spec.jsx b/superset/assets/spec/javascripts/modules/colors_spec.jsx
index 31ccea8..2a24633 100644
--- a/superset/assets/spec/javascripts/modules/colors_spec.jsx
+++ b/superset/assets/spec/javascripts/modules/colors_spec.jsx
@@ -8,7 +8,7 @@
     const color1 = getColorFromScheme('CA');
     expect(color1).to.equal(ALL_COLOR_SCHEMES.bnbColors[0]);
   });
-  it('series with same scheme should have the same color', () => {
+  it('getColorFromScheme series with same scheme should have the same color', () => {
     const color1 = getColorFromScheme('CA', 'bnbColors');
     const color2 = getColorFromScheme('CA', 'googleCategory20c');
     const color3 = getColorFromScheme('CA', 'bnbColors');
@@ -19,7 +19,22 @@
     expect(color1).to.equal(color3);
     expect(color4).to.equal(ALL_COLOR_SCHEMES.bnbColors[1]);
   });
+  it('getColorFromScheme forcing colors persists through calls', () => {
+    expect(getColorFromScheme('boys', 'bnbColors', 'blue')).to.equal('blue');
+    expect(getColorFromScheme('boys', 'bnbColors')).to.equal('blue');
+    expect(getColorFromScheme('boys', 'googleCategory20c')).to.equal('blue');
 
+    expect(getColorFromScheme('girls', 'bnbColors', 'pink')).to.equal('pink');
+    expect(getColorFromScheme('girls', 'bnbColors')).to.equal('pink');
+    expect(getColorFromScheme('girls', 'googleCategory20c')).to.equal('pink');
+  });
+  it('getColorFromScheme is not case sensitive', () => {
+    const c1 = getColorFromScheme('girls', 'bnbColors');
+    const c2 = getColorFromScheme('Girls', 'bnbColors');
+    const c3 = getColorFromScheme('GIRLS', 'bnbColors');
+    expect(c1).to.equal(c2);
+    expect(c3).to.equal(c2);
+  });
   it('hexToRGB converts properly', () => {
     expect(hexToRGB('#FFFFFF')).to.have.same.members([255, 255, 255, 255]);
     expect(hexToRGB('#000000')).to.have.same.members([0, 0, 0, 255]);