diff --git a/README.md b/README.md index 59786e89..516951e1 100644 --- a/README.md +++ b/README.md @@ -528,6 +528,8 @@ These components are automatically included and handle their own state: - **HyperTableV2::Column** - Column wrapper with header rendering - **HyperTableV2::Cell** - Individual cell component +A standalone [HyperTableV2::Summary](#hypertablev2summary) stats footer component is also available (not controlled by `@features`). + Users interact with these components through: - **Search**: Typing in the search input (when searchable is enabled) @@ -537,6 +539,51 @@ Users interact with these components through: The components handle their own internal state and communicate with the TableHandler automatically. +### Standalone Components + +#### HyperTableV2::Summary + +A purely presentational stats footer, usually rendered below the table. It only owns the layout and the loading skeletons: fetching the stats and building the fields (e.g. `—` for unavailable metrics) is the consumer's responsibility. + +The `SummaryField` type can be imported from `@upfluence/hypertable/components/hyper-table-v2/summary`: + +```ts +type SummaryField = { + label: string; + value: string; + tooltip?: string; // already translated +}; +``` + +**Arguments:** + +- `@loading` (boolean, required) - Displays skeletons instead of the fields +- `@fields` (SummaryField[], required) - The stats to display, in order +- `@title` (string, optional) - Title of the summary. Default: `hypertable.summary.title` translation ("Summary") +- `@titleTooltip` (string, optional) - Displays an info icon next to the title with this tooltip + +**Named blocks:** + +- **`:prefix`** - For a stat that doesn't fit the `label` / `value` shape. It is rendered before the fields, followed by a vertical separator. + +```hbs + + <:prefix> + + + +``` + +Behavior: + +- While `@loading` is true, one skeleton is displayed per field (4 when `@fields` is empty), and the `:prefix` block is hidden. +- The separator is only rendered when the `:prefix` block is provided. +- A field `tooltip` displays an info icon between its label and its value. + ## Events TableHandler emits events throughout its lifecycle. Listen to them using the `on` method: diff --git a/addon/components/hyper-table-v2/summary.hbs b/addon/components/hyper-table-v2/summary.hbs new file mode 100644 index 00000000..553312ad --- /dev/null +++ b/addon/components/hyper-table-v2/summary.hbs @@ -0,0 +1,46 @@ +
+
+ {{this.title}} + {{#if @titleTooltip}} + + {{/if}} +
+
+ {{#if @loading}} + {{#each this.skeletons}} +
+ + +
+ {{/each}} + {{else}} + {{#if (has-block "prefix")}} + {{yield to="prefix"}} +
+ {{/if}} + {{#each @fields as |field|}} +
+ + {{field.label}} + + {{#if field.tooltip}} + + {{/if}} + + {{field.value}} + +
+ {{/each}} + {{/if}} +
+
\ No newline at end of file diff --git a/addon/components/hyper-table-v2/summary.ts b/addon/components/hyper-table-v2/summary.ts new file mode 100644 index 00000000..bba64ea8 --- /dev/null +++ b/addon/components/hyper-table-v2/summary.ts @@ -0,0 +1,43 @@ +import { assert } from '@ember/debug'; +import { inject as service } from '@ember/service'; +import Component from '@glimmer/component'; + +import type { IntlService } from 'ember-intl'; + +export type SummaryField = { + label: string; + value: string; + tooltip?: string; +}; + +interface HyperTableV2SummaryArgs { + loading: boolean; + fields: SummaryField[]; + title?: string; + titleTooltip?: string; +} + +const DEFAULT_SKELETONS_COUNT = 4; + +export default class HyperTableV2Summary extends Component { + @service declare intl: IntlService; + + constructor(owner: unknown, args: HyperTableV2SummaryArgs) { + super(owner, args); + + assert( + '[component][HyperTableV2::Summary] Boolean @loading argument is mandatory.', + typeof args.loading === 'boolean' + ); + assert('[component][HyperTableV2::Summary] Array @fields argument is mandatory.', Array.isArray(args.fields)); + } + + get title(): string { + return this.args.title ?? this.intl.t('hypertable.summary.title'); + } + + get skeletons(): number[] { + const count = this.args.fields.length || DEFAULT_SKELETONS_COUNT; + return Array.from({ length: count }, (_, index) => index); + } +} diff --git a/app/components/hyper-table-v2/summary.js b/app/components/hyper-table-v2/summary.js new file mode 100644 index 00000000..7f360471 --- /dev/null +++ b/app/components/hyper-table-v2/summary.js @@ -0,0 +1 @@ +export { default } from '@upfluence/hypertable/components/hyper-table-v2/summary'; diff --git a/app/styles/hypertable.less b/app/styles/hypertable.less index fcc9860b..9ae41e38 100644 --- a/app/styles/hypertable.less +++ b/app/styles/hypertable.less @@ -4,6 +4,7 @@ @import 'facetting'; @import 'footer'; @import 'selection'; +@import 'summary'; .hypertable-container { width: 100%; diff --git a/app/styles/summary.less b/app/styles/summary.less new file mode 100644 index 00000000..ade6c96f --- /dev/null +++ b/app/styles/summary.less @@ -0,0 +1,74 @@ +.hypertable-summary-v2 { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-px-36); + background-color: var(--color-white); + padding: 0 var(--spacing-px-18); + border-top: 1px solid var(--color-border-default); + height: 60px; + width: 100%; + + &__title { + .text-style-semibold; + color: var(--color-primary-500); + font-size: var(--font-size-lg); + } + + &__title-container { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-px-6); + } + + &__title-tooltip { + color: var(--color-primary-500); + font-size: var(--font-size-sm); + } + + &__separator { + width: 1px; + height: 36px; + flex-shrink: 0; + background-color: var(--color-border-default); + } + + &__fields { + display: flex; + flex-direction: row; + align-items: center; + overflow: hidden; + width: 100%; + gap: var(--spacing-px-36); + + @media screen and (max-width: 1220px) { + gap: var(--spacing-px-6); + justify-content: space-between; + } + } + + &__field, + &__skeleton { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-px-6); + } + + &__field-label { + .text-style-uppercase; + color: var(--color-gray-500); + font-size: var(--font-size-sm); + } + + &__field-tooltip { + color: var(--color-primary-500); + font-size: var(--font-size-sm); + } + + &__field-value { + color: var(--color-primary-500); + font-size: var(--font-size-lg); + } +} diff --git a/tests/dummy/app/controllers/application.ts b/tests/dummy/app/controllers/application.ts index 246c7eab..5bb67e8a 100644 --- a/tests/dummy/app/controllers/application.ts +++ b/tests/dummy/app/controllers/application.ts @@ -2,6 +2,7 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; +import { type SummaryField } from '@upfluence/hypertable/components/hyper-table-v2/summary'; import TableHandler from '@upfluence/hypertable/core/handler'; import { Column, @@ -218,6 +219,7 @@ export default class Application extends Controller { @tracked searchQuery: string = ''; @tracked selectedValue: number = 0; @tracked animationsEnabled: boolean = true; + @tracked summaryLoading: boolean = false; private prependedRowsCount: number = 0; tableManager = new Manager(); @@ -294,6 +296,25 @@ export default class Application extends Controller { ]); } + get summaryFields(): SummaryField[] { + return [ + { label: 'Affiliates', value: '128' }, + { label: 'Total Orders', value: '1,234' }, + { label: 'Total Sales', value: '$45,678.90' }, + { label: 'Average Order Value', value: '$37.02' }, + { + label: 'ROI', + value: '—', + tooltip: 'Profit earned for every $1 spent on commissions. (Sales − Commissions) / Commissions.' + } + ]; + } + + @action + toggleSummaryLoading(value: boolean): void { + this.summaryLoading = value; + } + @action resetRows(): Promise { return this.handler.resetRows(); diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index 09b9f963..f1f584a0 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -29,4 +29,27 @@ + + +
+
+ Summary loading: + +
+ + + <:prefix> +
+ +
+ Community + 238,190 / ∞ +
+
+ +
\ No newline at end of file diff --git a/tests/integration/components/hyper-table-v2/summary-test.ts b/tests/integration/components/hyper-table-v2/summary-test.ts new file mode 100644 index 00000000..ba1fb169 --- /dev/null +++ b/tests/integration/components/hyper-table-v2/summary-test.ts @@ -0,0 +1,191 @@ +import { render, setupOnerror, type TestContext } from '@ember/test-helpers'; + +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; +import { setupRenderingTest } from 'ember-qunit'; +import { module, test } from 'qunit'; + +module('Integration | Component | hyper-table-v2/summary', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks); + + hooks.beforeEach(function (this: TestContext) { + this.intl = this.owner.lookup('service:intl'); + this.loading = false; + this.fields = [ + { label: 'Total Orders', value: '1,234' }, + { label: 'ROI', value: '3.2x', tooltip: 'Profit earned for every $1 spent on commissions.' } + ]; + }); + + module('title', () => { + test('it renders the default title when @title is not provided', async function (this: TestContext, assert) { + await render(hbs``); + + assert.dom('[data-control-name="hypertable_summary_title"]').hasText(this.intl.t('hypertable.summary.title')); + }); + + test('it renders @title when provided', async function (this: TestContext, assert) { + await render( + hbs`` + ); + + assert.dom('[data-control-name="hypertable_summary_title"]').hasText('Conversions summary'); + }); + + test('it does not render a title tooltip icon when @titleTooltip is not provided', async function (this: TestContext, assert) { + await render(hbs``); + + assert.dom('[data-control-name="hypertable_summary_title_tooltip"]').doesNotExist(); + }); + + test('it renders a title tooltip icon displaying @titleTooltip on hover', async function (this: TestContext, assert) { + await render( + hbs`` + ); + + assert.dom('[data-control-name="hypertable_summary_title_tooltip"]').hasClass('fa-info-circle'); + + await assert + .tooltip('[data-control-name="hypertable_summary_title_tooltip"]') + .hasTitle('Stats of the filtered records'); + }); + }); + + module('when @loading is true', (hooks) => { + hooks.beforeEach(function (this: TestContext) { + this.loading = true; + }); + + test('it renders one skeleton per field', async function (this: TestContext, assert) { + await render(hbs``); + + assert.dom('[data-control-name="hypertable_summary_skeleton"]').exists({ count: 2 }); + assert.dom('[data-control-name="hypertable_summary_stat"]').doesNotExist(); + }); + + test('it renders the default amount of skeletons when @fields is empty', async function (this: TestContext, assert) { + this.fields = []; + await render(hbs``); + + assert.dom('[data-control-name="hypertable_summary_skeleton"]').exists({ count: 4 }); + }); + + test('it does not render the prefix block nor the separator', async function (this: TestContext, assert) { + await render(hbs` + + <:prefix>Community + + `); + + assert.dom('[data-control-name="prefix_stat"]').doesNotExist(); + assert.dom('[data-control-name="hypertable_summary_separator"]').doesNotExist(); + }); + }); + + module('when @loading is false', () => { + test('it renders one stat per field', async function (this: TestContext, assert) { + await render(hbs``); + + assert.dom('[data-control-name="hypertable_summary_skeleton"]').doesNotExist(); + assert.dom('[data-control-name="hypertable_summary_stat"]').exists({ count: 2 }); + assert + .dom( + '[data-control-name="hypertable_summary_stat"]:nth-child(1) [data-control-name="hypertable_summary_stat_label"]' + ) + .hasText('Total Orders'); + assert + .dom( + '[data-control-name="hypertable_summary_stat"]:nth-child(1) [data-control-name="hypertable_summary_stat_value"]' + ) + .hasText('1,234'); + assert + .dom( + '[data-control-name="hypertable_summary_stat"]:nth-child(2) [data-control-name="hypertable_summary_stat_label"]' + ) + .hasText('ROI'); + assert + .dom( + '[data-control-name="hypertable_summary_stat"]:nth-child(2) [data-control-name="hypertable_summary_stat_value"]' + ) + .hasText('3.2x'); + }); + + test('it does not render a tooltip icon when the field has no tooltip', async function (this: TestContext, assert) { + await render(hbs``); + + assert + .dom( + '[data-control-name="hypertable_summary_stat"]:nth-child(1) [data-control-name="hypertable_summary_stat_tooltip"]' + ) + .doesNotExist(); + }); + + test('it renders a tooltip icon displaying the field tooltip on hover', async function (this: TestContext, assert) { + await render(hbs``); + + const tooltipSelector = + '[data-control-name="hypertable_summary_stat"]:nth-child(2) [data-control-name="hypertable_summary_stat_tooltip"]'; + assert.dom(tooltipSelector).hasClass('fa-info-circle'); + + await assert.tooltip(tooltipSelector).hasTitle('Profit earned for every $1 spent on commissions.'); + }); + + test('it renders no stat when @fields is empty', async function (this: TestContext, assert) { + this.fields = []; + await render(hbs``); + + assert.dom('[data-control-name="hypertable_summary_stat"]').doesNotExist(); + assert.dom('[data-control-name="hypertable_summary_skeleton"]').doesNotExist(); + }); + + test('it does not render the separator when the prefix block is not provided', async function (this: TestContext, assert) { + await render(hbs``); + + assert.dom('[data-control-name="hypertable_summary_separator"]').doesNotExist(); + }); + + test('it renders the prefix block followed by a separator before the fields', async function (this: TestContext, assert) { + await render(hbs` + + <:prefix>Community + + `); + + assert.dom('.hypertable-summary-v2__fields > :nth-child(1)').hasAttribute('data-control-name', 'prefix_stat'); + assert.dom('[data-control-name="prefix_stat"]').hasText('Community'); + assert + .dom('.hypertable-summary-v2__fields > :nth-child(2)') + .hasAttribute('data-control-name', 'hypertable_summary_separator'); + assert + .dom('.hypertable-summary-v2__fields > :nth-child(3)') + .hasAttribute('data-control-name', 'hypertable_summary_stat'); + }); + }); + + module('Error management', () => { + test('it throws an error when @loading is not provided', async function (this: TestContext, assert) { + assert.expect(1); + setupOnerror((err: Error) => { + assert.strictEqual( + err.message, + 'Assertion Failed: [component][HyperTableV2::Summary] Boolean @loading argument is mandatory.' + ); + }); + + await render(hbs``); + }); + + test('it throws an error when @fields is not provided', async function (this: TestContext, assert) { + assert.expect(1); + setupOnerror((err: Error) => { + assert.strictEqual( + err.message, + 'Assertion Failed: [component][HyperTableV2::Summary] Array @fields argument is mandatory.' + ); + }); + + await render(hbs``); + }); + }); +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index c0330ff2..db8d89ca 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -83,6 +83,8 @@ hypertable: success: title: Ownership update message: View successfully shared + summary: + title: Summary selection: records_selected: '{count, plural, =0 {{count} record is selected} =1 {{count} record is selected} other {{count} records are selected}}' all_records_selected: All records ({count}) are selected