]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-6360 add detailed "Code Coverage" panel for the "Overview" main page
authorStas Vilchik <vilchiks@gmail.com>
Fri, 23 Oct 2015 12:07:09 +0000 (14:07 +0200)
committerStas Vilchik <vilchiks@gmail.com>
Fri, 23 Oct 2015 13:03:50 +0000 (15:03 +0200)
12 files changed:
server/sonar-web/src/main/js/api/measures.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/coverage/bubble-chart.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/coverage/coverage-details.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/coverage/main.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/coverage/tests-details.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/coverage/timeline.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/coverage/treemap.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/formatting.js
server/sonar-web/src/main/js/apps/overview/general/nutshell-coverage.js
server/sonar-web/src/main/js/apps/overview/main.js
server/sonar-web/src/main/js/libs/application.js
server/sonar-web/src/main/less/components/columns.less

diff --git a/server/sonar-web/src/main/js/api/measures.js b/server/sonar-web/src/main/js/api/measures.js
new file mode 100644 (file)
index 0000000..b79b56a
--- /dev/null
@@ -0,0 +1,14 @@
+import { getJSON } from '../helpers/request.js';
+
+export function getMeasures (componentKey, metrics) {
+  let url = baseUrl + '/api/resources/index';
+  let data = { resource: componentKey, metrics: metrics.join(',') };
+  return getJSON(url, data).then(r => {
+    let msr = r[0].msr || [];
+    let measures = {};
+    msr.forEach(measure => {
+      measures[measure.key] = measure.val;
+    });
+    return measures;
+  });
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/bubble-chart.js b/server/sonar-web/src/main/js/apps/overview/coverage/bubble-chart.js
new file mode 100644 (file)
index 0000000..fde275f
--- /dev/null
@@ -0,0 +1,105 @@
+import _ from 'underscore';
+import React from 'react';
+import { BubbleChart } from '../../../components/charts/bubble-chart';
+import { getProjectUrl } from '../../../helpers/Url';
+import { getFiles } from '../../../api/components';
+import { formatMeasure } from '../formatting';
+
+
+const X_METRIC = 'complexity';
+const Y_METRIC = 'coverage';
+const SIZE_METRIC = 'sqale_index';
+const COMPONENTS_METRICS = [X_METRIC, Y_METRIC, SIZE_METRIC];
+const HEIGHT = 360;
+
+
+function formatInt (d) {
+  return window.formatMeasure(d, 'SHORT_INT');
+}
+
+function formatPercent (d) {
+  return window.formatMeasure(d, 'PERCENT');
+}
+
+function getMeasure (component, metric) {
+  return component.measures[metric] || 0;
+}
+
+
+export class CoverageBubbleChart extends React.Component {
+  constructor () {
+    super();
+    this.state = { loading: true, files: [] };
+  }
+
+  componentDidMount () {
+    this.requestFiles();
+  }
+
+  requestFiles () {
+    return getFiles(this.props.component.key, COMPONENTS_METRICS).then(r => {
+      let files = r.map(file => {
+        let measures = {};
+        (file.msr || []).forEach(measure => {
+          measures[measure.key] = measure.val;
+        });
+        return _.extend(file, { measures });
+      });
+      this.setState({ loading: false, files });
+    });
+  }
+
+  renderLoading () {
+    return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}>
+      <i className="spinner"/>
+    </div>;
+  }
+
+  renderBubbleChart () {
+    if (this.state.loading) {
+      return this.renderLoading();
+    }
+
+    let items = this.state.files.map(component => {
+      return {
+        x: getMeasure(component, X_METRIC),
+        y: getMeasure(component, Y_METRIC),
+        size: getMeasure(component, SIZE_METRIC),
+        link: getProjectUrl(component.key)
+      };
+    });
+    let xGrid = this.state.files.map(component => component.measures[X_METRIC]);
+    let tooltips = this.state.files.map(component => {
+      let inner = [
+        component.name,
+        `Complexity: ${formatMeasure(getMeasure(component, X_METRIC), X_METRIC)}`,
+        `Coverage: ${formatMeasure(getMeasure(component, Y_METRIC), Y_METRIC)}`,
+        `Technical Debt: ${formatMeasure(getMeasure(component, SIZE_METRIC), SIZE_METRIC)}`
+      ].join('<br>');
+      return `<div class="text-left">${inner}</div>`;
+    });
+    return <BubbleChart items={items}
+                        xGrid={xGrid}
+                        tooltips={tooltips}
+                        height={HEIGHT}
+                        padding={[25, 30, 50, 60]}
+                        formatXTick={formatInt}
+                        formatYTick={formatPercent}/>;
+  }
+
+  render () {
+    return <div className="overview-bubble-chart overview-domain-dark">
+      <div className="overview-domain-header">
+        <h2 className="overview-title">Project Files</h2>
+        <ul className="list-inline small">
+          <li>X: Complexity</li>
+          <li>Y: Coverage</li>
+          <li>Size: Technical Debt</li>
+        </ul>
+      </div>
+      <div>
+        {this.renderBubbleChart()}
+      </div>
+    </div>;
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/coverage-details.js b/server/sonar-web/src/main/js/apps/overview/coverage/coverage-details.js
new file mode 100644 (file)
index 0000000..403d8b6
--- /dev/null
@@ -0,0 +1,113 @@
+import React from 'react';
+import { getMeasures } from '../../../api/measures';
+
+
+const METRICS = [
+  'coverage',
+  'line_coverage',
+  'branch_coverage',
+  'it_coverage',
+  'it_line_coverage',
+  'it_branch_coverage',
+  'overall_coverage',
+  'overall_line_coverage',
+  'overall_branch_coverage'
+];
+
+
+function formatCoverage (value) {
+  return value != null ? window.formatMeasure(value, 'PERCENT') : '—';
+}
+
+
+export class CoverageDetails extends React.Component {
+  constructor () {
+    super();
+    this.state = { measures: {} };
+  }
+
+  componentDidMount () {
+    this.requestDetails();
+  }
+
+  requestDetails () {
+    return getMeasures(this.props.component.key, METRICS).then(measures => {
+      this.setState({ measures });
+    });
+  }
+
+  renderCoverage (coverage, lineCoverage, branchCoverage) {
+    return <table className="data zebra">
+      <tbody>
+      <tr>
+        <td>Coverage</td>
+        <td className="thin nowrap text-right">
+          {formatCoverage(coverage)}
+        </td>
+      </tr>
+      <tr>
+        <td>Line Coverage</td>
+        <td className="thin nowrap text-right">
+          {formatCoverage(lineCoverage)}
+        </td>
+      </tr>
+      <tr>
+        <td>Branch Coverage</td>
+        <td className="thin nowrap text-right">
+          {formatCoverage(branchCoverage)}
+        </td>
+      </tr>
+      </tbody>
+    </table>;
+  }
+
+  renderUTCoverage () {
+    if (this.state.measures['coverage'] == null) {
+      return null;
+    }
+    return <div className="big-spacer-top">
+      <h4 className="spacer-bottom">Unit Tests</h4>
+      {this.renderCoverage(
+          this.state.measures['coverage'],
+          this.state.measures['line_coverage'],
+          this.state.measures['branch_coverage'])}
+    </div>;
+  }
+
+  renderITCoverage () {
+    if (this.state.measures['it_coverage'] == null) {
+      return null;
+    }
+    return <div className="big-spacer-top">
+      <h4 className="spacer-bottom">Integration Tests</h4>
+      {this.renderCoverage(
+          this.state.measures['it_coverage'],
+          this.state.measures['it_line_coverage'],
+          this.state.measures['it_branch_coverage'])}
+    </div>;
+  }
+
+  renderOverallCoverage () {
+    if (this.state.measures['coverage'] == null ||
+        this.state.measures['it_coverage'] == null ||
+        this.state.measures['overall_coverage'] == null) {
+      return null;
+    }
+    return <div className="big-spacer-top">
+      <h4 className="spacer-bottom">Overall</h4>
+      {this.renderCoverage(
+          this.state.measures['overall_coverage'],
+          this.state.measures['overall_line_coverage'],
+          this.state.measures['overall_branch_coverage'])}
+    </div>;
+  }
+
+  render () {
+    return <div className="overview-domain-section">
+      <h2 className="overview-title">Coverage Details</h2>
+      {this.renderUTCoverage()}
+      {this.renderITCoverage()}
+      {this.renderOverallCoverage()}
+    </div>;
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/main.js b/server/sonar-web/src/main/js/apps/overview/coverage/main.js
new file mode 100644 (file)
index 0000000..88533e1
--- /dev/null
@@ -0,0 +1,31 @@
+import React from 'react';
+
+import { CoverageDetails } from './coverage-details';
+import { TestsDetails } from './tests-details';
+import { CoverageBubbleChart } from './bubble-chart';
+import { CoverageTimeline } from './timeline';
+import { CoverageTreemap } from './treemap';
+
+import { getSeverities, getTags, getAssignees } from '../../../api/issues';
+
+
+export default class CoverageDomain extends React.Component {
+  render () {
+    return <div className="overview-domain">
+
+      <CoverageTimeline {...this.props}/>
+
+      <div className="flex-columns">
+        <div className="flex-column flex-column-half">
+          <CoverageDetails {...this.props}/>
+        </div>
+        <div className="flex-column flex-column-half">
+          <TestsDetails {...this.props}/>
+        </div>
+      </div>
+
+      <CoverageBubbleChart {...this.props}/>
+      <CoverageTreemap {...this.props}/>
+    </div>;
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/tests-details.js b/server/sonar-web/src/main/js/apps/overview/coverage/tests-details.js
new file mode 100644 (file)
index 0000000..6503472
--- /dev/null
@@ -0,0 +1,82 @@
+import React from 'react';
+import { getMeasures } from '../../../api/measures';
+import { formatMeasure } from '../formatting';
+
+
+const METRICS = [
+  'tests',
+  'skipped_tests',
+  'test_errors',
+  'test_failures',
+  'test_execution_time',
+  'test_success_density'
+];
+
+
+function format (value, metric) {
+  return value != null ? formatMeasure(value, metric) : '—';
+}
+
+
+export class TestsDetails extends React.Component {
+  constructor () {
+    super();
+    this.state = { measures: {} };
+  }
+
+  componentDidMount () {
+    this.requestDetails();
+  }
+
+  requestDetails () {
+    return getMeasures(this.props.component.key, METRICS).then(measures => {
+      this.setState({ measures });
+    });
+  }
+
+  render () {
+    return <div className="overview-domain-section">
+      <h2 className="overview-title">Tests Details</h2>
+      <table className="data zebra">
+        <tbody>
+        <tr>
+          <td>Tests</td>
+          <td className="thin nowrap text-right">
+            {format(this.state.measures['tests'], 'tests')}
+          </td>
+        </tr>
+        <tr>
+          <td>Skipped Tests</td>
+          <td className="thin nowrap text-right">
+            {format(this.state.measures['skipped_tests'], 'skipped_tests')}
+          </td>
+        </tr>
+        <tr>
+          <td>Test Errors</td>
+          <td className="thin nowrap text-right">
+            {format(this.state.measures['test_errors'], 'test_errors')}
+          </td>
+        </tr>
+        <tr>
+          <td>Test Failures</td>
+          <td className="thin nowrap text-right">
+            {format(this.state.measures['test_failures'], 'test_failures')}
+          </td>
+        </tr>
+        <tr>
+          <td>Tests Execution Time</td>
+          <td className="thin nowrap text-right">
+            {format(this.state.measures['test_execution_time'], 'test_execution_time')}
+          </td>
+        </tr>
+        <tr>
+          <td>Tests Success</td>
+          <td className="thin nowrap text-right">
+            {format(this.state.measures['test_success_density'], 'test_success_density')}
+          </td>
+        </tr>
+        </tbody>
+      </table>
+    </div>;
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/timeline.js b/server/sonar-web/src/main/js/apps/overview/coverage/timeline.js
new file mode 100644 (file)
index 0000000..58e9a9d
--- /dev/null
@@ -0,0 +1,164 @@
+import _ from 'underscore';
+import moment from 'moment';
+import React from 'react';
+
+import { LineChart } from '../../../components/charts/line-chart';
+import { formatMeasure } from '../formatting';
+import { getTimeMachineData } from '../../../api/time-machine';
+import { getEvents } from '../../../api/events';
+
+
+const COVERAGE_METRICS = [
+  'coverage',
+  'line_coverage',
+  'branch_coverage',
+  'lines_to_cover',
+  'conditions_to_cover',
+  'uncovered_lines',
+  'uncovered_conditions',
+
+  'it_coverage',
+  'it_line_coverage',
+  'it_branch_coverage',
+  'it_lines_to_cover',
+  'it_conditions_to_cover',
+  'it_uncovered_lines',
+  'it_uncovered_conditions',
+
+  'overall_coverage',
+  'overall_line_coverage',
+  'overall_branch_coverage',
+  'overall_lines_to_cover',
+  'overall_conditions_to_cover',
+  'overall_uncovered_lines',
+  'overall_uncovered_conditions'
+];
+
+const TESTS_METRICS = [
+  'tests',
+  'skipped_tests',
+  'test_errors',
+  'test_failures',
+  'test_execution_time',
+  'test_success_density'
+];
+
+const HEIGHT = 280;
+
+
+export class CoverageTimeline extends React.Component {
+  constructor () {
+    super();
+    this.state = { loading: true, currentMetric: COVERAGE_METRICS[0] };
+  }
+
+  componentDidMount () {
+    Promise.all([
+      this.requestTimeMachineData(),
+      this.requestEvents()
+    ]).then(() => this.setState({ loading: false }));
+  }
+
+  requestTimeMachineData () {
+    return getTimeMachineData(this.props.component.key, this.state.currentMetric).then(r => {
+      let snapshots = r[0].cells.map(cell => {
+        return { date: moment(cell.d).toDate(), value: cell.v[0] };
+      });
+      this.setState({ snapshots });
+    });
+  }
+
+  requestEvents () {
+    return getEvents(this.props.component.key, 'Version').then(r => {
+      let events = r.map(event => {
+        return { version: event.n, date: moment(event.dt).toDate() };
+      });
+      events = _.sortBy(events, 'date');
+      this.setState({ events });
+    });
+  }
+
+  prepareEvents () {
+    let events = this.state.events;
+    let snapshots = this.state.snapshots;
+    return events
+        .map(event => {
+          let snapshot = snapshots.find(s => s.date.getTime() === event.date.getTime());
+          event.value = snapshot && snapshot.value;
+          return event;
+        })
+        .filter(event => event.value != null);
+  }
+
+  handleMetricChange () {
+    let metric = React.findDOMNode(this.refs.metricSelect).value;
+    this.setState({ currentMetric: metric }, this.requestTimeMachineData);
+  }
+
+  renderLoading () {
+    return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}>
+      <i className="spinner"/>
+    </div>;
+  }
+
+  renderLineChart () {
+    if (this.state.loading) {
+      return this.renderLoading();
+    }
+
+    let events = this.prepareEvents();
+
+    let data = events.map((event, index) => {
+      return { x: index, y: event.value };
+    });
+
+    let xTicks = events.map(event => event.version.substr(0, 6));
+
+    let xValues = events.map(event => formatMeasure(event.value, this.state.currentMetric));
+
+    // TODO use leak period
+    let backdropConstraints = [
+      this.state.events.length - 2,
+      this.state.events.length - 1
+    ];
+
+    return <LineChart data={data}
+                      xTicks={xTicks}
+                      xValues={xValues}
+                      backdropConstraints={backdropConstraints}
+                      height={HEIGHT}
+                      interpolate="linear"
+                      padding={[25, 30, 50, 30]}/>;
+  }
+
+  renderTimelineMetricSelect () {
+    if (this.state.loading) {
+      return null;
+    }
+
+    let issueOptions = COVERAGE_METRICS
+        .map(metric => <option key={metric} value={metric}>{window.t('metric', metric, 'name')}</option>);
+    let debtOptions = TESTS_METRICS
+        .map(metric => <option key={metric} value={metric}>{window.t('metric', metric, 'name')}</option>);
+
+    return <select ref="metricSelect"
+                   className="overview-timeline-select"
+                   onChange={this.handleMetricChange.bind(this)}
+                   value={this.state.currentMetric}>
+      <optgroup label="Coverage">{issueOptions}</optgroup>
+      <optgroup label="Tests">{debtOptions}</optgroup>
+    </select>;
+  }
+
+  render () {
+    return <div className="overview-timeline overview-domain-dark">
+      <div className="overview-domain-header">
+        <h2 className="overview-title">Project History</h2>
+        {this.renderTimelineMetricSelect()}
+      </div>
+      <div>
+        {this.renderLineChart()}
+      </div>
+    </div>;
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/treemap.js b/server/sonar-web/src/main/js/apps/overview/coverage/treemap.js
new file mode 100644 (file)
index 0000000..ee4af18
--- /dev/null
@@ -0,0 +1,89 @@
+import _ from 'underscore';
+import d3 from 'd3';
+import React from 'react';
+
+import { Treemap } from '../../../components/charts/treemap';
+import { formatMeasure } from '../formatting';
+import { getChildren } from '../../../api/components';
+
+const COMPONENTS_METRICS = [
+  'lines',
+  'coverage'
+];
+
+const HEIGHT = 360;
+
+const COLORS_5 = ['#ee0000', '#f77700', '#ffee00', '#80cc00', '#00aa00'];
+
+export class CoverageTreemap extends React.Component {
+  constructor () {
+    super();
+    this.state = { loading: true, components: [] };
+  }
+
+  componentDidMount () {
+    this.requestComponents();
+  }
+
+  requestComponents () {
+    return getChildren(this.props.component.key, COMPONENTS_METRICS).then(r => {
+      let components = r.map(component => {
+        let measures = {};
+        (component.msr || []).forEach(measure => {
+          measures[measure.key] = measure.val;
+        });
+        return _.extend(component, { measures });
+      });
+      this.setState({ loading: false, components });
+    });
+  }
+
+  renderLoading () {
+    return <div className="overview-chart-placeholder" style={{ height: HEIGHT }}>
+      <i className="spinner"/>
+    </div>;
+  }
+
+  renderTreemap () {
+    if (this.state.loading) {
+      return this.renderLoading();
+    }
+
+    let colorScale = d3.scale.linear().domain([0, 25, 50, 75, 100]);
+    colorScale.range(COLORS_5);
+
+    let items = this.state.components.map(component => {
+      let coverage = component.measures['coverage'];
+      console.log(coverage);
+      return {
+        size: component.measures['lines'],
+        color: coverage != null ? colorScale(coverage) : '#777'
+      };
+    });
+    let labels = this.state.components.map(component => component.name);
+    let tooltips = this.state.components.map(component => {
+      let inner = [
+        component.name,
+        `Lines: ${formatMeasure(component.measures['lines'], 'lines')}`,
+        `Coverage: ${formatMeasure(component.measures['coverage'], 'coverage')}`
+      ].join('<br>');
+      return `<div class="text-left">${inner}</div>`;
+    });
+    return <Treemap items={items} labels={labels} tooltips={tooltips} height={HEIGHT}/>;
+  }
+
+  render () {
+    return <div className="overview-domain-section overview-treemap">
+      <div className="overview-domain-header">
+        <h2 className="overview-title">Project Components</h2>
+        <ul className="list-inline small">
+          <li>Size: Lines</li>
+          <li>Color: Coverage</li>
+        </ul>
+      </div>
+      <div>
+        {this.renderTreemap()}
+      </div>
+    </div>;
+  }
+}
index 7a91cbcd5f12b2c1d0575f3c95477bb2a5acd200..5baa7d2f13f9dd479abe7b3a51207bbefa5adbdd 100644 (file)
@@ -12,7 +12,40 @@ const METRIC_TYPES = {
   'sqale_index': 'SHORT_WORK_DUR',
   'sqale_debt_ratio': 'PERCENT',
   'sqale_rating': 'RATING',
-  'lines': 'SHORT_INT'
+  'lines': 'SHORT_INT',
+
+  'coverage': 'PERCENT',
+  'line_coverage': 'PERCENT',
+  'branch_coverage': 'PERCENT',
+  'lines_to_cover': 'SHORT_INT',
+  'conditions_to_cover': 'SHORT_INT',
+  'uncovered_lines': 'SHORT_INT',
+  'uncovered_conditions': 'SHORT_INT',
+
+  'it_coverage': 'PERCENT',
+  'it_line_coverage': 'PERCENT',
+  'it_branch_coverage': 'PERCENT',
+  'it_lines_to_cover': 'SHORT_INT',
+  'it_conditions_to_cover': 'SHORT_INT',
+  'it_uncovered_lines': 'SHORT_INT',
+  'it_uncovered_conditions': 'SHORT_INT',
+
+  'overall_coverage': 'PERCENT',
+  'overall_line_coverage': 'PERCENT',
+  'overall_branch_coverage': 'PERCENT',
+  'overall_lines_to_cover': 'SHORT_INT',
+  'overall_conditions_to_cover': 'SHORT_INT',
+  'overall_uncovered_lines': 'SHORT_INT',
+  'overall_uncovered_conditions': 'SHORT_INT',
+
+  'tests': 'SHORT_INT',
+  'skipped_tests': 'SHORT_INT',
+  'test_errors': 'SHORT_INT',
+  'test_failures': 'SHORT_INT',
+  'test_execution_time': 'MILLISEC',
+  'test_success_density': 'PERCENT',
+
+  'complexity': 'INT'
 };
 
 export function formatMeasure (value, metric) {
index 9de1e53825d62a7f4c353ecbc513803687885ef3..d7cbec6055366584121f392f66569fe7d5ece6d9 100644 (file)
@@ -18,8 +18,10 @@ export default React.createClass({
       return null;
     }
 
+    let active = this.props.section === 'coverage';
+
     return (
-        <Card>
+        <Card linkTo="coverage" active={active} onRoute={this.props.onRoute}>
           <div className="measures">
             <div className="measures-chart">
               <Donut data={donutData} size="47"/>
index 81924a313cc2938cfb432559f50ab307a1eca032..6f9ddcfe7de08483fde420df64432ee27db1c423 100644 (file)
@@ -2,6 +2,7 @@ import React from 'react';
 import offset from 'document-offset';
 import GeneralMain from './general/main';
 import IssuesMain from './issues/main';
+import CoverageMain from './coverage/main';
 import Meta from './meta';
 
 export default class Overview extends React.Component {
@@ -27,6 +28,9 @@ export default class Overview extends React.Component {
       case 'issues':
         child = <IssuesMain {...this.props}/>;
         break;
+      case 'coverage':
+        child = <CoverageMain {...this.props}/>;
+        break;
       default:
         child = null;
     }
index 5338ae64886b4e344d1400bec8300c15f7c5882a..8692a73f9cb4a7a5606a4cd566b986b5c72b1d4f 100644 (file)
@@ -492,6 +492,15 @@ function closeModalWindow () {
     return l10nKey !== result ? result : value;
   };
 
+
+  /**
+   * Format a milliseconds measure
+   * @param {number} value
+   */
+  var millisecondsFormatter = function (value) {
+    return value + ' ms';
+  };
+
   /**
    * Format a measure according to its type
    * @param measure
@@ -517,7 +526,8 @@ function closeModalWindow () {
           'WORK_DUR': durationFormatter,
           'SHORT_WORK_DUR': shortDurationFormatter,
           'RATING': ratingFormatter,
-          'LEVEL': levelFormatter
+          'LEVEL': levelFormatter,
+          'MILLISEC': millisecondsFormatter
         };
     if (measure != null && type != null) {
       formatted = formatters[type] != null ? formatters[type](measure) : measure;
index 1e59a6f13ad9e2034578aec0864783ea63c7acaf..8759b4cac1a4a32f4dc47224a5fffb927dbbf2d9 100644 (file)
@@ -48,3 +48,7 @@
 .flex-column-third {
   width: ~"calc(100% / 3)";
 }
+
+.flex-column-two-thirds {
+  width: ~"calc(100% / 3 * 2)";
+}