transformProps.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. /**
  2. * Licensed to the Apache Software Foundation (ASF) under one
  3. * or more contributor license agreements. See the NOTICE file
  4. * distributed with this work for additional information
  5. * regarding copyright ownership. The ASF licenses this file
  6. * to you under the Apache License, Version 2.0 (the
  7. * "License"); you may not use this file except in compliance
  8. * with the License. You may obtain a copy of the License at
  9. *
  10. * http://www.apache.org/licenses/LICENSE-2.0
  11. *
  12. * Unless required by applicable law or agreed to in writing,
  13. * software distributed under the License is distributed on an
  14. * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  15. * KIND, either express or implied. See the License for the
  16. * specific language governing permissions and limitations
  17. * under the License.
  18. */
  19. import {
  20. ChartProps,
  21. getNumberFormatter,
  22. SqlaFormData,
  23. supersetTheme,
  24. } from '@superset-ui/core';
  25. import type { PieSeriesOption } from 'echarts/charts';
  26. import type {
  27. LabelFormatterCallback,
  28. CallbackDataParams,
  29. } from 'echarts/types/src/util/types';
  30. import transformProps, { parseParams } from '../../src/Pie/transformProps';
  31. import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
  32. describe('Pie transformProps', () => {
  33. const formData: SqlaFormData = {
  34. colorScheme: 'bnbColors',
  35. datasource: '3__table',
  36. granularity_sqla: 'ds',
  37. metric: 'sum__num',
  38. groupby: ['foo', 'bar'],
  39. viz_type: 'my_viz',
  40. };
  41. const chartProps = new ChartProps({
  42. formData,
  43. width: 800,
  44. height: 600,
  45. queriesData: [
  46. {
  47. data: [
  48. {
  49. foo: 'Sylvester',
  50. bar: 1,
  51. sum__num: 10,
  52. sum__num__contribution: 0.8,
  53. },
  54. { foo: 'Arnold', bar: 2, sum__num: 2.5, sum__num__contribution: 0.2 },
  55. ],
  56. },
  57. ],
  58. theme: supersetTheme,
  59. });
  60. it('should transform chart props for viz', () => {
  61. expect(transformProps(chartProps as EchartsPieChartProps)).toEqual(
  62. expect.objectContaining({
  63. width: 800,
  64. height: 600,
  65. echartOptions: expect.objectContaining({
  66. series: [
  67. expect.objectContaining({
  68. avoidLabelOverlap: true,
  69. data: expect.arrayContaining([
  70. expect.objectContaining({
  71. name: 'Arnold, 2',
  72. value: 2.5,
  73. }),
  74. expect.objectContaining({
  75. name: 'Sylvester, 1',
  76. value: 10,
  77. }),
  78. ]),
  79. }),
  80. ],
  81. }),
  82. }),
  83. );
  84. });
  85. });
  86. describe('formatPieLabel', () => {
  87. it('should generate a valid pie chart label', () => {
  88. const numberFormatter = getNumberFormatter();
  89. const params = { name: 'My Label', value: 1234, percent: 12.34 };
  90. expect(
  91. parseParams({
  92. params,
  93. numberFormatter,
  94. }),
  95. ).toEqual(['My Label', '1.23k', '12.34%']);
  96. expect(
  97. parseParams({
  98. params: { ...params, name: '<NULL>' },
  99. numberFormatter,
  100. }),
  101. ).toEqual(['<NULL>', '1.23k', '12.34%']);
  102. expect(
  103. parseParams({
  104. params: { ...params, name: '<NULL>' },
  105. numberFormatter,
  106. sanitizeName: true,
  107. }),
  108. ).toEqual(['&lt;NULL&gt;', '1.23k', '12.34%']);
  109. });
  110. });
  111. describe('Pie label string template', () => {
  112. const params: CallbackDataParams = {
  113. componentType: '',
  114. componentSubType: '',
  115. componentIndex: 0,
  116. seriesType: 'pie',
  117. seriesIndex: 0,
  118. seriesId: 'seriesId',
  119. seriesName: 'test',
  120. name: 'Tablet',
  121. dataIndex: 0,
  122. data: {},
  123. value: 123456,
  124. percent: 55.5,
  125. $vars: [],
  126. };
  127. const getChartProps = (form: Partial<SqlaFormData>): EchartsPieChartProps => {
  128. const formData: SqlaFormData = {
  129. colorScheme: 'bnbColors',
  130. datasource: '3__table',
  131. granularity_sqla: 'ds',
  132. metric: 'sum__num',
  133. groupby: ['foo', 'bar'],
  134. viz_type: 'my_viz',
  135. ...form,
  136. };
  137. return new ChartProps({
  138. formData,
  139. width: 800,
  140. height: 600,
  141. queriesData: [
  142. {
  143. data: [
  144. { foo: 'Sylvester', bar: 1, sum__num: 10 },
  145. { foo: 'Arnold', bar: 2, sum__num: 2.5 },
  146. ],
  147. },
  148. ],
  149. theme: supersetTheme,
  150. }) as EchartsPieChartProps;
  151. };
  152. const format = (form: Partial<SqlaFormData>) => {
  153. const props = transformProps(getChartProps(form));
  154. expect(props).toEqual(
  155. expect.objectContaining({
  156. width: 800,
  157. height: 600,
  158. echartOptions: expect.objectContaining({
  159. series: [
  160. expect.objectContaining({
  161. avoidLabelOverlap: true,
  162. data: expect.arrayContaining([
  163. expect.objectContaining({
  164. name: 'Arnold, 2',
  165. value: 2.5,
  166. }),
  167. expect.objectContaining({
  168. name: 'Sylvester, 1',
  169. value: 10,
  170. }),
  171. ]),
  172. label: expect.objectContaining({
  173. formatter: expect.any(Function),
  174. }),
  175. }),
  176. ],
  177. }),
  178. }),
  179. );
  180. const formatter = (props.echartOptions.series as PieSeriesOption[])[0]!
  181. .label?.formatter;
  182. return (formatter as LabelFormatterCallback)(params);
  183. };
  184. it('should generate a valid pie chart label with template', () => {
  185. expect(
  186. format({
  187. label_type: 'template',
  188. label_template: '{name}:{value}\n{percent}',
  189. }),
  190. ).toEqual('Tablet:123k\n55.50%');
  191. });
  192. it('should be formatted using the number formatter', () => {
  193. expect(
  194. format({
  195. label_type: 'template',
  196. label_template: '{name}:{value}\n{percent}',
  197. number_format: ',d',
  198. }),
  199. ).toEqual('Tablet:123,456\n55.50%');
  200. });
  201. it('should be compatible with ECharts raw variable syntax', () => {
  202. expect(
  203. format({
  204. label_type: 'template',
  205. label_template: '{b}:{c}\n{d}',
  206. number_format: ',d',
  207. }),
  208. ).toEqual('Tablet:123456\n55.5');
  209. });
  210. });
  211. describe('Total value positioning with legends', () => {
  212. const getChartPropsWithLegend = (
  213. showTotal = true,
  214. showLegend = true,
  215. legendOrientation = 'right',
  216. donut = true,
  217. ): EchartsPieChartProps => {
  218. const formData: SqlaFormData = {
  219. colorScheme: 'bnbColors',
  220. datasource: '3__table',
  221. granularity_sqla: 'ds',
  222. metric: 'sum__num',
  223. groupby: ['category'],
  224. viz_type: 'pie',
  225. show_total: showTotal,
  226. show_legend: showLegend,
  227. legend_orientation: legendOrientation,
  228. donut,
  229. };
  230. return new ChartProps({
  231. formData,
  232. width: 800,
  233. height: 600,
  234. queriesData: [
  235. {
  236. data: [
  237. { category: 'A', sum__num: 10, sum__num__contribution: 0.4 },
  238. { category: 'B', sum__num: 15, sum__num__contribution: 0.6 },
  239. ],
  240. },
  241. ],
  242. theme: supersetTheme,
  243. }) as EchartsPieChartProps;
  244. };
  245. it('should center total text when legend is on the right', () => {
  246. const props = getChartPropsWithLegend(true, true, 'right', true);
  247. const transformed = transformProps(props);
  248. expect(transformed.echartOptions.graphic).toEqual(
  249. expect.objectContaining({
  250. type: 'text',
  251. left: expect.stringMatching(/^\d+(\.\d+)?%$/),
  252. top: 'middle',
  253. style: expect.objectContaining({
  254. text: expect.stringContaining('Total:'),
  255. }),
  256. }),
  257. );
  258. // The left position should be less than 50% (shifted left)
  259. const leftValue = parseFloat(
  260. (transformed.echartOptions.graphic as any).left.replace('%', ''),
  261. );
  262. expect(leftValue).toBeLessThan(50);
  263. expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
  264. });
  265. it('should center total text when legend is on the left', () => {
  266. const props = getChartPropsWithLegend(true, true, 'left', true);
  267. const transformed = transformProps(props);
  268. expect(transformed.echartOptions.graphic).toEqual(
  269. expect.objectContaining({
  270. type: 'text',
  271. left: expect.stringMatching(/^\d+(\.\d+)?%$/),
  272. top: 'middle',
  273. }),
  274. );
  275. // The left position should be greater than 50% (shifted right)
  276. const leftValue = parseFloat(
  277. (transformed.echartOptions.graphic as any).left.replace('%', ''),
  278. );
  279. expect(leftValue).toBeGreaterThan(50);
  280. expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
  281. });
  282. it('should center total text when legend is on top', () => {
  283. const props = getChartPropsWithLegend(true, true, 'top', true);
  284. const transformed = transformProps(props);
  285. expect(transformed.echartOptions.graphic).toEqual(
  286. expect.objectContaining({
  287. type: 'text',
  288. left: 'center',
  289. top: expect.stringMatching(/^\d+(\.\d+)?%$/),
  290. }),
  291. );
  292. // The top position should be adjusted for top legend
  293. const topValue = parseFloat(
  294. (transformed.echartOptions.graphic as any).top.replace('%', ''),
  295. );
  296. expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
  297. });
  298. it('should center total text when legend is on bottom', () => {
  299. const props = getChartPropsWithLegend(true, true, 'bottom', true);
  300. const transformed = transformProps(props);
  301. expect(transformed.echartOptions.graphic).toEqual(
  302. expect.objectContaining({
  303. type: 'text',
  304. left: 'center',
  305. top: expect.stringMatching(/^\d+(\.\d+)?%$/),
  306. }),
  307. );
  308. // The top position should be adjusted for bottom legend
  309. const topValue = parseFloat(
  310. (transformed.echartOptions.graphic as any).top.replace('%', ''),
  311. );
  312. expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
  313. });
  314. it('should use default positioning when no legend is shown', () => {
  315. const props = getChartPropsWithLegend(true, false, 'right', true);
  316. const transformed = transformProps(props);
  317. expect(transformed.echartOptions.graphic).toEqual(
  318. expect.objectContaining({
  319. type: 'text',
  320. left: 'center',
  321. top: 'middle',
  322. }),
  323. );
  324. });
  325. it('should handle regular pie chart (non-donut) positioning', () => {
  326. const props = getChartPropsWithLegend(true, true, 'right', false);
  327. const transformed = transformProps(props);
  328. expect(transformed.echartOptions.graphic).toEqual(
  329. expect.objectContaining({
  330. type: 'text',
  331. top: '0', // Non-donut charts use '0' as default top position
  332. left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust left for right legend
  333. }),
  334. );
  335. });
  336. it('should not show total graphic when showTotal is false', () => {
  337. const props = getChartPropsWithLegend(false, true, 'right', true);
  338. const transformed = transformProps(props);
  339. expect(transformed.echartOptions.graphic).toBeNull();
  340. });
  341. });
  342. describe('Other category', () => {
  343. const defaultFormData: SqlaFormData = {
  344. colorScheme: 'bnbColors',
  345. datasource: '3__table',
  346. granularity_sqla: 'ds',
  347. metric: 'metric',
  348. groupby: ['foo', 'bar'],
  349. viz_type: 'my_viz',
  350. };
  351. const getChartProps = (formData: Partial<SqlaFormData>) =>
  352. new ChartProps({
  353. formData: {
  354. ...defaultFormData,
  355. ...formData,
  356. },
  357. width: 800,
  358. height: 600,
  359. queriesData: [
  360. {
  361. data: [
  362. {
  363. foo: 'foo 1',
  364. bar: 'bar 1',
  365. metric: 1,
  366. metric__contribution: 1 / 15, // 6.7%
  367. },
  368. {
  369. foo: 'foo 2',
  370. bar: 'bar 2',
  371. metric: 2,
  372. metric__contribution: 2 / 15, // 13.3%
  373. },
  374. {
  375. foo: 'foo 3',
  376. bar: 'bar 3',
  377. metric: 3,
  378. metric__contribution: 3 / 15, // 20%
  379. },
  380. {
  381. foo: 'foo 4',
  382. bar: 'bar 4',
  383. metric: 4,
  384. metric__contribution: 4 / 15, // 26.7%
  385. },
  386. {
  387. foo: 'foo 5',
  388. bar: 'bar 5',
  389. metric: 5,
  390. metric__contribution: 5 / 15, // 33.3%
  391. },
  392. ],
  393. },
  394. ],
  395. theme: supersetTheme,
  396. });
  397. it('generates Other category', () => {
  398. const chartProps = getChartProps({
  399. threshold_for_other: 20,
  400. });
  401. const transformed = transformProps(chartProps as EchartsPieChartProps);
  402. const series = transformed.echartOptions.series as PieSeriesOption[];
  403. const data = series[0].data as PieChartDataItem[];
  404. expect(data).toHaveLength(4);
  405. expect(data[0].value).toBe(3);
  406. expect(data[1].value).toBe(4);
  407. expect(data[2].value).toBe(5);
  408. expect(data[3].value).toBe(1 + 2);
  409. expect(data[3].name).toBe('Other');
  410. expect(data[3].isOther).toBe(true);
  411. });
  412. });
  413. describe('legend sorting', () => {
  414. const defaultFormData: SqlaFormData = {
  415. colorScheme: 'bnbColors',
  416. datasource: '3__table',
  417. granularity_sqla: 'ds',
  418. metric: 'metric',
  419. groupby: ['foo', 'bar'],
  420. viz_type: 'my_viz',
  421. };
  422. const getChartProps = (formData: Partial<SqlaFormData>) =>
  423. new ChartProps({
  424. formData: {
  425. ...defaultFormData,
  426. ...formData,
  427. },
  428. width: 800,
  429. height: 600,
  430. queriesData: [
  431. {
  432. data: [
  433. {
  434. foo: 'A foo',
  435. bar: 'A bar',
  436. metric: 1,
  437. },
  438. {
  439. foo: 'D foo',
  440. bar: 'D bar',
  441. metric: 2,
  442. },
  443. {
  444. foo: 'C foo',
  445. bar: 'C bar',
  446. metric: 3,
  447. },
  448. {
  449. foo: 'B foo',
  450. bar: 'B bar',
  451. metric: 4,
  452. },
  453. {
  454. foo: 'E foo',
  455. bar: 'E bar',
  456. metric: 5,
  457. },
  458. ],
  459. },
  460. ],
  461. theme: supersetTheme,
  462. });
  463. it('sort legend by data', () => {
  464. const chartProps = getChartProps({
  465. legendSort: null,
  466. });
  467. const transformed = transformProps(chartProps as EchartsPieChartProps);
  468. expect((transformed.echartOptions.legend as any).data).toEqual([
  469. 'A foo, A bar',
  470. 'D foo, D bar',
  471. 'C foo, C bar',
  472. 'B foo, B bar',
  473. 'E foo, E bar',
  474. ]);
  475. });
  476. it('sort legend by label ascending', () => {
  477. const chartProps = getChartProps({
  478. legendSort: 'asc',
  479. });
  480. const transformed = transformProps(chartProps as EchartsPieChartProps);
  481. expect((transformed.echartOptions.legend as any).data).toEqual([
  482. 'A foo, A bar',
  483. 'B foo, B bar',
  484. 'C foo, C bar',
  485. 'D foo, D bar',
  486. 'E foo, E bar',
  487. ]);
  488. });
  489. it('sort legend by label descending', () => {
  490. const chartProps = getChartProps({
  491. legendSort: 'desc',
  492. });
  493. const transformed = transformProps(chartProps as EchartsPieChartProps);
  494. expect((transformed.echartOptions.legend as any).data).toEqual([
  495. 'E foo, E bar',
  496. 'D foo, D bar',
  497. 'C foo, C bar',
  498. 'B foo, B bar',
  499. 'A foo, A bar',
  500. ]);
  501. });
  502. });