Skip to content

Chart: custom context menu #4005

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: develop
Choose a base branch
from
Open

Chart: custom context menu #4005

wants to merge 25 commits into from

Conversation

cnrudd
Copy link
Member

@cnrudd cnrudd commented May 28, 2025

This addresses the following tickets:

#2389
#3813
#3388
#2753

Yes, you get 4 for 1 by merging this PR. :)

Corresponding Toolbox PR with new custom context menu on Line Chart: xh/toolbox#765

Hoist P/R Checklist

Pull request authors: Review and check off the below. Items that do not apply can also be
checked off to indicate they have been considered. If unclear if a step is relevant, please leave
unchecked and note in comments.

  • Caught up with develop branch as of last change.
  • Added CHANGELOG entry, or determined not required.
  • Reviewed for breaking changes: minor breaking change, noted in changelog.
  • Updated doc comments / prop-types, or determined not required.
  • Reviewed and tested on Mobile: not required. no context menu on mobile
  • Created Toolbox branch: Customized chart context menu on lineChart. toolbox#765

If your change is still a WIP, please use the "Create draft pull request" option in the split
button below to indicate it is not ready yet for a final review.

Pull request reviewers: when merging this P/R, please consider using a squash commit to
collapse multiple intermediate commits into a single commit representing the overall feature
change. This helps keep the commit log clean and easy to scan across releases. PRs containing a
single commit should be rebased when possible.

@cnrudd cnrudd marked this pull request as draft May 28, 2025 22:17
@cnrudd
Copy link
Member Author

cnrudd commented May 28, 2025

Still in progress.
I realized I could improve this by passing the series.point and original right click event to the custom actionFn, but have run into a low level issue where it looks like the rendered contextMenu component is cached. So, I am only getting the first context menu generated when clicking on a point for the first time. Subsequent clicks on other points still carry the first point clicked in the context menu.

@cnrudd cnrudd marked this pull request as ready for review May 29, 2025 15:25
@cnrudd
Copy link
Member Author

cnrudd commented May 29, 2025

Still in progress. I realized I could improve this by passing the series.point and original right click event to the custom actionFn, but have run into a low level issue where it looks like the rendered contextMenu component is cached. So, I am only getting the first context menu generated when clicking on a point for the first time. Subsequent clicks on other points still carry the first point clicked in the context menu.

Now ready for review, with point and rightClick event passed to actionFn

@amcclain
Copy link
Member

amcclain commented Jun 2, 2025

Thanks for this @cnrudd - I will review this very shortly and reach out with any questions

Copy link
Member

@amcclain amcclain left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was pre-existing code, but since we're looking at the context menu here, in Chart.ts render function we have

return !XH.isMobileApp ? useContextMenu(coreContents, impl.contextMenu) : coreContents;

Seems like we should check impl.contextMenu instead and return coreContents directly if null. That way the "not supported on mobile" fact is expressed once, and we also aren't wrapping the component in the case where the user has disabled the context menu.


Thanks for making this change - I think we have all the parts we need, just wondering in my review comments here about the "packaging" - specifically the roles of ChartModel vs. ChartLocalModel and the need for the new ChartContextMenu class vs a functional implementation. Have a look and LMK when you think when you have a chance.

contextMenuClickEvt: e,
point
}).items;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be more clear to have this work of determining the point and creating the new object done in the main ChartModel, with the render function getting it directly off of the main model vs this impl?

What's ChartLocalModel doing for us here?

@cnrudd
Copy link
Member Author

cnrudd commented Jun 3, 2025

@amcclain @lbwexler This is ready for a 2nd look.

I've implemented these changes:

  1. reworked as @amcclain suggested so that menu item building is no longer in a class (and implemented all of the smaller suggestions)
  2. added contextMenuEvent to standard context menu (Lee's suggestion)
  3. chart actionFn: grouped args in 'params' arg (Lee's suggestion)
  4. chart actionFn: support providing points (for when more than one series is in chart and tooltip is shared)
  5. also fixed chart mouse right to left zoom out bug
  6. added/improved toolbox demos: multi series chart, new context menu item on basicPanel demo


/**
* If a String, value can be '-' for a separator, or a token supported by HighCharts
* for its native menu items, or a Hoist specific token.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can it also be any arbitrary string to insert a header? If not, think we should spec '-' vs string here

actionFn: () => chartModel.highchart.downloadXLS()
};
default:
return token;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question here - wondering if this can only be - or...

return {
text: 'View in full screen',
icon: Icon.expand(),
hidden: !chartModel,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice that all of these have this hidden: !chartModel - are we ever rendering this menu without a chartModel? Should there be some early out before we reach this point?

| 'downloadPDF'
| 'copyToClipboard';

export interface ChartMenuItem extends Omit<MenuItem, 'actionFn' | 'items'> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth making the naming consistent - we could drop Context from the other exported members here (there's no other kind of menu), or make this ChartContextMenuItem

* If a String, value can be '-' for a separator, or a token supported by HighCharts
* for its native menu items, or a Hoist specific token.
*/
export type ChartContextMenuItemLike = ChartMenuItem | ChartContextMenuToken | string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I'm still a little confused by ChartContextMenuItemLike and ChartMenuItemLike below - just having a hard time following all the variations in this file, not sure if it's the naming, or if we have some extra layer or something..... Left wondering if any way to streamline a bit. Maybe if we clarify the role of the '-' string it would help.

}

function isMenuItem(item: ChartMenuItemLike): item is MenuItem {
return !isString(item) && !isValidElement(item);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is called in just one place here, and we've already checked if the value is a string and won't reach here if so - could inline the validElement check and drop this function which I think just adds a bit more noise to the file at the moment

@@ -30,7 +30,8 @@ export function installZoomoutGesture(Highcharts) {

Highcharts.addEvent(this, 'selection', e => {
if (pixelDiff < 0) {
this.zoom(); // call w/o arguments resets zoom
// `undefined`'s preserve defaults, last arg `false` disables animation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this change doing? Doesn't seem related to the others, or is it?

@@ -284,10 +284,10 @@ export interface MenuItem {
className?: string;

/** Executed when the user clicks the menu item. */
actionFn?: (e: MouseEvent | PointerEvent) => void;
actionFn?: (e: MouseEvent | PointerEvent, contextMenuEvent?: MouseEvent | PointerEvent) => void;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the first event e is the click on a specific item within the menu, and the new second event contextMenuEvent is the right-click that caused the menu to appear in the first place?

@@ -20,6 +20,12 @@ export type ContextMenuSpec = MenuItemLike[] | ((e: MouseEvent) => MenuItemLike[

export interface ContextMenuProps extends HoistProps {
menuItems: MenuItemLike[];
/**
* Optional event because this component is sometimes used as the content in popovers, to
* create menu buttons. In those cases, it is not expected that the click event contains any
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because this component is sometimes used as the content in popovers, to create menu buttons

Is this not always how it's used? What's an example of the "contextual information" here, or when would you expect to find something in the first event e? Just as a developer I'm left a little uncertain as to what I would do with one event or the other.

| ChartContextMenuItemLike[]
| ((chartModel: ChartModel) => ChartContextMenuItemLike[]);

export function getChartContextMenuItems(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we mark as @internal?

if (isNil(spec) || spec === true) return ChartModel.defaultContextMenu;

return spec;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great / very clear

/** True to showContextMenu. Defaults to true. Desktop only. */
showContextMenu?: boolean;
/**
* True to show default ContextMenu. Defaults to true. Desktop only.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* True to show default ContextMenu. Defaults to true. Desktop only.
* True (default) to show default ContextMenu. Supported on desktop only.

@@ -106,7 +108,7 @@ class ChartLocalModel extends HoistModel {
model: ChartModel;

chartRef = createObservableRef<HTMLElement>();
contextMenu: any;
contextMenu: (e) => MenuItemLike[];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry we can leave this as is, but still feels like there's some way to streamline a bit more.

Currently we have

  • ChartLocalModel.contextMenu - function created/captured as soon as linked
  • ChartLocalModel.getContextMenu() - function to create the above function
  • ChartModel.contextMenu - partially processed/parsed spec, might be a function that this local model calls, passing back reference to ChartModel itself
  • getChartContextMenuItems() exported util

Still wondering what we're getting from having the context menu stuff in this local model - I am probably missing some subtlety as to why we need this layer involved, but whatever that subtlety is, if we could make more clear might help whenever we revisit this code in the future.

Or maybe we could drop contextMenu from this local model altogether and have the primary ChartModel process the specs/tokens it's given "all the way" into the function that ChartLocalModel.getContextMenu() currently produces.

So we'd have context menu stuff in two places - the main ChartModel and the helper util - and ChartModel in its ctor would process the specs it's passed all the way through to the form that the rendering within Chart.ts consumes and passes on to the useContextMenu hook.

Don't mean to be a pain with all of this, but am having a hard time keeping track of the current flow and if it could be simpler, great - if not, no worries :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants