[IMP] web: make kanban group header sticky

This commit makes the kanban group header sticky, it means that when
the user scrolls the headers will still be visible on top of the view.

closes odoo/odoo#109060

Task: 2050014
Related: odoo/enterprise#35611
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
Michael (mcm)
2023-01-13 16:27:43 +01:00
parent 0561637aaa
commit 65e43dd881
11 changed files with 144 additions and 45 deletions
@@ -2,7 +2,7 @@
<templates>
<t t-name="crm.CrmKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary" owl="1">
<xpath expr="//ColumnProgress" position="replace">
<CrmColumnProgress group="group" aggregate="getGroupAggregate(group)" progressAttributes="props.list.model.progressAttributes"/>
<CrmColumnProgress group="group" aggregate="getGroupAggregate(group)" onBarClicked="props.scrollTop" progressAttributes="props.list.model.progressAttributes"/>
</xpath>
</t>
</templates>
@@ -122,6 +122,12 @@ export class Dropdown extends Component {
}
},
};
if (this.props.container) {
positioningOptions.container = () =>
typeof this.props.container === "function"
? this.props.container()
: this.props.container;
}
this.state.directionCaretClass = DIRECTION_CARET_CLASS[direction];
this.togglerRef = useRef("togglerRef");
if (this.props.toggler === "parent") {
@@ -390,5 +396,9 @@ Dropdown.props = {
type: Boolean,
optional: true,
},
container: {
type: [Element, Function],
optional: true,
},
};
Dropdown.template = "web.Dropdown";
+5 -1
View File
@@ -11,7 +11,7 @@ import { onWillUnmount, useEffect, useExternalListener, useRef } from "@odoo/owl
/**
* @typedef {{
* popper?: string;
* container?: HTMLElement;
* container?: HTMLElement | (() => HTMLElement);
* margin?: number;
* position?: Direction | Position;
* onPositioned?: PositionEventHandler;
@@ -92,6 +92,10 @@ function getBestPosition(reference, popper, { container, margin, position }) {
variantKey === "fit" ? FIT_FLIP_ORDER[directionKey] : DIRECTION_FLIP_ORDER[directionKey];
const variants = VARIANT_FLIP_ORDER[variantKey];
if (typeof container === "function") {
container = container();
}
// Boxes
const popBox = popper.getBoundingClientRect();
const refBox = reference.getBoundingClientRect();
@@ -35,13 +35,13 @@ export class KanbanController extends Component {
rootState,
});
const rootRef = useRef("root");
useViewButtons(this.model, rootRef, {
this.rootRef = useRef("root");
useViewButtons(this.model, this.rootRef, {
beforeExecuteAction: this.beforeExecuteActionButton.bind(this),
afterExecuteAction: this.afterExecuteActionButton.bind(this),
});
useSetupView({
rootRef,
rootRef: this.rootRef,
getGlobalState: () => {
return {
resIds: this.model.root.records.map((rec) => rec.resId), // WOWL: ask LPE why?
@@ -121,6 +121,10 @@ export class KanbanController extends Component {
async afterExecuteActionButton(clickParams) {}
async onUpdatedPager() {}
scrollTop() {
this.rootRef.el.querySelector(".o_content").scrollTo({ top: 0 });
}
}
KanbanController.template = `web.KanbanView`;
@@ -60,7 +60,7 @@
&:focus,
&:focus-within {
z-index: 1; // show the shadow on top of the previous & next cards in grouped mode
margin-bottom: 0; // show the shadow on top of the previous & next cards in grouped mode
}
// ------- Kanban Record, v11 Layout -------
@@ -511,9 +511,35 @@
background-color: var(--KanbanColumn__highlight-background) !important;
box-shadow: -1px 0px 0px 0px var(--KanbanColumn__highlight-border) inset,
1px 0px 0px 0px var(--KanbanColumn__highlight-border) inset;
.o_kanban_header {
// hack to display correct background color
margin: 0 calc(-1 * var(--KanbanGroup-padding-h) + 1px);
padding: 0 calc(var(--KanbanGroup-padding-h) - 1px);
}
}
}
.o_kanban_group .o_kanban_header,
.o_quick_create_folded {
position: sticky; // keep it visible
z-index: 1;
}
.o_kanban_group:not(.o_column_folded) .o_kanban_header {
background-color: $o-webclient-background-color;
.dropdown-item {
max-width: calc(var(--KanbanRecord-width) * 0.75);
text-overflow: ellipsis;
overflow: hidden;
}
}
.o_quick_create_folded {
top: 1rem;
}
.o_column_quick_create,
.o_kanban_group:not(.o_column_folded) .o_kanban_header_title {
// Makes them come on top of the "no-content" background gradient.
@@ -562,12 +588,6 @@
.o_column_quick_create {
scroll-snap-align: center;
overflow-y: scroll;
.o_kanban_header {
position: sticky; // keep it visible
z-index: 1;
background-color: inherit;
}
}
}
@@ -15,6 +15,7 @@
forceGlobalClick="props.forceGlobalClick"
openRecord.bind="openRecord"
noContentHelp="props.info.noContentHelp"
scrollTop.bind="scrollTop"
/>
</Layout>
@@ -25,6 +25,29 @@ const DRAGGABLE_GROUP_TYPES = ["many2one"];
const MOVABLE_RECORD_TYPES = ["char", "boolean", "integer", "selection", "many2one"];
export class KanbanRenderer extends Component {
static template = "web.KanbanRenderer";
static components = {
Dropdown,
DropdownItem,
ColumnProgress,
KanbanColumnQuickCreate,
KanbanRecord,
KanbanRecordQuickCreate,
};
static props = [
"archInfo",
"Compiler?", // optional in stable for backward compatibility
"list",
"openRecord",
"readonly",
"forceGlobalClick?",
"noContentHelp?",
"scrollTop?",
];
static defaultProps = {
scrollTop: () => {},
};
setup() {
this.dialogClose = [];
this.state = useState({
@@ -40,12 +63,12 @@ export class KanbanRenderer extends Component {
// Sortable
let dataRecordId;
let dataGroupId;
const rootRef = useRef("root");
this.rootRef = useRef("root");
if (this.canUseSortable) {
useSortable({
enable: () => this.canResequenceRecords,
// Params
ref: rootRef,
ref: this.rootRef,
elements: ".o_record_draggable",
ignore: ".dropdown",
groups: () => this.props.list.isGrouped && ".o_kanban_group",
@@ -66,7 +89,7 @@ export class KanbanRenderer extends Component {
useSortable({
enable: () => this.canResequenceGroups,
// Params
ref: rootRef,
ref: this.rootRef,
elements: ".o_group_draggable",
handle: ".o_column_title",
cursor: "move",
@@ -81,7 +104,7 @@ export class KanbanRenderer extends Component {
});
}
useBounceButton(rootRef, (clickedEl) => {
useBounceButton(this.rootRef, (clickedEl) => {
if (!this.props.list.count || this.props.list.model.useSampleModel) {
return clickedEl.matches(
[
@@ -105,7 +128,7 @@ export class KanbanRenderer extends Component {
if (model.useSampleModel || !model.hasData()) {
return;
}
const firstCard = rootRef.el.querySelector(".o_kanban_record");
const firstCard = this.rootRef.el.querySelector(".o_kanban_record");
if (firstCard) {
// Focus first kanban card
firstCard.focus();
@@ -127,10 +150,10 @@ export class KanbanRenderer extends Component {
}
return;
},
{ area: () => rootRef.el }
{ area: () => this.rootRef.el }
);
const arrowsOptions = { area: () => rootRef.el, allowRepeat: true };
const arrowsOptions = { area: () => this.rootRef.el, allowRepeat: true };
if (this.env.searchModel) {
useHotkey(
"ArrowUp",
@@ -260,7 +283,7 @@ export class KanbanRenderer extends Component {
if (!this.env.isSmall && group.isFolded) {
classes.push("o_column_folded");
}
if (!group.isFolded && !group.hasActiveProgressValue) {
if (!group.isFolded) {
classes.push("bg-100");
}
if (group.progressBars.length) {
@@ -447,13 +470,18 @@ export class KanbanRenderer extends Component {
});
}
getGroupEl(groupId) {
return this.rootRef.el.querySelector(`.o_kanban_group[data-id="${groupId}"]`);
}
// ------------------------------------------------------------------------
// Handlers
// ------------------------------------------------------------------------
onGroupClick(group) {
async onGroupClick(group, ev) {
if (!this.env.isSmall && group.isFolded) {
group.toggle();
await group.toggle();
this.props.scrollTop();
}
}
@@ -603,22 +631,3 @@ export class KanbanRenderer extends Component {
};
}
}
KanbanRenderer.props = [
"archInfo",
"Compiler?", // optional in stable for backward compatibility
"list",
"openRecord",
"readonly",
"forceGlobalClick?",
"noContentHelp?",
];
KanbanRenderer.components = {
Dropdown,
DropdownItem,
ColumnProgress,
KanbanColumnQuickCreate,
KanbanRecord,
KanbanRecordQuickCreate,
};
KanbanRenderer.template = "web.KanbanRenderer";
@@ -15,10 +15,10 @@
t-on-click="(ev) => this.onGroupClick(group, ev)"
>
<div class="o_kanban_header top-0" t-attf-class="{{ !env.isSmall and group.isFolded ? 'pt-3' : 'py-3' }}">
<div class="o_kanban_header_title position-relative d-flex align-items-center lh-lg text-900" t-att="tooltipAttributes(group)">
<div class="o_kanban_header_title position-relative d-flex align-items-baseline lh-lg text-900" t-att="tooltipAttributes(group)">
<span class="o_column_title flex-grow-1 d-inline-block mw-100 text-truncate fs-4 fw-bold align-top" t-att-class="{ 'overflow-visible': !env.isSmall and group.isFolded }" t-esc="getGroupName(group)"/>
<t t-if="env.isSmall or !group.isFolded">
<Dropdown class="'o_kanban_config'" togglerClass="'btn p-0 border-0'">
<Dropdown class="'o_kanban_config'" togglerClass="'btn p-0 border-0'" position="'bottom-end'" container="() => this.getGroupEl(group.id)">
<t t-set-slot="toggler">
<i class="fa fa-gear fa-stack d-block text-center text-700 small opacity-50 opacity-100-hover cursor-pointer" role="img" aria-label="Settings" title="Settings" />
</t>
@@ -56,7 +56,7 @@
</button>
</div>
<div t-if="(env.isSmall or !group.isFolded) and group.progressBars.length" class="o_kanban_counter position-relative d-flex align-items-center justify-content-between" t-att-class="{ 'opacity-25': !group.count }">
<ColumnProgress group="group" aggregate="getGroupAggregate(group)" />
<ColumnProgress group="group" aggregate="getGroupAggregate(group)" onBarClicked="props.scrollTop" />
</div>
</div>
<t t-if="!group.isFolded">
@@ -11,5 +11,14 @@ export class ColumnProgress extends Component {
static props = {
aggregate: { type: Object },
group: { type: Object },
onBarClicked: { type: Function, optional: true },
};
static defaultProps = {
onBarClicked: () => {},
};
async onBarClick(progressBar) {
await this.props.group.filterProgressValue(progressBar.value);
this.props.onBarClicked();
}
}
@@ -15,7 +15,7 @@
t-att-aria-valuenow="progressBar.count"
t-attf-data-tooltip="{{ progressBar.count }} {{ progressBar.string }}"
data-tooltip-delay="0"
t-on-click="() => props.group.filterProgressValue(progressBar.value)"
t-on-click="() => this.onBarClick(progressBar)"
/>
</t>
</div>
@@ -12314,4 +12314,46 @@ QUnit.module("Views", (hooks) => {
);
assert.containsN(target, ".o_kanban_group .o_kanban_record", 16);
});
QUnit.test("scroll on group unfold and progressbar click", async (assert) => {
assert.expect(7);
await makeView({
type: "kanban",
resModel: "partner",
serverData,
arch: `
<kanban>
<progressbar field="foo" colors='{"yop": "success", "gnap": "warning", "blip": "danger"}' sum_field="int_field" />
<templates>
<t t-name="kanban-box">Record</t>
</templates>
</kanban>`,
groupBy: ["product_id"],
async mockRPC(route, args, performRPC) {
if (args.method === "web_read_group") {
const result = await performRPC(route, args);
if (result.groups.length) {
result.groups[0].__fold = false;
result.groups[1].__fold = true;
}
return result;
}
},
});
const content = target.querySelector(".o_content");
content.scrollTo = (params) => {
assert.step("scrolled");
assert.strictEqual(params.top, 0);
};
await click(getProgressBars(0)[0]);
assert.verifySteps(["scrolled"]);
const column1 = getColumn(1);
assert.hasClass(column1, "o_column_folded");
await click(column1);
assert.verifySteps(["scrolled"]);
});
});