Skip to content

Commit

Permalink
[IMP] pivot: select dimension with arrow keys
Browse files Browse the repository at this point in the history
In the side panel of pivot properties, when clicking on Add
to add a new dimension, the user can now use the arrow keys and Enter
key to select a dimension.

Note: tests are in odoo because there's currently
no pivot in o-spreadsheet alone.

closes #4135

Task: 3893736
Signed-off-by: Rémi Rahir (rar) <rar@odoo.com>
  • Loading branch information
LucasLefevre committed May 14, 2024
1 parent 475feb2 commit aaa7ab5
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 56 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { AutoCompleteProposal, AutoCompleteProvider } from "../../../registries";
import { SpreadsheetStore } from "../../../stores";

export class AutoCompleteStore extends SpreadsheetStore {
selectedIndex: number | undefined = undefined;
provider: AutoCompleteProvider | undefined;

get selectedProposal(): AutoCompleteProposal | undefined {
if (this.selectedIndex === undefined || this.provider === undefined) {
return undefined;
}
return this.provider.proposals[this.selectedIndex];
}

useProvider(provider: AutoCompleteProvider) {
this.provider = provider;
this.selectedIndex = provider.autoSelectFirstProposal ? 0 : undefined;
}

hide() {
this.provider = undefined;
this.selectedIndex = undefined;
}

selectIndex(index: number) {
this.selectedIndex = index;
}

moveSelection(direction: "previous" | "next") {
if (!this.provider) {
return;
}
if (this.selectedIndex === undefined) {
this.selectedIndex = 0;
return;
}
if (direction === "next") {
this.selectedIndex--;
if (this.selectedIndex < 0) {
this.selectedIndex = this.provider.proposals.length - 1;
}
} else {
this.selectedIndex = (this.selectedIndex + 1) % this.provider.proposals.length;
}
}
}
44 changes: 9 additions & 35 deletions src/components/composer/composer/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { clip, getZoneArea, isEqual, splitReference } from "../../../helpers/ind
import { ComposerStore } from "./composer_store";

import { EnrichedToken } from "../../../formulas/composer_tokenizer";
import { AutoCompleteProvider } from "../../../registries";
import { Store, useStore } from "../../../store_engine";
import { Store, useLocalStore, useStore } from "../../../store_engine";
import { DOMFocusableElementStore } from "../../../stores/DOM_focus_store";
import {
CSSProperties,
Expand All @@ -21,6 +20,7 @@ import { css, cssPropertiesToCss } from "../../helpers/css";
import { keyboardEventToShortcutString } from "../../helpers/dom_helpers";
import { updateSelectionWithArrowKeys } from "../../helpers/selection_helpers";
import { TextValueProvider } from "../autocomplete_dropdown/autocomplete_dropdown";
import { AutoCompleteStore } from "../autocomplete_dropdown/autocomplete_dropdown_store";
import { ComposerFocusType } from "../composer_focus_store";
import { ContentEditableHelper } from "../content_editable_helper";
import { FunctionDescriptionProvider } from "../formula_assistant/formula_assistant";
Expand Down Expand Up @@ -115,11 +115,6 @@ interface ComposerState {
positionEnd: number;
}

interface AutoCompleteState {
provider: AutoCompleteProvider | undefined;
selectedIndex: number | undefined;
}

interface FunctionDescriptionState {
showDescription: boolean;
functionName: string;
Expand Down Expand Up @@ -159,10 +154,7 @@ export class Composer extends Component<ComposerProps, SpreadsheetChildEnv> {
positionEnd: 0,
});

autoCompleteState: AutoCompleteState = useState({
provider: undefined,
selectedIndex: undefined,
});
autoCompleteState!: Store<AutoCompleteStore>;

functionDescriptionState: FunctionDescriptionState = useState({
showDescription: false,
Expand Down Expand Up @@ -227,6 +219,7 @@ export class Composer extends Component<ComposerProps, SpreadsheetChildEnv> {
setup() {
this.composerStore = useStore(ComposerStore);
this.DOMFocusableElementStore = useStore(DOMFocusableElementStore);
this.autoCompleteState = useLocalStore(AutoCompleteStore);
onMounted(() => {
const el = this.composerRef.el!;
if (this.props.isDefaultFocus) {
Expand Down Expand Up @@ -262,7 +255,7 @@ export class Composer extends Component<ComposerProps, SpreadsheetChildEnv> {
)
) {
this.functionDescriptionState.showDescription = false;
this.autoCompleteState.provider = undefined;
this.autoCompleteState.hide();
// Prevent the default content editable behavior which moves the cursor
ev.preventDefault();
ev.stopPropagation();
Expand All @@ -288,21 +281,7 @@ export class Composer extends Component<ComposerProps, SpreadsheetChildEnv> {
// only for arrow up and down
if (["ArrowUp", "ArrowDown"].includes(ev.key) && this.autoCompleteState.provider) {
ev.preventDefault();
if (this.autoCompleteState.selectedIndex === undefined) {
this.autoCompleteState.selectedIndex = 0;
return;
}
if (ev.key === "ArrowUp") {
this.autoCompleteState.selectedIndex--;
if (this.autoCompleteState.selectedIndex < 0) {
this.autoCompleteState.selectedIndex =
this.autoCompleteState.provider.proposals.length - 1;
}
} else {
this.autoCompleteState.selectedIndex =
(this.autoCompleteState.selectedIndex + 1) %
this.autoCompleteState.provider.proposals.length;
}
this.autoCompleteState.moveSelection(ev.key === "ArrowDown" ? "next" : "previous");
}
}

Expand Down Expand Up @@ -465,13 +444,8 @@ export class Composer extends Component<ComposerProps, SpreadsheetChildEnv> {
}
}

showAutoComplete(provider: AutoCompleteProvider) {
this.autoCompleteState.provider = provider;
this.autoCompleteState.selectedIndex = provider.autoSelectFirstProposal ? 0 : undefined;
}

updateAutoCompleteIndex(index: number) {
this.autoCompleteState.selectedIndex = clip(0, index, 10);
this.autoCompleteState.selectIndex(clip(0, index, 10));
}

/**
Expand Down Expand Up @@ -705,11 +679,11 @@ export class Composer extends Component<ComposerProps, SpreadsheetChildEnv> {
*/
private processTokenAtCursor(): void {
let content = this.composerStore.currentContent;
this.autoCompleteState.provider = undefined;
this.autoCompleteState.hide();
this.functionDescriptionState.showDescription = false;
const autoCompleteProvider = this.composerStore.autocompleteProvider;
if (autoCompleteProvider) {
this.showAutoComplete(autoCompleteProvider);
this.autoCompleteState.useProvider(autoCompleteProvider);
return;
}
const token = this.composerStore.tokenAtCursor;
Expand Down
11 changes: 11 additions & 0 deletions src/components/helpers/autofocus_hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useEffect, useRef } from "@odoo/owl";

export function useAutofocus({ refName }: { refName: string }) {
const ref = useRef(refName);
useEffect(
(el) => {
el?.focus();
},
() => [ref.el]
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { Component, useExternalListener, useRef, useState } from "@odoo/owl";
import { SpreadsheetChildEnv } from "../../../../..";
import { COMPOSER_ASSISTANT_COLOR } from "../../../../../constants";
import { fuzzyLookup } from "../../../../../helpers";
import { AutoCompleteProposal, AutoCompleteProvider } from "../../../../../registries";
import { Store, useLocalStore } from "../../../../../store_engine";
import { PivotField } from "../../../../../types/pivot";
import { TextValueProvider } from "../../../../composer/autocomplete_dropdown/autocomplete_dropdown";
import { AutoCompleteStore } from "../../../../composer/autocomplete_dropdown/autocomplete_dropdown_store";
import { css } from "../../../../helpers";
import { useAutofocus } from "../../../../helpers/autofocus_hook";
import { getHtmlContentFromPattern } from "../../../../helpers/html_content_helpers";
import { Popover } from "../../../../popover";

interface Props {
Expand Down Expand Up @@ -32,7 +39,7 @@ css/* scss */ `

export class AddDimensionButton extends Component<Props, SpreadsheetChildEnv> {
static template = "o-spreadsheet-AddDimensionButton";
static components = { Popover };
static components = { Popover, TextValueProvider };
static props = {
onFieldPicked: Function,
fields: Array,
Expand All @@ -41,21 +48,53 @@ export class AddDimensionButton extends Component<Props, SpreadsheetChildEnv> {
private buttonRef = useRef("button");
private popover = useState({ isOpen: false });
private search = useState({ input: "" });
private autoComplete!: Store<AutoCompleteStore>;

// TODO navigation keys. (this looks a lot like auto-complete list. Could maybe be factorized)
setup() {
this.autoComplete = useLocalStore(AutoCompleteStore);
this.autoComplete.useProvider(this.getProvider());
useExternalListener(window, "click", (ev) => {
if (ev.target !== this.buttonRef.el) {
this.popover.isOpen = false;
}
});
useAutofocus({ refName: "autofocus" });
}

get filteredFields() {
getProvider(): AutoCompleteProvider {
return {
proposals: this.proposals,
autoSelectFirstProposal: false,
selectProposal: (value) => {
const field = this.props.fields.find((field) => field.string === value);
if (field) {
this.pickField(field);
}
},
};
}

get proposals(): AutoCompleteProposal[] {
let fields: PivotField[];
if (this.search.input) {
return fuzzyLookup(this.search.input, this.props.fields, (field) => field.string);
fields = fuzzyLookup(this.search.input, this.props.fields, (field) => field.string);
} else {
fields = this.props.fields;
}
return this.props.fields;
return fields.map((field) => {
const text = field.string;
return {
text,
fuzzySearchKey: text,
htmlContent: getHtmlContentFromPattern(
this.search.input,
text,
COMPOSER_ASSISTANT_COLOR,
"o-semi-bold"
),
};
});
}

get popoverProps() {
Expand All @@ -65,20 +104,41 @@ export class AddDimensionButton extends Component<Props, SpreadsheetChildEnv> {
};
}

updateSearch(searchInput: string) {
this.search.input = searchInput;
this.autoComplete.useProvider(this.getProvider());
}

pickField(field: PivotField) {
this.props.onFieldPicked(field.name);
this.popover.isOpen = false;
this.search.input = "";
this.togglePopover();
}

togglePopover() {
this.popover.isOpen = !this.popover.isOpen;
this.search.input = "";
this.autoComplete.useProvider(this.getProvider());
}

onKeyDown(ev: KeyboardEvent) {
if (this.filteredFields.length === 1 && ev.key === "Enter") {
this.pickField(this.filteredFields[0]);
switch (ev.key) {
case "Enter":
const proposals = this.autoComplete.provider?.proposals;
if (proposals?.length === 1) {
this.autoComplete.provider?.selectProposal(proposals[0].text || "");
}
const proposal = this.autoComplete.selectedProposal;
this.autoComplete.provider?.selectProposal(proposal?.text || "");
break;
case "ArrowUp":
case "ArrowDown":
this.autoComplete.moveSelection(ev.key === "ArrowDown" ? "next" : "previous");
break;
case "Escape":
this.popover.isOpen = false;
break;
default:
break;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,23 @@
Add
</span>
<Popover t-if="popover.isOpen" t-props="popoverProps">
<div class="p-2 bg-white border-bottom d-flex align-items-baseline pivot-dimension-search">
<div
class="p-2 bg-white border-bottom d-flex sticky-top align-items-baseline pivot-dimension-search">
<i class="pe-1 pivot-dimension-search-field-icon">
<t t-call="o-spreadsheet-Icon.SEARCH"/>
</i>
<input
t-model="search.input"
t-on-input="(ev) => this.updateSearch(ev.target.value)"
t-on-keydown="onKeyDown"
class="border-0 w-100 pivot-dimension-search-field"
autofocus="1"
t-ref="autofocus"
/>
</div>
<div
t-foreach="filteredFields"
t-as="field"
t-key="field.name"
t-esc="field.string"
t-att-title="field.help"
t-on-click="() => this.pickField(field)"
class="p-1 px-2 pivot-dimension-field"
role="button"
<TextValueProvider
proposals="autoComplete.provider.proposals"
selectedIndex="autoComplete.selectedIndex"
onValueSelected="autoComplete.provider.selectProposal"
onValueHovered="() => {}"
/>
</Popover>
</t>
Expand Down
2 changes: 1 addition & 1 deletion src/store_engine/store_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function useStore<T extends StoreConstructor>(Store: T): Store<InstanceTy

export function useLocalStore<T extends LocalStoreConstructor<any>>(
Store: T,
...args: StoreParams<T>
...args: StoreParams<T> extends never ? [] : StoreParams<T>
): Store<InstanceType<T>> {
const env = useEnv();
const container = getDependencyContainer(env);
Expand Down

0 comments on commit aaa7ab5

Please sign in to comment.