Skip to content

Commit 770b463

Browse files
committed
[IMP] Menu: Delegate enabled to children items
Currently, if a menu is evaluated as disabled, we disable it and prevent the possibility to see its children by hover. However, there are some issues with this: - the user is mislead because we keep showing the arrow that suggest the presence of children items - the user can simply not navigate through the submenus to see what kind of feature could be available - if a menu has several children some of which are readonlyAllowed, then the readonlyAllowed condition has to be set on the parent as well; the 'enabling' condition therefore becomes a combination set on both the parent and children - if a parent menu is computed as 'enabled' it will not be greyed out (suggests that it has useful children) even if the said children are not enabled. This revision changes the computation of 'enabled' state of item by looking at the full tree of children and applies the following logic; A parent menu is disabled if every leaf of the tree is disabled. A single 'enabled' leaf (that is a child without children of its own) will make all its parent enabled as well, up to the root. We also allow all submenus to be opened regardless of the state of the parent. The user will therefore have a visual hint that there are active submenus or not (if all children are disabled, so will be the parent) and they will also have the possibility to have a look at all children to explore the possibilities. Task: 5331717
1 parent 92d1026 commit 770b463

File tree

8 files changed

+109
-69
lines changed

8 files changed

+109
-69
lines changed

src/actions/action.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ let nextItemId = 1;
9797

9898
export function createAction(item: ActionSpec): Action {
9999
const name = item.name;
100-
const children = item.children;
100+
const children = item.children || [];
101101
const description = item.description;
102102
const icon = item.icon;
103103
const secondaryIcon = item.secondaryIcon;
@@ -117,14 +117,15 @@ export function createAction(item: ActionSpec): Action {
117117
return undefined;
118118
}
119119
: undefined,
120-
children: children
121-
? (env) => {
122-
return children
123-
.map((child) => (typeof child === "function" ? child(env) : child))
124-
.flat()
125-
.map(createAction);
126-
}
127-
: () => [],
120+
children:
121+
children.length > 0
122+
? (env) => {
123+
return children
124+
.map((child) => (typeof child === "function" ? child(env) : child))
125+
.flat()
126+
.map(createAction);
127+
}
128+
: () => [],
128129
isReadonlyAllowed: item.isReadonlyAllowed || false,
129130
separator: item.separator || false,
130131
icon: typeof icon === "function" ? icon : () => icon || "",

src/actions/view_actions.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,21 +131,18 @@ export const unFreezeRows: ActionSpec = {
131131
env.model.dispatch("UNFREEZE_ROWS", {
132132
sheetId: env.model.getters.getActiveSheetId(),
133133
}),
134-
isReadonlyAllowed: true,
135134
isVisible: (env) =>
136135
!!env.model.getters.getPaneDivisions(env.model.getters.getActiveSheetId()).ySplit,
137136
};
138137

139138
export const freezeFirstRow: ActionSpec = {
140139
name: _t("1 row"),
141140
execute: (env) => interactiveFreezeColumnsRows(env, "ROW", 1),
142-
isReadonlyAllowed: true,
143141
};
144142

145143
export const freezeSecondRow: ActionSpec = {
146144
name: _t("2 rows"),
147145
execute: (env) => interactiveFreezeColumnsRows(env, "ROW", 2),
148-
isReadonlyAllowed: true,
149146
};
150147

151148
export const freezeCurrentRow: ActionSpec = {
@@ -154,7 +151,6 @@ export const freezeCurrentRow: ActionSpec = {
154151
const { bottom } = env.model.getters.getSelectedZone();
155152
interactiveFreezeColumnsRows(env, "ROW", bottom + 1);
156153
},
157-
isReadonlyAllowed: true,
158154
};
159155

160156
export const unFreezeCols: ActionSpec = {
@@ -163,21 +159,18 @@ export const unFreezeCols: ActionSpec = {
163159
env.model.dispatch("UNFREEZE_COLUMNS", {
164160
sheetId: env.model.getters.getActiveSheetId(),
165161
}),
166-
isReadonlyAllowed: true,
167162
isVisible: (env) =>
168163
!!env.model.getters.getPaneDivisions(env.model.getters.getActiveSheetId()).xSplit,
169164
};
170165

171166
export const freezeFirstCol: ActionSpec = {
172167
name: _t("1 column"),
173168
execute: (env) => interactiveFreezeColumnsRows(env, "COL", 1),
174-
isReadonlyAllowed: true,
175169
};
176170

177171
export const freezeSecondCol: ActionSpec = {
178172
name: _t("2 columns"),
179173
execute: (env) => interactiveFreezeColumnsRows(env, "COL", 2),
180-
isReadonlyAllowed: true,
181174
};
182175

183176
export const freezeCurrentCol: ActionSpec = {
@@ -186,7 +179,6 @@ export const freezeCurrentCol: ActionSpec = {
186179
const { right } = env.model.getters.getSelectedZone();
187180
interactiveFreezeColumnsRows(env, "COL", right + 1);
188181
},
189-
isReadonlyAllowed: true,
190182
};
191183

192184
export const viewGridlines: ActionSpec = {

src/components/menu/menu.css

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,18 @@
1818
background-color: var(--os-button-active-bg);
1919
color: var(--os-button-active-text-color);
2020
}
21+
.o-menu-item-icon {
22+
.o-icon {
23+
color: var(--os-icons-color);
24+
}
25+
}
26+
.o-menu-item-description {
27+
color: grey;
28+
}
2129
}
2230
&.disabled {
2331
color: var(--os-disabled-text-color);
32+
cursor: not-allowed;
2433
}
2534
.o-menu-item-name {
2635
min-width: 40%;
@@ -29,12 +38,6 @@
2938
display: inline-block;
3039
margin: 0px 8px 0px 0px;
3140
}
32-
.o-menu-item-description {
33-
color: grey;
34-
}
35-
&.disabled {
36-
cursor: not-allowed;
37-
}
3841
}
3942
}
4043
&.o-spreadsheet-mobile {

src/components/menu/menu.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,19 @@ export class Menu extends Component<MenuProps, SpreadsheetChildEnv> {
118118
}
119119

120120
isEnabled(menu: Action) {
121-
if (menu.isEnabled(this.env)) {
122-
return this.env.model.getters.isReadonly() ? menu.isReadonlyAllowed : true;
121+
const children = menu.children?.(this.env);
122+
if (children.length) {
123+
const areChildrenEnabled = children.length
124+
? children.some((child) => this.isEnabled(child))
125+
: true;
126+
127+
return areChildrenEnabled;
128+
} else {
129+
if (menu.isEnabled(this.env)) {
130+
return this.env.model.getters.isReadonly() ? menu.isReadonlyAllowed : true;
131+
}
132+
return false;
123133
}
124-
return false;
125134
}
126135

127136
get menuStyle() {

src/components/menu_popover/menu_popover.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -245,17 +245,15 @@ export class MenuPopover extends Component<Props, SpreadsheetChildEnv> {
245245
}
246246

247247
onMouseOver(menu: Action, ev: MouseEvent) {
248-
if (this.isEnabled(menu)) {
249-
if (this.isParentMenu(this.subMenu, menu)) {
250-
this.openingTimeOut.clear();
251-
return;
252-
}
253-
const currentTarget = ev.currentTarget as HTMLElement;
254-
if (this.isRoot(menu)) {
255-
this.openingTimeOut.schedule(() => {
256-
this.openSubMenu(menu, currentTarget);
257-
}, TIMEOUT_DELAY);
258-
}
248+
if (this.isParentMenu(this.subMenu, menu)) {
249+
this.openingTimeOut.clear();
250+
return;
251+
}
252+
const currentTarget = ev.currentTarget as HTMLElement;
253+
if (this.isRoot(menu)) {
254+
this.openingTimeOut.schedule(() => {
255+
this.openSubMenu(menu, currentTarget);
256+
}, TIMEOUT_DELAY);
259257
}
260258
}
261259

src/registries/menus/topbar_menu_registry.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ topbarMenuRegistry
2222
.add("file", {
2323
name: _t("File"),
2424
sequence: 10,
25-
isReadonlyAllowed: true,
2625
})
2726
.addChild("settings", ["file"], {
2827
name: _t("Settings"),
@@ -39,7 +38,6 @@ topbarMenuRegistry
3938
.add("edit", {
4039
name: _t("Edit"),
4140
sequence: 20,
42-
isReadonlyAllowed: true,
4341
})
4442
.addChild("undo", ["edit"], {
4543
...ACTION_EDIT.undo,
@@ -126,7 +124,6 @@ topbarMenuRegistry
126124
.add("view", {
127125
name: _t("View"),
128126
sequence: 30,
129-
isReadonlyAllowed: true,
130127
})
131128
.addChild("unfreeze_panes", ["view"], {
132129
...ACTION_VIEW.unFreezePane,
@@ -225,7 +222,6 @@ topbarMenuRegistry
225222
.add("insert", {
226223
name: _t("Insert"),
227224
sequence: 40,
228-
isReadonlyAllowed: true,
229225
})
230226
.addChild("insert_row", ["insert"], {
231227
...ACTION_INSERT.insertRow,
@@ -346,7 +342,6 @@ topbarMenuRegistry
346342
.add("format", {
347343
name: _t("Format"),
348344
sequence: 50,
349-
isReadonlyAllowed: true,
350345
})
351346
.addChild("format_number", ["format"], {
352347
...formatNumberMenuItemSpec,
@@ -441,7 +436,6 @@ topbarMenuRegistry
441436
.add("data", {
442437
name: _t("Data"),
443438
sequence: 60,
444-
isReadonlyAllowed: true,
445439
})
446440
.addChild("sort_range", ["data"], {
447441
...ACTION_DATA.sortRange,

tests/menus/context_menu_component.test.ts

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -449,33 +449,51 @@ describe("Context MenuPopover internal tests", () => {
449449
expect(menuItem2?.classList).toContain("o-menu-item-active");
450450
});
451451

452-
test("submenu does not open when disabled", async () => {
453-
const menuItems: Action[] = createActions([
454-
makeTestMenuItem("root", {
455-
isEnabled: () => false,
456-
children: [makeTestMenuItem("subMenu")],
457-
}),
458-
]);
459-
await renderContextMenu(300, 300, { menuItems });
460-
expect(fixture.querySelector(".o-menu div[data-name='root']")!.classList).toContain("disabled");
461-
await simulateClick(".o-menu div[data-name='root']");
462-
expect(fixture.querySelector(".o-menu div[data-name='subMenu']")).toBeFalsy();
463-
});
452+
describe("'IsEnabled' is ignored on parent menu items", () => {
453+
test("submenu opens even if the parent is disabled", async () => {
454+
const menuItems: Action[] = createActions([
455+
makeTestMenuItem("root", {
456+
isEnabled: () => false,
457+
children: [makeTestMenuItem("subMenu")],
458+
}),
459+
]);
460+
await renderContextMenu(300, 300, { menuItems });
461+
expect(fixture.querySelector(".o-menu div[data-name='root']")!).not.toHaveClass("disabled");
462+
await simulateClick(".o-menu div[data-name='root']");
463+
expect(fixture.querySelector(".o-menu div[data-name='subMenu']")).toBeTruthy();
464+
});
464465

465-
test("submenu does not open when hovering write only parent", async () => {
466-
const menuItems: Action[] = createActions([
467-
makeTestMenuItem("root", {
468-
isReadonlyAllowed: false,
469-
isEnabled: () => true,
470-
children: [makeTestMenuItem("subMenu")],
471-
}),
472-
]);
473-
await renderContextMenu(300, 300, { menuItems });
474-
model.updateMode("readonly");
475-
await nextTick();
476-
expect(fixture.querySelector(".o-menu div[data-name='root']")!.classList).toContain("disabled");
477-
await mouseOverMenuElement(".o-menu div[data-name='root']");
478-
expect(fixture.querySelector(".o-menu div[data-name='subMenu']")).toBeFalsy();
466+
test("in readonly, submenu opens if the children are readonly and the parent write only", async () => {
467+
const menuItems: Action[] = createActions([
468+
makeTestMenuItem("root", {
469+
isReadonlyAllowed: false,
470+
isEnabled: () => false,
471+
children: [makeTestMenuItem("subMenu", { isReadonlyAllowed: true })],
472+
}),
473+
]);
474+
await renderContextMenu(300, 300, { menuItems });
475+
model.updateMode("readonly");
476+
await nextTick();
477+
expect(fixture.querySelector(".o-menu div[data-name='root']")!).not.toHaveClass("disabled");
478+
await mouseOverMenuElement(".o-menu div[data-name='root']");
479+
expect(fixture.querySelector(".o-menu div[data-name='subMenu']")).toBeTruthy();
480+
});
481+
482+
test("in readonly, submenu opens if the children are readonly and the parent write only", async () => {
483+
const menuItems: Action[] = createActions([
484+
makeTestMenuItem("root", {
485+
isReadonlyAllowed: false,
486+
isEnabled: () => true,
487+
children: [makeTestMenuItem("subMenu")],
488+
}),
489+
]);
490+
await renderContextMenu(300, 300, { menuItems });
491+
model.updateMode("readonly");
492+
await nextTick();
493+
expect(fixture.querySelector(".o-menu div[data-name='root']")!).toHaveClass("disabled");
494+
await mouseOverMenuElement(".o-menu div[data-name='root']");
495+
expect(fixture.querySelector(".o-menu div[data-name='subMenu']")).toBeTruthy();
496+
});
479497
});
480498

481499
test("submenu does not close when sub item hovered", async () => {

tests/menus/menu_component.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Model } from "@odoo/o-spreadsheet-engine";
12
import { createActions } from "../../src/actions/action";
23
import { Menu } from "../../src/components/menu/menu";
34
import { simulateClick } from "../test_helpers/dom_helper";
@@ -24,4 +25,28 @@ describe("Menu component", () => {
2425
await simulateClick(selector);
2526
expect(callback).not.toHaveBeenCalled();
2627
});
28+
29+
test("Execute is not called when menu item is ont readonly allowed", async () => {
30+
const callback = jest.fn();
31+
const menuItems = createActions([
32+
{
33+
id: "test_menu",
34+
name: "Test Menu",
35+
isEnabled: () => true,
36+
execute: () => {},
37+
},
38+
]);
39+
40+
const selector = ".o-menu div[data-name='test_menu']";
41+
const model = new Model();
42+
model.updateMode("readonly");
43+
const { fixture } = await mountComponent(Menu, {
44+
props: { menuItems, onClose: () => {}, onClickMenu: () => callback() },
45+
env: { model },
46+
});
47+
48+
expect(fixture.querySelector(selector)!.classList).toContain("disabled");
49+
await simulateClick(selector);
50+
expect(callback).not.toHaveBeenCalled();
51+
});
2752
});

0 commit comments

Comments
 (0)