[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:
@@ -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";
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user