diff --git a/src/assets/locales/en/tdp.json b/src/assets/locales/en/tdp.json index 25eaa77cf..9a72f53b6 100644 --- a/src/assets/locales/en/tdp.json +++ b/src/assets/locales/en/tdp.json @@ -206,6 +206,7 @@ }, "LineupPanelActions": { + "rankingPanelTabTitle": "Ranking Configuration", "searchPlaceholder": "Add Column …", "addColumnButton": "Add Column", "collapseButton": "(Un)Collapse", diff --git a/src/extensions.ts b/src/extensions.ts index c4b80e66d..27f4b2198 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -6,9 +6,10 @@ import {IEventHandler} from 'phovea_core/src/event'; import {IAdditionalColumnDesc} from './lineup'; import {RangeLike} from 'phovea_core/src/range'; import {IDType} from 'phovea_core/src/idtype'; -import {IColumnDesc, Column} from 'lineupjs'; +import {IColumnDesc, Column, LocalDataProvider} from 'lineupjs'; import {EViewMode} from './views/interfaces'; import {AppHeader} from 'phovea_ui/src/header'; +import {PanelTab} from './lineup/internal/panel/PanelTab'; export * from './tour/extensions'; @@ -23,6 +24,12 @@ export const EXTENSION_POINT_TDP_APP_EXTENSION = 'tdpAppExtension'; export const EXTENSION_POINT_TDP_LIST_FILTERS = 'tdpListFilters'; export const EXTENSION_POINT_TDP_VIEW_GROUPS = 'tdpViewGroups'; +/** + * Register a new tab to the LineupSidePanel. + * Consists of a button/header to open the tab content and the tab content itself + */ +export const EP_TDP_CORE_LINEUP_PANEL_TAB = 'epTdpCoreLineupPanelTab'; + /** * Register new form elements for the form builder. Form elements must implement the `IFormElement`. * @@ -136,6 +143,48 @@ export interface IScoreColumnPatcherExtensionDesc extends IPluginDesc { load(): Promise; } +export interface IPanelTabExtension { + desc: IPanelTabExtensionDesc; + + /** + * Create and attach a new LineUp side panel + * @param tab PanelTab instance to attach the HTMLElement and listen to events + * @param provider The data of the current ranking + * @param desc The phovea extension point description + */ + factory(desc: IPanelTabExtensionDesc, tab: PanelTab, provider: LocalDataProvider): void; +} + +export interface IPanelTabExtensionDesc extends IPluginDesc { + /** + * CSS class for the PanelNavButton of the PanelTab + */ + cssClass: string; + + /** + * Title attribute PanelNavButton + */ + title: string; + + /** + * Customize the PanelNavButtons' position (recommended to use multiples of 10) + */ + order: number; + + /** + * Width of the PanelTab + */ + width: string; + + /** + * If true a shortcut button is appended to the SidePanel header in collapsed mode + * @default false + */ + shortcut?: boolean; + + load(): Promise; +} + export interface IRankingButtonExtension { desc: IRankingButtonExtensionDesc; factory(desc: IRankingButtonExtensionDesc, idType: IDType, extraArgs: object): Promise; @@ -302,7 +351,7 @@ export interface IViewPluginDesc extends IPluginDesc { /** * optional security check to show only certain views */ - security?: string|((user: IUser)=>boolean); + security?: string|((user: IUser) => boolean); /** * a lot of topics/tags describing this view @@ -312,7 +361,7 @@ export interface IViewPluginDesc extends IPluginDesc { /** * a link to an external help page */ - helpUrl?: string | { url: string, linkText: string, title: string }; + helpUrl?: string | {url: string, linkText: string, title: string}; /** * as an alternative an help text shown as pop up */ diff --git a/src/lineup/internal/LineUpPanelActions.ts b/src/lineup/internal/LineUpPanelActions.ts index 55109a3d9..fd9aa78a6 100644 --- a/src/lineup/internal/LineUpPanelActions.ts +++ b/src/lineup/internal/LineUpPanelActions.ts @@ -1,17 +1,25 @@ -import {SidePanel, spaceFillingRule, IGroupSearchItem, SearchBox, LocalDataProvider, createStackDesc, IColumnDesc, createScriptDesc, createSelectionDesc, createAggregateDesc, createGroupDesc, Ranking, createImpositionDesc, createNestedDesc, createReduceDesc, isSupportType, Column} from 'lineupjs'; +import {SidePanel, spaceFillingRule, IGroupSearchItem, LocalDataProvider, createStackDesc, IColumnDesc, createScriptDesc, createSelectionDesc, createAggregateDesc, createGroupDesc, Ranking, createImpositionDesc, createNestedDesc, createReduceDesc, IEngineRankingContext, IRenderContext, IRankingHeaderContextContainer} from 'lineupjs'; import {IDType, resolve} from 'phovea_core/src/idtype'; import {IPlugin, IPluginDesc, list as listPlugins} from 'phovea_core/src/plugin'; import {editDialog} from '../../storage'; import { IScoreLoader, EXTENSION_POINT_TDP_SCORE_LOADER, EXTENSION_POINT_TDP_SCORE, EXTENSION_POINT_TDP_RANKING_BUTTON, - IScoreLoaderExtensionDesc, IRankingButtonExtension, IRankingButtonExtensionDesc + IScoreLoaderExtensionDesc, IRankingButtonExtension, IRankingButtonExtensionDesc, EP_TDP_CORE_LINEUP_PANEL_TAB, IPanelTabExtensionDesc } from '../../extensions'; import {EventHandler} from 'phovea_core/src/event'; import {IARankingViewOptions} from '../ARankingView'; -import {exportLogic} from './export'; import {lazyDialogModule} from '../../dialogs'; +import PanelButton from './panel/PanelButton'; +import {ITabContainer, PanelTabContainer, NullTabContainer} from './panel/PanelTabContainer'; +import {PanelTab, SidePanelTab} from './panel/PanelTab'; +import SearchBoxProvider from './panel/SearchBoxProvider'; +import PanelHeader from './panel/PanelHeader'; +import PanelRankingButton from './panel/PanelRankingButton'; +import PanelAddColumnButton from './panel/PanelAddColumnButton'; import i18n from 'phovea_core/src/i18n'; +import PanelDownloadButton from './panel/PanelDownloadButton'; +import {IPanelTabExtension} from '../../extensions'; export interface ISearchOption { text: string; @@ -25,6 +33,7 @@ export const rule = spaceFillingRule({ groupPadding: 5 }); + /** * Wraps the score such that the plugin is loaded and the score modal opened, when the factory function is called * @param score @@ -58,39 +67,40 @@ export default class LineUpPanelActions extends EventHandler { private idType: IDType | null = null; - private readonly search: SearchBox | null; + private readonly searchBoxProvider: SearchBoxProvider; readonly panel: SidePanel | null; - readonly node: HTMLElement; + readonly node: HTMLElement; // wrapper node + + private readonly header: PanelHeader; + private readonly tabContainer: ITabContainer; + private overview: HTMLElement; private wasCollapsed = false; - constructor(protected readonly provider: LocalDataProvider, ctx: any, private readonly options: Readonly, doc = document) { + constructor(protected readonly provider: LocalDataProvider, ctx: IRankingHeaderContextContainer & IRenderContext & IEngineRankingContext, private readonly options: Readonly, doc = document) { super(); + this.node = doc.createElement('aside'); + this.node.classList.add('lu-side-panel-wrapper'); - if (options.enableAddingColumns) { - this.search = new SearchBox({ - placeholder: i18n.t('tdp:core.lineup.LineupPanelActions.searchPlaceholder') - }); - this.search.on(SearchBox.EVENT_SELECT, (item) => { - this.node.querySelector('.lu-adder')!.classList.remove('once'); - item.action(); - }); - } + this.header = new PanelHeader(this.node); + + this.searchBoxProvider = new SearchBoxProvider(); + + if (this.options.enableSidePanel === 'top') { + this.node.classList.add('lu-side-panel-top'); + this.tabContainer = new NullTabContainer(); // tab container without functionality - if (this.options.enableSidePanel !== 'top') { - this.panel = new SidePanel(ctx, doc, { - chooser: false - }); - this.node = this.panel.node; } else { - this.node = doc.createElement('div'); - this.node.classList.add('lu-side-panel', 'lu-side-panel-top'); + const sidePanel = new SidePanelTab(this.node, this.searchBoxProvider.createSearchBox(), ctx, doc); + this.panel = sidePanel.panel; + this.tabContainer = new PanelTabContainer(this.node); + this.tabContainer.addTab(sidePanel); + this.tabContainer.showTab(sidePanel); } - this.node.classList.add('tdp-view-lineup'); - this.collapse = options.enableSidePanel === 'top' || options.enableSidePanel === 'collapsed'; this.init(); + this.collapse = options.enableSidePanel === 'top' || options.enableSidePanel === 'collapsed'; } forceCollapse() { @@ -114,14 +124,26 @@ export default class LineUpPanelActions extends EventHandler { set collapse(value: boolean) { this.node.classList.toggle('collapsed', value); + + if(value) { + this.tabContainer.hideCurrentTab(); // Hide the active PanelTab and inform its content to stop updating + } else { + this.tabContainer.showCurrentTab(); // Show the last active PanelTab and inform its content to start updating again + } } hide() { this.node.style.display = 'none'; + + // Hide the active PanelTab and inform its content to stop updating + this.tabContainer.hideCurrentTab(); } show() { this.node.style.display = 'flex'; + + // Show the last active PanelTab and inform its content to start updating again + this.tabContainer.showCurrentTab(); } private get isTopMode() { @@ -133,82 +155,64 @@ export default class LineUpPanelActions extends EventHandler { } private init() { - this.node.insertAdjacentHTML('afterbegin', ` -
-
${this.search ? `` : ''} -
`); + const buttons = this.header.node; if (!this.isTopMode && this.options.enableSidePanelCollapsing) { // top mode doesn't need collapse feature - this.node.insertAdjacentHTML('afterbegin', ``); - this.node.querySelector('a')!.addEventListener('click', (evt) => { - evt.preventDefault(); - evt.stopPropagation(); + const listener = () => { this.collapse = !this.collapse; - }); + }; + + const collapseButton = new PanelButton(buttons, i18n.t('tdp:core.lineup.LineupPanelActions.collapseButton'), 'collapse-button', listener); + this.header.addButton(collapseButton); } - const buttons = this.node.querySelector('section'); - this.appendExtraButtons().forEach((b) => buttons.appendChild(b)); + if (this.options.enableAddingColumns) { + const addColumnButton = new PanelAddColumnButton(buttons, this.searchBoxProvider.createSearchBox()); + this.header.addButton(addColumnButton); + } + + this.appendExtraButtons(buttons); + if (this.options.enableSaveRanking) { - buttons.appendChild(this.appendSaveRanking()); + const listener = (ranking: Ranking) => { + editDialog(null, (name, description, sec) => { + this.fire(LineUpPanelActions.EVENT_SAVE_NAMED_SET, ranking.getOrder(), name, description, sec); + }); + }; + + const saveRankingButton = new PanelRankingButton(buttons, this.provider, i18n.t('tdp:core.lineup.LineupPanelActions.saveEntities'), 'fa fa-save', listener); + this.header.addButton(saveRankingButton); } + if (this.options.enableDownload) { - buttons.appendChild(this.appendDownload()); + const downloadButtonContainer = new PanelDownloadButton(buttons, this.provider, this.isTopMode); + this.header.addButton(downloadButtonContainer); } + if (this.options.enableZoom) { - buttons.appendChild(this.createMarkup(i18n.t('tdp:core.lineup.LineupPanelActions.zoomIn'), 'fa fa-search-plus gap', () => this.fire(LineUpPanelActions.EVENT_ZOOM_IN))); - buttons.appendChild(this.createMarkup(i18n.t('tdp:core.lineup.LineupPanelActions.zoomOut'), 'fa fa-search-minus', () => this.fire(LineUpPanelActions.EVENT_ZOOM_OUT))); + const zoomInButton = new PanelButton(buttons, i18n.t('tdp:core.lineup.LineupPanelActions.zoomIn'), 'fa fa-search-plus gap', () => this.fire(LineUpPanelActions.EVENT_ZOOM_IN)); + this.header.addButton(zoomInButton); + + const zoomOutButton = new PanelButton(buttons, i18n.t('tdp:core.lineup.LineupPanelActions.zoomOut'), 'fa fa-search-minus', () => this.fire(LineUpPanelActions.EVENT_ZOOM_OUT)); + this.header.addButton(zoomOutButton); } + if (this.options.enableOverviewMode) { - buttons.appendChild(this.appendOverviewButton()); + const listener = () => { + const selected = this.overview.classList.toggle('fa-th-list'); + this.overview.classList.toggle('fa-list'); + this.fire(LineUpPanelActions.EVENT_RULE_CHANGED, selected ? rule : null); + }; + const overviewButton = new PanelButton(buttons, i18n.t('tdp:core.lineup.LineupPanelActions.toggleOverview'), this.options.enableOverviewMode === 'active' ? 'fa fa-th-list' : 'fa fa-list', listener); + this.overview = overviewButton.node; // TODO might be removed + this.header.addButton(overviewButton); } - const header = this.node.querySelector('.lu-adder')!; - - header.addEventListener('mouseleave', () => { - header.classList.remove('once'); - }); - - if (this.search) { - header.appendChild(this.search.node); - - this.node.querySelector('.lu-adder button')!.addEventListener('click', (evt) => { - evt.preventDefault(); - evt.stopPropagation(); - if (!this.collapse) { - return; - } - header.classList.add('once'); - (this.search.node.querySelector('input'))!.focus(); - this.search.focus(); - }); + if (!this.isTopMode) { + this.appendExtraTabs(); } } - private createMarkup(title: string, linkClass: string, onClick: (ranking: Ranking) => void) { - const b = this.node.ownerDocument.createElement('button'); - b.className = linkClass; - b.title = title; - b.addEventListener('click', (evt) => { - evt.stopPropagation(); - evt.preventDefault(); - const first = this.provider.getRankings()[0]; - if (first) { - onClick(first); - } - }); - return b; - } - - private appendOverviewButton() { - const listener = () => { - const selected = this.overview.classList.toggle('fa-th-list'); - this.overview.classList.toggle('fa-list'); - this.fire(LineUpPanelActions.EVENT_RULE_CHANGED, selected ? rule : null); - }; - return this.overview = this.createMarkup(i18n.t('tdp:core.lineup.LineupPanelActions.toggleOverview'), this.options.enableOverviewMode === 'active' ? 'fa fa-th-list' : 'fa fa-list', listener); - } - setViolation(violation?: string) { if (violation) { this.overview.dataset.violation = violation; @@ -217,78 +221,46 @@ export default class LineUpPanelActions extends EventHandler { } } - private appendDownload() { - const node = this.node.ownerDocument.createElement('div'); - node.classList.add('btn-group', 'download-data-dropdown'); - node.innerHTML = ` - - - `; - - // Listen for row selection and update number of selected rows - // Show/hide some dropdown menu points accordingly using CSS - this.provider.on(LocalDataProvider.EVENT_SELECTION_CHANGED + '.download-menu', (indices: number[]) => { - (node.querySelector('[data-num-selected-rows]')).dataset.numSelectedRows = indices.length.toString(); - }); - - const links = Array.from(node.querySelectorAll('a')); - for (const link of links) { - link.onclick = (evt) => { - evt.preventDefault(); - evt.stopPropagation(); - const type = link.dataset.t; - const onlySelected = link.dataset.s === 's'; - exportLogic(type, onlySelected, this.provider).then(({content, mimeType, name}) => { - this.downloadFile(content, mimeType, name); - }); - }; - } - - - return node; - } - - private appendSaveRanking() { - const listener = (ranking: Ranking) => { - this.saveRankingDialog(ranking.getOrder()); - }; - - return this.createMarkup(i18n.t('tdp:core.lineup.LineupPanelActions.saveEntities'), 'fa fa-save', listener); - } - - private appendExtraButtons() { + private appendExtraButtons(parent: HTMLElement) { const buttons = listPlugins(EXTENSION_POINT_TDP_RANKING_BUTTON); return buttons.map((button) => { const listener = () => { button.load().then((p) => this.scoreColumnDialog(p)); }; - return this.createMarkup(button.title, 'fa ' + button.cssClass, listener); + + const luButton = new PanelRankingButton(parent, this.provider, button.title, 'fa ' + button.cssClass, listener); + this.header.addButton(luButton); }); } - private downloadFile(content: BufferSource | Blob | string, mimeType: string, name: string) { - const doc = this.node.ownerDocument; - const downloadLink = doc.createElement('a'); - const blob = new Blob([content], {type: mimeType}); - downloadLink.href = URL.createObjectURL(blob); - (downloadLink).download = name; + private appendExtraTabs() { + const plugins = listPlugins(EP_TDP_CORE_LINEUP_PANEL_TAB).sort((a, b) => a.order - b.order); + plugins.forEach((plugin) => { + let isLoaded = false; + const tab = new PanelTab(this.tabContainer.node, plugin); - doc.body.appendChild(downloadLink); - downloadLink.click(); - downloadLink.remove(); - } + const onClick = () => { + if (isLoaded) { + if (this.collapse) { + this.collapse = false; // expand side panel + } + this.tabContainer.showTab(tab); - protected saveRankingDialog(order: number[]) { - editDialog(null, (name, description, sec) => { - this.fire(LineUpPanelActions.EVENT_SAVE_NAMED_SET, order, name, description, sec); + } else { + plugin.load().then((p: IPanelTabExtension) => { + p.factory(p.desc, tab, this.provider); + this.collapse = false; // expand side panel + this.tabContainer.showTab(tab); + isLoaded = true; + }); + } + }; + + if (plugin.shortcut) { + this.header.addButton(tab.getShortcutButton()); + } + + this.tabContainer.addTab(tab, onClick); }); } @@ -330,9 +302,10 @@ export default class LineUpPanelActions extends EventHandler { async updateChooser(idType: IDType, descs: IColumnDesc[]) { this.idType = idType; - if (!this.search) { + if (this.searchBoxProvider.length === 0) { return; } + const {metaDataOptions, loadedScorePlugins} = await this.resolveScores(this.idType); const items: (ISearchOption | IGroupSearchItem)[] = []; @@ -404,7 +377,7 @@ export default class LineUpPanelActions extends EventHandler { items.push(specialColumnsOption); } - this.search.data = items; + this.searchBoxProvider.update(items); } private groupedDialog(text: string, children: ISearchOption[]): ISearchOption | IGroupSearchItem { @@ -482,10 +455,10 @@ export function findMappablePlugins(target: IDType, all: IPluginDesc[]) { if (idtype === target.id) { return true; } - //lookup the targets and check if our target is part of it + // lookup the targets and check if our target is part of it return resolve(idtype).getCanBeMappedTo().then((mappables: IDType[]) => mappables.some((d) => d.id === target.id)); } - //check which idTypes can be mapped to the target one + // check which idTypes can be mapped to the target one return Promise.all(idTypes.map(canBeMappedTo)).then((mappable: boolean[]) => { const valid = idTypes.filter((d, i) => mappable[i]); return all.filter((d) => valid.indexOf(d.idtype) >= 0); diff --git a/src/lineup/internal/panel/PanelAddColumnButton.ts b/src/lineup/internal/panel/PanelAddColumnButton.ts new file mode 100644 index 000000000..fed8a938f --- /dev/null +++ b/src/lineup/internal/panel/PanelAddColumnButton.ts @@ -0,0 +1,39 @@ +import {SearchBox} from 'lineupjs'; +import {ISearchOption} from '../LineUpPanelActions'; +import {IPanelButton} from './PanelButton'; +import i18n from 'phovea_core/src/i18n'; + +/** + * Div HTMLElement that contains a button and a SearchBox. + * The SearchBox is hidden by default and can be toggled by the button. + */ +export default class PanelAddColumnButton implements IPanelButton { + readonly node: HTMLElement; + /** + * + * @param parent The parent HTML DOM element + * @param search LineUp SearchBox instance + */ + constructor(parent: HTMLElement, private readonly search: SearchBox) { + this.node = parent.ownerDocument.createElement('div'); + this.node.classList.add('lu-adder'); + this.node.addEventListener('mouseleave', () => { + this.node.classList.remove('once'); + }); + + const button = this.node.ownerDocument.createElement('button'); + button.classList.add('fa', 'fa-plus'); + button.title = i18n.t('tdp:core.lineup.LineupPanelActions.addColumnButton'); + + button.addEventListener('click', (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + this.node.classList.add('once'); + (this.search.node.querySelector('input'))!.focus(); + this.search.focus(); + }); + + this.node.appendChild(button); + this.node.appendChild(this.search.node); + } +} diff --git a/src/lineup/internal/panel/PanelButton.ts b/src/lineup/internal/panel/PanelButton.ts new file mode 100644 index 000000000..1a9223259 --- /dev/null +++ b/src/lineup/internal/panel/PanelButton.ts @@ -0,0 +1,75 @@ +import {PanelTab, IPanelTabDesc} from './PanelTab'; +/** + * Interface for the LineUp panel button + */ +export interface IPanelButton { + /** + * DOM node of the LineUp panel button + */ + readonly node: HTMLElement; +} + +/** + * Plain HTML button with a custom title, CSS class and an onClick function + */ +export default class PanelButton implements IPanelButton { + readonly node: HTMLElement; + + /** + * Constructor of the PanelButton + * @param parent The parent HTML DOM element + * @param title String that is used for the title attribute + * @param linkClass CSS classes to apply + * @param onClick Function that should be executed on button click + */ + constructor(parent: HTMLElement, title: string, linkClass: string, onClick: () => void) { + this.node = parent.ownerDocument.createElement('button'); + this.node.className = linkClass; + this.node.title = title; + this.node.addEventListener('click', (evt) => { + evt.stopPropagation(); + evt.preventDefault(); + onClick(); + }); + } +} + +/** + * HTML button with a custom title, CSS class, an onClick function + * Acts as tab header/button and highlights itself when clicked depending on if the tab body is open or closed + */ +export class PanelNavButton implements IPanelButton { + readonly node: HTMLElement; + readonly order: number; + + /** + * Constructor of the PanelButton + * @param parent The parent HTML DOM element + * @param onClick Function that should be executed on button click + * @param options Options to customize the PanelNavButton + */ + constructor(parent: HTMLElement, onClick: () => void, options: IPanelTabDesc) { + this.node = parent.ownerDocument.createElement('li'); + this.order = options.order; + this.node.innerHTML = `  ${options.title || ''}`; + this.node.querySelector('a').addEventListener('click', (evt) => { + evt.preventDefault(); + onClick(); + }); + } + + /** + * Set the active class to this button + * @param isActive Toggle the class + */ + setActive(isActive: boolean) { + this.node.classList.toggle('active', isActive); + } + + /** + * Trigger click event on anchor element. + */ + click() { + this.node.querySelector('a').click(); + } +} diff --git a/src/lineup/internal/panel/PanelDownloadButton.ts b/src/lineup/internal/panel/PanelDownloadButton.ts new file mode 100644 index 000000000..93a7b74fc --- /dev/null +++ b/src/lineup/internal/panel/PanelDownloadButton.ts @@ -0,0 +1,60 @@ +import {LocalDataProvider} from 'lineupjs'; +import {exportLogic} from '../export'; +import {IPanelButton} from './PanelButton'; +import i18n from 'phovea_core/src/i18n'; + + +/** + * A button dropdown to download selected/all rows of the ranking + */ +export default class PanelDownloadButton implements IPanelButton { + readonly node: HTMLElement; + + constructor(parent: HTMLElement, private provider: LocalDataProvider, isTopMode:boolean) { + this.node = parent.ownerDocument.createElement('div'); + this.node.classList.add('btn-group', 'download-data-dropdown'); + this.node.innerHTML = ` + + + `; + + // Listen for row selection and update number of selected rows + // Show/hide some dropdown menu points accordingly using CSS + this.provider.on(LocalDataProvider.EVENT_SELECTION_CHANGED + '.download-menu', (indices: number[]) => { + (this.node.querySelector('[data-num-selected-rows]')).dataset.numSelectedRows = indices.length.toString(); + }); + + const links = Array.from(this.node.querySelectorAll('a')); + for (const link of links) { + link.onclick = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const type = link.dataset.t; + const onlySelected = link.dataset.s === 's'; + exportLogic(type, onlySelected, this.provider).then(({content, mimeType, name}) => { + this.downloadFile(content, mimeType, name); + }); + }; + } + } + + private downloadFile(content: BufferSource | Blob | string, mimeType: string, name: string) { + const doc = this.node.ownerDocument; + const downloadLink = doc.createElement('a'); + const blob = new Blob([content], {type: mimeType}); + downloadLink.href = URL.createObjectURL(blob); + (downloadLink).download = name; + + doc.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + } +} diff --git a/src/lineup/internal/panel/PanelHeader.ts b/src/lineup/internal/panel/PanelHeader.ts new file mode 100644 index 000000000..f02a9f7b1 --- /dev/null +++ b/src/lineup/internal/panel/PanelHeader.ts @@ -0,0 +1,29 @@ +import {IPanelButton} from './PanelButton'; + +/** + * The panel header contains a list of panel buttons. + */ +export default class PanelHeader { + + readonly node: HTMLElement; + private buttons: IPanelButton[] = []; + + /** + * + * @param parent The parent HTML DOM element. + * @param isTopMode Is the SidePanel collapsed or not. + */ + constructor(parent: HTMLElement) { + this.node = parent.ownerDocument.createElement('header'); + parent.appendChild(this.node); + } + + /** + * Add a panel button to this header + * @param button Panel button instance to add + */ + addButton(button: IPanelButton) { + this.buttons = [...this.buttons, button]; + this.node.appendChild(button.node); + } +} diff --git a/src/lineup/internal/panel/PanelRankingButton.ts b/src/lineup/internal/panel/PanelRankingButton.ts new file mode 100644 index 000000000..12966f523 --- /dev/null +++ b/src/lineup/internal/panel/PanelRankingButton.ts @@ -0,0 +1,24 @@ +import {IPanelButton} from './PanelButton'; +import {LocalDataProvider, Ranking} from 'lineupjs'; + +/** + * Plain HTML button with a custom title, CSS class and an onClick function. + * Injects through the onClick callback the current ranking. + */ +export default class PanelRankingButton implements IPanelButton { + readonly node: HTMLElement; + + constructor(parent: HTMLElement, private provider: LocalDataProvider, title: string, linkClass: string, onClick: (ranking: Ranking) => void) { + this.node = parent.ownerDocument.createElement('button'); + this.node.className = linkClass; + this.node.title = title; + this.node.addEventListener('click', (evt) => { + evt.stopPropagation(); + evt.preventDefault(); + const firstRanking = this.provider.getRankings()[0]; + if (firstRanking) { + onClick(firstRanking); + } + }); + } +} diff --git a/src/lineup/internal/panel/PanelTab.ts b/src/lineup/internal/panel/PanelTab.ts new file mode 100644 index 000000000..f570eff8f --- /dev/null +++ b/src/lineup/internal/panel/PanelTab.ts @@ -0,0 +1,126 @@ +import {SidePanel, SearchBox, IEngineRankingContext, IRenderContext, IRankingHeaderContextContainer} from 'lineupjs'; +import {ISearchOption} from '../LineUpPanelActions'; +import {EventHandler} from 'phovea_core/src/event'; +import PanelButton, {PanelNavButton} from './PanelButton'; +import i18n from 'phovea_core/src/i18n'; + +/** + * Interface for the options parameter of PanelTab + */ +export interface IPanelTabDesc { + + /** + * Width of the SidePanel + */ + width: string; + + /** + * CSS class for PanelNavButton of the PanelTab + */ + cssClass: string; + + /** + * Title and Text content for the PanelNavButton of the PanelTab. + */ + title: string; + + /** + * Define the sort order of the PanelNavButtons + */ + order: number; + + /** + * Show PanelNavButton in collapsed mode + * @default false + */ + shortcut?: boolean; +} + +/** + * The PanelTab creates a tab component that with can be toggled through the PanelNavButton + */ +export class PanelTab extends EventHandler { + + static readonly SHOW_PANEL = 'showPanel'; + + static readonly HIDE_PANEL = 'hidePanel'; + + readonly node: HTMLElement; + readonly options: IPanelTabDesc = { + cssClass: 'fa fa-sliders', + title: i18n.t('tdp:core.lineup.LineupPanelActions.rankingPanelTabTitle'), + width: '23em', + order: 0 + }; + private navButton: PanelNavButton; + + /** + * @param parent The parent HTML DOM element + * @param options Extra styles to apply to the PanelTab + */ + constructor(private parent: HTMLElement, options?: IPanelTabDesc) { + super(); + + this.node = parent.ownerDocument.createElement('div'); + this.node.classList.add('tab-pane'); + this.node.setAttribute('role', 'tabpanel'); + Object.assign(this.options, options); + } + + /** + * Show this tab and fire the `PanelTab.SHOW_PANEL` event. + */ + public show() { + this.node.classList.add('active'); + this.navButton.setActive(true); + this.fire(PanelTab.SHOW_PANEL); + } + + /** + * Hide this tab and fire the `PanelTab.HIDE_PANEL` event. + */ + public hide() { + this.node.classList.remove('active'); + this.navButton.setActive(false); + this.fire(PanelTab.HIDE_PANEL); + } + + getNavButton(listener): PanelNavButton { + this.navButton = new PanelNavButton(this.parent, listener, this.options); + return this.navButton; + } + + getShortcutButton(): PanelButton { + const onClick = () => { + this.navButton.click(); + }; + + return new PanelButton(this.parent, this.options.title, 'fa ' + this.options.cssClass + ' shortcut-nav', onClick); + } +} + +/** + * Default active PanelTab + * Contains LineUp SidePanel and LineUp SearchBox + */ +export class SidePanelTab extends PanelTab { + + readonly panel: SidePanel | null; + + /** + * @param parent The parent HTML DOM element + * @param search LineUp SearchBox + * @param ctx LineUp context + * @param doc Document + */ + constructor(parent: HTMLElement, private readonly search: SearchBox, ctx: IRankingHeaderContextContainer & IRenderContext & IEngineRankingContext, doc = document, options?: IPanelTabDesc) { + super(parent, options); + this.node.classList.add('default'); + this.panel = new SidePanel(ctx, doc, { + chooser: false + }); + + this.node.appendChild(this.search.node); + this.node.appendChild(this.panel.node); + } +} diff --git a/src/lineup/internal/panel/PanelTabContainer.ts b/src/lineup/internal/panel/PanelTabContainer.ts new file mode 100644 index 000000000..49babeee9 --- /dev/null +++ b/src/lineup/internal/panel/PanelTabContainer.ts @@ -0,0 +1,199 @@ +import {PanelTab} from './PanelTab'; +import {PanelNavButton} from './PanelButton'; + + + +/** + * The header of the PanelTab + * Contains the PanelNavButtons that toggle the PanelTab + */ +class PanelTabHeader { + public node: HTMLElement; + + /** + * @param parent The parent HTML DOM element + */ + constructor(parent: HTMLElement) { + this.node = parent.ownerDocument.createElement('ul'); + this.node.className = 'nav nav-tabs'; + parent.appendChild(this.node); + } + + /** + * Append PanelNavButtons to PanelTabHeader + * @param button PanelNavButton instance to add + */ + addNavButton(button: PanelNavButton) { + this.node.appendChild(button.node); + } +} + +export interface ITabContainer { + + /** + * HTMLElement of the tab container + */ + readonly node: HTMLElement; + + /** + * Resize the Panel to fit the content of the new tab. + * @param width width the PanelTabContainer should have. + */ + resizeNode(width: string): void; + + /** + * Method to add a new PanelTab. + * @param tab New PanelTab instance. + * @param onClick Optional function that is executed on the tab; Important: You must call `tabContainer.showTab()` yourself!. + */ + addTab(tab: PanelTab, onClick?: () => void): void; + + /** + * Close currentTab and show new PanelTab. + * @param tab A PanelTab instance. + */ + showTab(tab: PanelTab): void; + + /** + * Show last opened PanelTab. + * Used when the LineUpPanelActions reopens to show the last open PanelTab. + */ + showCurrentTab(): void; + + /** + * Hide currentTab. + */ + hideCurrentTab(): void; +} + +/** + * The NullTabContainer does not have any functionality. + * The public functions have no operation and the public properties are dummy HTMLElements. + */ +export class NullTabContainer implements ITabContainer { + readonly node: HTMLElement = null; + + /** + * Resize the Panel to fit the content of the new tab. + * @param width width the PanelTabContainer should have. + */ + resizeNode(width: string): void { + // noop + } + + /** + * Method to add a new PanelTab. + * @param tab New PanelTab instance. + * @param onClick Optional function that is executed on the tab; Important: You must call `tabContainer.showTab()` yourself!. + */ + addTab(tab: PanelTab, onClick?: () => void): void { + // noop + } + + /** + * Close currentTab and show new PanelTab. + * @param tab A PanelTab instance. + */ + showTab(tab: PanelTab): void { + // noop + } + + /** + * Show last opened PanelTab. + * Used when the LineUpPanelActions reopens to show the last open PanelTab. + */ + showCurrentTab(): void { + // noop + } + + /** + * Hide currentTab. + */ + hideCurrentTab(): void { + // noop + } +} + +/** + * The PanelTabContainer creates tab able nav buttons that toggle their corresponding PanelTab. + */ +export class PanelTabContainer implements ITabContainer { + + readonly node: HTMLElement; + + private readonly tabContentNode: HTMLElement; + private parent: HTMLElement; + private tabs: PanelTab[] = []; + private tabHeader: PanelTabHeader; + private currentTab: PanelTab; + + + /** + * @param parent The parent HTML DOM element. + */ + constructor(parent: HTMLElement) { + this.parent = parent; + this.node = parent.ownerDocument.createElement('main'); + + this.tabContentNode = this.node.ownerDocument.createElement('div'); + this.tabContentNode.classList.add('tab-content'); + + this.tabHeader = new PanelTabHeader(this.node); + + this.node.appendChild(this.tabContentNode); + parent.appendChild(this.node); + } + + /** + * Resize the Panel to fit the content of the new tab. + * @param width width the PanelTabContainer should have. + */ + resizeNode(width: string): void { + this.parent.style.width = width; + } + + /** + * Method to add a new PanelTab. + * @param tab New PanelTab instance. + * @param onClick Optional function that is executed on the tab; Important: You must call `tabContainer.showTab()` yourself!. + */ + addTab(tab: PanelTab, onClick?: () => void): void { + this.tabs = [...this.tabs, tab]; + + const listener = (onClick) ? onClick : () => { + this.showTab(tab); + }; + + this.tabHeader.addNavButton(tab.getNavButton(listener)); + this.tabContentNode.appendChild(tab.node); + } + + /** + * Close currentTab and show new PanelTab. + * @param tab A PanelTab instance. + */ + showTab(tab: PanelTab): void { + if (this.currentTab) { + this.currentTab.hide(); + } + + this.resizeNode(tab.options.width); + tab.show(); + this.currentTab = tab; + } + + /** + * Show last opened PanelTab. + * Used when the LineUpPanelActions reopens to show the last open PanelTab. + */ + showCurrentTab(): void { + this.currentTab.show(); + } + + /** + * Hide currentTab. + */ + hideCurrentTab(): void { + this.currentTab.hide(); + } +} diff --git a/src/lineup/internal/panel/SearchBoxProvider.ts b/src/lineup/internal/panel/SearchBoxProvider.ts new file mode 100644 index 000000000..c66f75cac --- /dev/null +++ b/src/lineup/internal/panel/SearchBoxProvider.ts @@ -0,0 +1,48 @@ +import {SearchBox, LocalDataProvider, IGroupSearchItem, ISearchBoxOptions} from 'lineupjs'; +import {ISearchOption} from '../LineUpPanelActions'; +import i18n from 'phovea_core/src/i18n'; + +/** + * The SearchBoxProvider allows creating multiple LineUp SearchBoxes and stores them internally in a list. + * All created search boxes can be updated simultaneously with a list of searchable items. + */ +export default class SearchBoxProvider { + + /** + * List of created LineUp SearchBoxes + */ + private searchBoxes: SearchBox[] = []; + + get length(): number { + return this.searchBoxes.length; + } + + /** + * Create a new LineUp SearchBox. The instance is added to the internal list and returned. + * @returns A new LineUp SearchBox instance + */ + createSearchBox(options: Partial> = {}): SearchBox { + const mergedOptions = Object.assign({ + placeholder: i18n.t('tdp:core.lineup.LineupPanelActions.searchPlaceholder') + }, options); + + const searchBox = new SearchBox(mergedOptions); + + searchBox.on(SearchBox.EVENT_SELECT, (item) => { + item.action(); + }); + + this.searchBoxes = [...this.searchBoxes, searchBox]; + + return searchBox; + } + + /** + * Set the passed items to all previously created search box instances. + * @param items List of searchable items for the SearchBox + */ + update(items: (ISearchOption | IGroupSearchItem)[]) { + this.searchBoxes.forEach((searchBox) => searchBox.data = items); + } + +} diff --git a/src/lineup/internal/utils.ts b/src/lineup/internal/utils.ts index 26668e6f4..c263dcc1f 100644 --- a/src/lineup/internal/utils.ts +++ b/src/lineup/internal/utils.ts @@ -7,12 +7,20 @@ import {IDataRow} from 'lineupjs'; import {convertRow2MultiMap, IFormMultiMap, IFormRow} from '../../form'; import {encodeParams} from 'phovea_core/src/ajax'; + +/** + * Interface for AScoreAccessorProxy + */ +export interface IAccessorFunc { + (row: IDataRow): T; +} + export class AScoreAccessorProxy { /** * the accessor for the score column * @param row */ - readonly accessor = (row: IDataRow) => this.access(row.v); + readonly accessor: IAccessorFunc = (row: IDataRow) => this.access(row.v); private readonly scores = new Map(); constructor(private readonly missingValue: T = null) { @@ -64,7 +72,7 @@ export function createAccessor(colDesc: any): AScoreAccessorProxy { * converts the given filter object to request params * @param filter input filter */ -export function toFilter(filter: IFormMultiMap|IFormRow[]): IParams { +export function toFilter(filter: IFormMultiMap | IFormRow[]): IParams { if (Array.isArray(filter)) { //map first return toFilter(convertRow2MultiMap(filter)); @@ -101,12 +109,13 @@ export function toFilterString(filter: IFormMultiMap, key2name?: Mapany): (rows: IFormRow[])=>Promise { +export function previewFilterHint(database: string, view: string, extraParams?: () => any): (rows: IFormRow[]) => Promise { let total: Promise = null; const cache = new Map>(); diff --git a/src/styles/_view_lineup.scss b/src/styles/_view_lineup.scss index 393a4d080..0c919acf2 100644 --- a/src/styles/_view_lineup.scss +++ b/src/styles/_view_lineup.scss @@ -1,6 +1,6 @@ -@import './vars'; -@import '~font-awesome/scss/variables'; -@import '~font-awesome/scss/mixins'; +@import "./vars"; +@import "~font-awesome/scss/variables"; +@import "~font-awesome/scss/mixins"; &.lineup { display: flex; @@ -73,10 +73,9 @@ } } -$lu_assets: '~lineupjs/src/assets'; +$lu_assets: "~lineupjs/src/assets"; @at-root { - .tdp-ranking-export-form { user-select: none; -moz-user-select: none; @@ -105,22 +104,27 @@ $lu_assets: '~lineupjs/src/assets'; } } + .lu-side-panel { + width: 100%; + } + .lu-side-panel > main > section::before { - content: 'Column Summaries'; + content: "Column Summaries"; } .lu-hierarchy .lu-search::before { height: 19px; } - .tdp-view-lineup.lu-side-panel { - flex: 0 0 auto; - position: relative; - + .lu-side-panel-wrapper { + display: flex; + flex-direction: column; + overflow: hidden; %lu-panel-button { background: #fff; border: 1px solid #ddd; + border-radius: 0px; padding: 5px 10px; cursor: pointer; z-index: 1; @@ -131,41 +135,58 @@ $lu_assets: '~lineupjs/src/assets'; } } - > section { - flex: 0 0 auto; + &:not(.collapsed) { + .shortcut-nav { + display: none; // hide navTab that doesn't have class `shortcut-nav` in collapsed mode + } + } + + .lu-side-panel > main > section > div { + flex: 1; + } + + aside { + border-left: 0; + } + + > header { display: flex; - justify-content: flex-end; - margin-right: 1em; flex-wrap: wrap; + margin-bottom: 0.5em; + + .lu-adder { + margin-top: 1em; + display: none; + } + .collapse-button, button { @extend %lu-panel-button; + height: 2em; } } - label, input { + label, + input { font-weight: normal; margin: 0; } - > div.lu-adder { + .lu-adder { margin-top: 1em; - > button { + button { @extend %lu-panel-button; display: none; } } - > a { + .collapse-button { @extend %lu-panel-button; - - position: absolute; - left: 0; - top: 0; - width: 2em; + grid-row: 1/3; height: 2em; + margin-right: 0.5em; &::before { @include fa-icon(); @@ -175,7 +196,7 @@ $lu_assets: '~lineupjs/src/assets'; button[data-violation] { position: relative; - color: #FFD700; + color: #ffd700; &::before { content: $fa-var-exclamation-triangle; @@ -185,7 +206,7 @@ $lu_assets: '~lineupjs/src/assets'; content: attr(data-violation); color: black; text-align: left; - background: lighten(#FFD700, 45%); + background: lighten(#ffd700, 45%); hyphens: manual; padding: 5px; width: 12em; @@ -207,65 +228,127 @@ $lu_assets: '~lineupjs/src/assets'; } .download-data-dropdown { + // show the number of selected rows after the element li[data-num-selected-rows]::after { - content: ' (' attr(data-num-selected-rows) ')'; + content: " ("attr(data-num-selected-rows) ")"; display: inline; } // hide the dropdown-header and the next list point (= download link), if no rows are selected // note that this only works for a *single* link as after the header list item! - li[data-num-selected-rows='0'], - li[data-num-selected-rows='0'] + li { + li[data-num-selected-rows="0"], + li[data-num-selected-rows="0"] + li { display: none; } } + > main { + display: flex; + flex-direction: column; + flex: 1; + + ul.nav-tabs { + display: flex; + + & > li { + &:only-child() { + border: 1px solid white; // overlay the parent element's border + width: 100%; + + & > a { + display: none; + } + } + + > a { + color: black; + padding: 5px 8.5px; + cursor: pointer; + text-align-last: center; + display: flex; + align-items: center; + text-align-last: auto; + + & > i { + align-self: flex-start; + } + } + } + } + + .tab-content { + position: relative; + margin-top: 0.5em; + display: flex; + flex-direction: column; + flex: 1; + } + + .tab-pane { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + overflow-y: auto; + } + } + &.collapsed { max-width: 3em; min-width: 0; + display: flex; + flex-direction: column; + overflow: visible; - > a { - width: 100%; - order: 1; + .collapse-button { + position: relative; + margin-right: 0; &::before { content: $fa-var-arrow-left; } } - > section { - order: 3; - flex-direction: column; + > header { + // Add margin before the first shortcut-nav + + > button:not(.shortcut-nav) + .shortcut-nav { + margin-top: 1em; + } + margin-right: 0; + min-width: 0; + align-self: flex-start; + max-width: 100%; + flex-direction: column; + + display: flex; + flex-direction: column; } - > div, - > .lu-stats, - > header, > main { display: none; } - > div.lu-adder { - order: 2; + .lu-adder { padding: 0; display: flex; flex-direction: column; position: relative; border: none; - margin-top: 3em; &:not(.once) .lu-search { display: none; } - > button { + button { display: block; } &.once::before { - content: ''; + content: ""; top: -30px; left: -20em; right: 0; @@ -280,7 +363,7 @@ $lu_assets: '~lineupjs/src/assets'; left: -15em; z-index: 2; - > ul { + ul { max-height: 50vh; } } @@ -292,25 +375,28 @@ $lu_assets: '~lineupjs/src/assets'; } } - .lu-sidepanel-top-form { + .lu-side-panel-top-form { display: flex; - > p { + p { flex: 1 1 0; } } - .lu-side-panel-top.tdp-view-lineup.lu-side-panel.collapsed { + .lu-side-panel-top.lu-side-panel-wrapper.collapsed { display: inline-flex; flex-direction: row; z-index: 100; max-width: unset; width: unset; float: right; + border-left: none; - > section { + > header { + display: flex; flex-direction: row; max-width: unset; + margin-bottom: 0; } .gap { @@ -318,7 +404,7 @@ $lu_assets: '~lineupjs/src/assets'; margin-left: 1em; } - > div.lu-adder { + div.lu-adder { margin-top: 0; &.once .lu-search { @@ -326,7 +412,7 @@ $lu_assets: '~lineupjs/src/assets'; } &.once::before { - content: ''; + content: ""; top: -10px; left: -2.5em; right: -15em;