Tip: Schedule activities to keep track of everything you have to do!")),
+ tooltipPosition: "bottom",
+ run: "click",
+},
+{
+ trigger: ".o_opportunity_kanban .o_kanban_renderer",
+},
+{
+ trigger: ".o-mail-ActivityListPopover button:contains(Schedule an activity)",
+ content: markup(_t("Let's Schedule an Activity.")),
+ tooltipPosition: "bottom",
+ run: "click",
+}, {
+ trigger: '.modal-footer button[name="action_schedule_activities"]',
+ content: markup(_t("All set. Let’s Schedule it.")),
+ tooltipPosition: "top", // dot NOT move to bottom, it would cause a resize flicker, see task-2476595
+ run: "click",
+}, {
+ id: "drag_opportunity_to_won_step",
+ trigger: ".o_opportunity_kanban .o_kanban_record:last-of-type",
+ content: markup(_t("Drag your opportunity to Won when you get the deal. Congrats!")),
+ tooltipPosition: "right",
+ run: "drag_and_drop(.o_opportunity_kanban .o_kanban_group:eq(3))",
+},
+{
+ trigger: ".o_kanban_record",
+ content: _t("Let’s have a look at an Opportunity."),
+ tooltipPosition: "right",
+ run: "click",
+}, {
+ trigger: ".o_lead_opportunity_form .o_statusbar_status",
+ content: _t("You can make your opportunity advance through your pipeline from here."),
+ tooltipPosition: "bottom",
+ run: "click",
+}, {
+ trigger: ".breadcrumb-item:not(.active):first",
+ content: _t("Click on the breadcrumb to go back to your Pipeline. Odoo will save all modifications as you navigate."),
+ tooltipPosition: "bottom",
+ run: "click .breadcrumb-item:not(.active):last",
+}]});
diff --git a/frontend/crm/static/src/scss/crm.scss b/frontend/crm/static/src/scss/crm.scss
new file mode 100644
index 0000000..ecacc8c
--- /dev/null
+++ b/frontend/crm/static/src/scss/crm.scss
@@ -0,0 +1,16 @@
+.crm_lead_merge_summary blockquote {
+ font-style: normal;
+}
+
+.crm_quick_create_opportunity_form_group {
+
+ padding-bottom: 0.5rem;
+
+ i.fa {
+ text-align: center;
+ min-width: 2rem;
+ }
+ .crm_revenues .o_row {
+ margin-left: 1.75rem;
+ }
+}
diff --git a/frontend/crm/static/src/scss/crm_team.scss b/frontend/crm/static/src/scss/crm_team.scss
new file mode 100644
index 0000000..190dad6
--- /dev/null
+++ b/frontend/crm/static/src/scss/crm_team.scss
@@ -0,0 +1,11 @@
+.o_crm_team_form_view {
+ /*
+ * Used to conditionnaly add a css class on a record field.
+ * Add text-warning on the element with class o_crm_lead_month_assignment
+ * only if the element with o_crm_lead_all_assigned_month_exceeded is visible
+ * in the template.
+ */
+ div.o_crm_lead_all_assigned_month_exceeded + .o_crm_lead_month_assignment {
+ color: $warning;
+ }
+}
diff --git a/frontend/crm/static/src/scss/crm_team_member_views.scss b/frontend/crm/static/src/scss/crm_team_member_views.scss
new file mode 100644
index 0000000..83fbab8
--- /dev/null
+++ b/frontend/crm/static/src/scss/crm_team_member_views.scss
@@ -0,0 +1,6 @@
+.o_crm_team_member_kanban .o_kanban_renderer {
+ .o_member_assignment div.oe_gauge {
+ width: 100px;
+ height: 80px;
+ }
+}
diff --git a/frontend/crm/static/src/views/check_rainbowman_message.js b/frontend/crm/static/src/views/check_rainbowman_message.js
new file mode 100644
index 0000000..1add7ab
--- /dev/null
+++ b/frontend/crm/static/src/views/check_rainbowman_message.js
@@ -0,0 +1,9 @@
+export async function checkRainbowmanMessage(orm, effect, recordId) {
+ const message = await orm.call("crm.lead", "get_rainbowman_message", [[recordId]]);
+ if (message) {
+ effect.add({
+ message,
+ type: "rainbow_man",
+ });
+ }
+}
diff --git a/frontend/crm/static/src/views/crm_form/crm_form.js b/frontend/crm/static/src/views/crm_form/crm_form.js
new file mode 100644
index 0000000..139e86c
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_form/crm_form.js
@@ -0,0 +1,70 @@
+import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
+import { registry } from "@web/core/registry";
+import { formView } from "@web/views/form/form_view";
+
+class CrmFormRecord extends formView.Model.Record {
+ /**
+ * override of record _save mechanism intended to affect the main form record
+ * We check if the stage_id field was altered and if we need to display a rainbowman
+ * message.
+ *
+ * This method will also simulate a real "force_save" on the email and phone
+ * when needed. The "force_save" attribute only works on readonly field. For our
+ * use case, we need to write the email and the phone even if the user didn't
+ * change them, to synchronize those values with the partner (so the email / phone
+ * inverse method can be called).
+ *
+ * We base this synchronization on the value of "partner_phone_update"
+ * and "partner_email_update", which are computed fields that hold a value
+ * whenever we need to synch.
+ *
+ * @override
+ */
+ async _save() {
+ if (this.resModel !== "crm.lead") {
+ return super._save(...arguments);
+ }
+ let changeStage = false;
+ const needsSynchronizationEmail =
+ this._changes.partner_email_update === undefined
+ ? this._values.partner_email_update // original value
+ : this._changes.partner_email_update; // new value
+
+ const needsSynchronizationPhone =
+ this._changes.partner_phone_update === undefined
+ ? this._values.partner_phone_update // original value
+ : this._changes.partner_phone_update; // new value
+
+ if (needsSynchronizationEmail && this._changes.email_from === undefined && this._values.email_from) {
+ this._changes.email_from = this._values.email_from;
+ }
+ if (needsSynchronizationPhone && this._changes.phone === undefined && this._values.phone) {
+ this._changes.phone = this._values.phone;
+ }
+
+ if ("stage_id" in this._changes) {
+ changeStage = this._values.stage_id !== this.data.stage_id;
+ }
+
+ const res = await super._save(...arguments);
+ if (changeStage) {
+ await checkRainbowmanMessage(this.model.orm, this.model.effect, this.resId);
+ }
+ return res;
+ }
+}
+
+class CrmFormModel extends formView.Model {
+ static Record = CrmFormRecord;
+ static services = [...formView.Model.services, "effect"];
+
+ setup(params, services) {
+ super.setup(...arguments);
+ this.effect = services.effect;
+ }
+}
+
+registry.category("views").add("crm_form", {
+ ...formView,
+ Model: CrmFormModel,
+});
diff --git a/frontend/crm/static/src/views/crm_form/crm_form.scss b/frontend/crm/static/src/views/crm_form/crm_form.scss
new file mode 100644
index 0000000..2cc0916
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_form/crm_form.scss
@@ -0,0 +1,5 @@
+.o_lead_opportunity_form {
+ .o_lead_opportunity_form_AI_switch_img {
+ height: 1rem;
+ }
+}
diff --git a/frontend/crm/static/src/views/crm_form/crm_pls_tooltip_button.js b/frontend/crm/static/src/views/crm_form/crm_pls_tooltip_button.js
new file mode 100644
index 0000000..77573b0
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_form/crm_pls_tooltip_button.js
@@ -0,0 +1,76 @@
+import { Component, status } from "@odoo/owl";
+import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
+import { localization } from "@web/core/l10n/localization";
+import { registry } from '@web/core/registry';
+import { usePopover } from "@web/core/popover/popover_hook";
+import { useService } from "@web/core/utils/hooks";
+
+
+export class CrmPlsTooltip extends Component {
+ static props = {
+ close: { optional: true, type: Function },
+ dashArrayVals: {type: String},
+ low3Data: { optional: true, type: Object },
+ probability: { type: Number },
+ teamName: { optional: true, type: String },
+ top3Data: { optional: true, type: Object },
+ };
+ static template = "crm.PlsTooltip";
+}
+
+
+export class CrmPlsTooltipButton extends Component {
+ static template = "crm.PlsTooltipButton";
+ static props = {...standardWidgetProps};
+
+ setup() {
+ super.setup();
+ this.orm = useService("orm");
+ this.popover = usePopover(CrmPlsTooltip, {
+ popoverClass: 'mt-2 me-2',
+ position: "bottom-start",
+ });
+ }
+
+ async onClickPlsTooltipButton(ev) {
+ const tooltipButtonEl = ev.currentTarget;
+ if (this.popover.isOpen) {
+ this.popover.close();
+ } else {
+ // Apply pending changes. They may change probability
+ await this.props.record.save();
+ if (status(this) === "destroyed" || !this.props.record.resId) {
+ return;
+ }
+
+ // This recomputes probability, and returns all tooltip data
+ const tooltipData = await this.orm.call(
+ "crm.lead",
+ "prepare_pls_tooltip_data",
+ [this.props.record.resId]
+ );
+ // Update the form
+ await this.props.record.load();
+
+ // Hard set wheel dimensions, see o_crm_pls_tooltip_wheel in scss and xml
+ const progressWheelPerimeter = 2 * Math.PI * 25;
+ const progressBarDashLength = progressWheelPerimeter * tooltipData.probability / 100.0;
+ const progressBarDashGap = progressWheelPerimeter - progressBarDashLength;
+ let dashArrayVals = progressBarDashLength + ' ' + progressBarDashGap;
+ if (localization.direction === "rtl") {
+ dashArrayVals = 0 + ' ' + 0.5 * progressWheelPerimeter + ' ' + dashArrayVals;
+ }
+ this.popover.open(tooltipButtonEl, {
+ 'dashArrayVals': dashArrayVals,
+ 'low3Data': tooltipData.low_3_data,
+ 'probability': tooltipData.probability,
+ 'teamName': tooltipData.team_name,
+ 'top3Data': tooltipData.top_3_data,
+ });
+ }
+ }
+}
+
+registry.category("view_widgets").add("pls_tooltip_button", {
+ component: CrmPlsTooltipButton
+});
diff --git a/frontend/crm/static/src/views/crm_form/crm_pls_tooltip_button.scss b/frontend/crm/static/src/views/crm_form/crm_pls_tooltip_button.scss
new file mode 100644
index 0000000..8b42156
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_form/crm_pls_tooltip_button.scss
@@ -0,0 +1,33 @@
+.o_crm_pls_tooltip {
+ .o_crm_pls_tooltip_wheel {
+ height: 60px;
+ min-width: 60px;
+ font-size: 11px;
+ circle {
+ transform: rotate(-90deg);
+ transform-origin: 30px 30px;
+ stroke: $link-color;
+ &.o_crm_tooltip_wheel_bg_circle {
+ stroke: #ddd;
+ }
+ }
+ > * {
+ height: 60px;
+ width: 60px;
+ }
+ }
+ .o_crm_pls_tooltip_section_sample {
+ opacity: 0.5;
+ }
+
+ .o_crm_pls_tooltip_icon {
+ min-width: 15px;
+ }
+ .o_crm_pls_tooltip_footer {
+ font-size: 11px;
+ }
+}
+
+.o_crm_pls_tooltip_button img {
+ height: 1rem;
+}
diff --git a/frontend/crm/static/src/views/crm_form/crm_pls_tooltip_button.xml b/frontend/crm/static/src/views/crm_form/crm_pls_tooltip_button.xml
new file mode 100644
index 0000000..9722294
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_form/crm_pls_tooltip_button.xml
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %
+
+
+
+
+
sample data
+
AI-computed chances of winning for
+
AI-computed chances of winning
+
+
+ this lead
+
+
+
+
+
+ Top Positives
+ (sample data)
+
+
+
+
+
+
+
+
+
+
+ Top Negatives
+ (sample data)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stage
+
+
+ Tagged as
+
+
+
+
+
+ Located in
+
+
+
+
+ Has a
+ valid phone number
+
+
+ Does not have a
+ valid phone number
+
+
+ Does not have a
+ phone number
+
+
+
+
+ Has a
+ valid email address
+
+
+ Does not have a
+ valid email address
+
+
+ Does not have an
+ email address
+
+
+
+ Source is
+
+
+
+ Language is
+
+
+
+ Field
+ has value
+
+
+
+
+ Historic win rate
+
+ (team)
+
+
+
+
+
diff --git a/frontend/crm/static/src/views/crm_kanban/crm_column_progress.js b/frontend/crm/static/src/views/crm_kanban/crm_column_progress.js
new file mode 100644
index 0000000..3714663
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_kanban/crm_column_progress.js
@@ -0,0 +1,25 @@
+import { onWillStart } from "@odoo/owl";
+import { user } from "@web/core/user";
+import { RottingColumnProgress } from "@mail/js/rotting_mixin/rotting_column_progress";
+
+export class CrmColumnProgress extends RottingColumnProgress {
+ static template = "crm.ColumnProgress";
+ setup() {
+ super.setup();
+ this.showRecurringRevenue = false;
+
+ onWillStart(async () => {
+ if (this.props.progressBarState.progressAttributes.recurring_revenue_sum_field) {
+ this.showRecurringRevenue = await user.hasGroup("crm.group_use_recurring_revenues");
+ }
+ });
+ }
+
+ getRecurringRevenueGroupAggregate(group) {
+ if (!this.showRecurringRevenue) {
+ return {};
+ }
+ const rrField = this.props.progressBarState.progressAttributes.recurring_revenue_sum_field;
+ return this.props.progressBarState.getAggregateValue(group, rrField);
+ }
+}
diff --git a/frontend/crm/static/src/views/crm_kanban/crm_column_progress.xml b/frontend/crm/static/src/views/crm_kanban/crm_column_progress.xml
new file mode 100644
index 0000000..2267f42
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_kanban/crm_column_progress.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ MRR
+
+
+
+
diff --git a/frontend/crm/static/src/views/crm_kanban/crm_kanban_arch_parser.js b/frontend/crm/static/src/views/crm_kanban/crm_kanban_arch_parser.js
new file mode 100644
index 0000000..4362c24
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_kanban/crm_kanban_arch_parser.js
@@ -0,0 +1,14 @@
+import { KanbanArchParser } from "@web/views/kanban/kanban_arch_parser";
+import { extractAttributes } from "@web/core/utils/xml";
+
+export class CrmKanbanArchParser extends KanbanArchParser {
+ /**
+ * @override
+ */
+ parseProgressBar(progressBar, fields) {
+ const result = super.parseProgressBar(...arguments);
+ const attrs = extractAttributes(progressBar, ["recurring_revenue_sum_field"]);
+ result.recurring_revenue_sum_field = fields[attrs.recurring_revenue_sum_field] || false;
+ return result;
+ }
+}
diff --git a/frontend/crm/static/src/views/crm_kanban/crm_kanban_model.js b/frontend/crm/static/src/views/crm_kanban/crm_kanban_model.js
new file mode 100644
index 0000000..8b5ce1c
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_kanban/crm_kanban_model.js
@@ -0,0 +1,35 @@
+import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message";
+import { RelationalModel } from "@web/model/relational_model/relational_model";
+
+export class CrmKanbanModel extends RelationalModel {
+ setup(params, { effect }) {
+ super.setup(...arguments);
+ this.effect = effect;
+ }
+}
+
+export class CrmKanbanDynamicGroupList extends RelationalModel.DynamicGroupList {
+ /**
+ * @override
+ *
+ * If the kanban view is grouped by stage_id check if the lead is won and display
+ * a rainbowman message if that's the case.
+ */
+ async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
+ await super.moveRecord(...arguments);
+ const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
+ const targetGroup = this.groups.find((g) => g.id === targetGroupId);
+ if (
+ dataGroupId !== targetGroupId &&
+ sourceGroup &&
+ targetGroup &&
+ sourceGroup.groupByField.name === "stage_id"
+ ) {
+ const record = targetGroup.list.records.find((r) => r.id === dataRecordId);
+ await checkRainbowmanMessage(this.model.orm, this.model.effect, record.resId);
+ }
+ }
+}
+
+CrmKanbanModel.DynamicGroupList = CrmKanbanDynamicGroupList;
+CrmKanbanModel.services = [...RelationalModel.services, "effect"];
diff --git a/frontend/crm/static/src/views/crm_kanban/crm_kanban_renderer.js b/frontend/crm/static/src/views/crm_kanban/crm_kanban_renderer.js
new file mode 100644
index 0000000..fcc0d59
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_kanban/crm_kanban_renderer.js
@@ -0,0 +1,19 @@
+import { CrmColumnProgress } from "./crm_column_progress";
+import { RottingKanbanRecord } from "@mail/js/rotting_mixin/rotting_kanban_record";
+import { RottingKanbanHeader } from "@mail/js/rotting_mixin/rotting_kanban_header";
+import { RottingKanbanRenderer } from "@mail/js/rotting_mixin/rotting_kanban_renderer";
+
+class CrmKanbanHeader extends RottingKanbanHeader {
+ static components = {
+ ...RottingKanbanHeader.components,
+ ColumnProgress: CrmColumnProgress,
+ };
+}
+
+export class CrmKanbanRenderer extends RottingKanbanRenderer {
+ static components = {
+ ...RottingKanbanRenderer.components,
+ KanbanHeader: CrmKanbanHeader,
+ KanbanRecord: RottingKanbanRecord,
+ };
+}
diff --git a/frontend/crm/static/src/views/crm_kanban/crm_kanban_view.js b/frontend/crm/static/src/views/crm_kanban/crm_kanban_view.js
new file mode 100644
index 0000000..ceb8be7
--- /dev/null
+++ b/frontend/crm/static/src/views/crm_kanban/crm_kanban_view.js
@@ -0,0 +1,25 @@
+import { registry } from "@web/core/registry";
+import { CrmKanbanModel } from "@crm/views/crm_kanban/crm_kanban_model";
+import { CrmKanbanArchParser } from "@crm/views/crm_kanban/crm_kanban_arch_parser";
+import { CrmKanbanRenderer } from "@crm/views/crm_kanban/crm_kanban_renderer";
+import { rottingKanbanView } from "@mail/js/rotting_mixin/rotting_kanban_view";
+
+export const crmKanbanView = {
+ ...rottingKanbanView,
+ ArchParser: CrmKanbanArchParser,
+ // Makes it easier to patch
+ Controller: class extends rottingKanbanView.Controller {
+ get progressBarAggregateFields() {
+ const res = super.progressBarAggregateFields;
+ const progressAttributes = this.props.archInfo.progressAttributes;
+ if (progressAttributes && progressAttributes.recurring_revenue_sum_field) {
+ res.push(progressAttributes.recurring_revenue_sum_field);
+ }
+ return res;
+ }
+ },
+ Model: CrmKanbanModel,
+ Renderer: CrmKanbanRenderer,
+};
+
+registry.category("views").add("crm_kanban", crmKanbanView);
diff --git a/frontend/crm/static/src/views/fill_temporal_service.js b/frontend/crm/static/src/views/fill_temporal_service.js
new file mode 100644
index 0000000..ef1726d
--- /dev/null
+++ b/frontend/crm/static/src/views/fill_temporal_service.js
@@ -0,0 +1,304 @@
+import { registry } from "@web/core/registry";
+import {
+ serializeDate,
+ serializeDateTime,
+} from "@web/core/l10n/dates";
+
+/**
+ * Configuration depending on the granularity, using Luxon DateTime objects:
+ * @param {function} startOf function to get a DateTime at the beginning of a period
+ * from another DateTime.
+ * @param {int} cycle amount of 'granularity' periods constituting a cycle. The cycle duration
+ * is arbitrary for each granularity:
+ * cycle --- granularity
+ * ___________________________
+ * 1 day hour
+ * 1 week day
+ * 1 week week # there is no greater time period that takes an integer amount of weeks
+ * 1 year month
+ * 1 year quarter
+ * 1 year year # we are not using a greater time period in Odoo (yet)
+ * @param {int} cyclePos function to get the position (index) in the cycle from a DateTime.
+ * {1} is the first index. {+1} is used for properties which have an index
+ * starting from 0, to standardize between granularities.
+ */
+export const GRANULARITY_TABLE = {
+ hour: {
+ startOf: (x) => x.startOf("hour"),
+ cycle: 24,
+ cyclePos: (x) => x.hour + 1,
+ },
+ day: {
+ startOf: (x) => x.startOf("day"),
+ cycle: 7,
+ cyclePos: (x) => x.weekday,
+ },
+ week: {
+ startOf: (x) => x.startOf("week"),
+ cycle: 1,
+ cyclePos: (x) => 1,
+ },
+ month: {
+ startOf: (x) => x.startOf("month"),
+ cycle: 12,
+ cyclePos: (x) => x.month,
+ },
+ quarter: {
+ startOf: (x) => x.startOf("quarter"),
+ cycle: 4,
+ cyclePos: (x) => x.quarter,
+ },
+ year: {
+ startOf: (x) => x.startOf("year"),
+ cycle: 1,
+ cyclePos: (x) => 1,
+ },
+};
+
+/**
+ * fill_temporal period:
+ * Represents a specific date/time range for a specific model, field and granularity.
+ *
+ * It is used to add new domain and context constraints related to a specific date/time
+ * field, in order to configure the _read_group_fill_temporal (see core models.py)
+ * method. It will be used when we want to get continuous groups in chronological
+ * order in a specific date/time range.
+ */
+export class FillTemporalPeriod {
+ /**
+ * This constructor is meant to be used only by the FillTemporalService (see below)
+ *
+ * @param {string} modelName directly taken from model.loadParams.modelName.
+ * this is the `res_model` from the action (i.e. `crm.lead`)
+ * @param {Object} field a dictionary with keys "name" and "type".
+ * name: Name of the field on which the fill_temporal should apply
+ * (i.e. 'date_deadline')
+ * type: 'date' or 'datetime'
+ * @param {string} granularity can either be : hour, day, week, month, quarter, year
+ * @param {integer} minGroups minimum amount of groups to display, regardless of other
+ * constraints
+ */
+ constructor(modelName, field, granularity, minGroups) {
+ this.modelName = modelName;
+ this.field = field;
+ this.granularity = granularity || "month";
+ this.setMinGroups(minGroups);
+
+ this._computeStart();
+ this._computeEnd();
+ }
+ /**
+ * Compute this.start: the DateTime for the start of the period containing
+ * the current time ("now").
+ * i.e. 2020-10-01 13:43:17 -> the current "hour" DateTime started at:
+ * 2020-10-01 13:00:00
+ *
+ * @private
+ */
+ _computeStart() {
+ this.start = GRANULARITY_TABLE[this.granularity].startOf(luxon.DateTime.now());
+ }
+ /**
+ * Compute this.end: the DateTime for the end of the fill_temporal period.
+ * This bound is exclusive.
+ * The fill_temporal period is the number of [granularity] from [start] to the end of the
+ * [cycle] reached after adding [minGroups]
+ * i.e. we are in october 2020 :
+ * [start] = 2020-10-01
+ * [granularity] = 'month',
+ * [cycle] = 12
+ * [minGroups] = 4,
+ * => fillTemporalPeriod = 15 months (until end of december 2021)
+ *
+ * @private
+ */
+ _computeEnd() {
+ const cycle = GRANULARITY_TABLE[this.granularity].cycle;
+ const cyclePos = GRANULARITY_TABLE[this.granularity].cyclePos(this.start);
+ // fillTemporalPeriod formula explanation :
+ // We want to know how many steps need to be taken from the current position until the end
+ // of the cycle reached after guaranteeing minGroups positions. Let's call this cycle (C).
+ //
+ // (1) compute the steps needed to reach the last position of the current cycle, from the
+ // current position:
+ // {cycle - cyclePos}
+ //
+ // (2) ignore {minGroups - 1} steps from the position reached in (1). Now, the current
+ // position is somewhere in (C). One step from minGroups is reserved to reach the first
+ // position after (C), hence {-1}
+ //
+ // (3) compute the additional steps needed to reach the last position of (C), from the
+ // position reached in (2):
+ // {cycle - (minGroups - 1) % cycle}
+ //
+ // (4) combine (1) and (3), the sum should not be greater than a full cycle (-> truncate):
+ // {(2 * cycle - (minGroups - 1) % cycle - cyclePos) % cycle}
+ //
+ // (5) add minGroups!
+ const fillTemporalPeriod = ((2 * cycle - ((this.minGroups - 1) % cycle) - cyclePos) % cycle) + this.minGroups;
+ this.end = this.start.plus({[`${this.granularity}s`]: fillTemporalPeriod});
+ this.computedEnd = true;
+ }
+ /**
+ * The server needs a date/time in UTC, but we don't want a day shift in case
+ * of dates, even if the date is not in UTC
+ *
+ * @param {DateTime} bound the DateTime to be formatted (this.start or this.end)
+ */
+ _getFormattedServerDate(bound) {
+ if (this.field.type === "date") {
+ return serializeDate(bound);
+ } else {
+ return serializeDateTime(bound);
+ }
+ }
+ /**
+ * @param {Object} configuration
+ * @param {Array[]} [domain]
+ * @param {boolean} [forceStartBound=true] whether this.start DateTime must be used as a domain
+ * constraint to limit read_group results or not
+ * @param {boolean} [forceEndBound=true] whether this.end DateTime must be used as a domain
+ * constraint to limit read_group results or not
+ * @returns {Array[]} new domain
+ */
+ getDomain({ domain, forceStartBound = true, forceEndBound = true }) {
+ if (!forceEndBound && !forceStartBound) {
+ return domain;
+ }
+ const originalDomain = domain.length ? ["&", ...domain] : [];
+ const defaultDomain = ["|", [this.field.name, "=", false]];
+ const linkDomain = forceStartBound && forceEndBound ? ["&"] : [];
+ const startDomain = !forceStartBound ? [] : [[this.field.name, ">=", this._getFormattedServerDate(this.start)]];
+ const endDomain = !forceEndBound ? [] : [[this.field.name, "<", this._getFormattedServerDate(this.end)]];
+ return [...originalDomain, ...defaultDomain, ...linkDomain, ...startDomain, ...endDomain];
+ }
+ /**
+ * The default value of forceFillingTo is false when this.end is the
+ * computed one, and true when it is manually set. This is because the default value of
+ * this.end is computed without any knowledge of the existing data, and as such, we only
+ * want to get continuous groups until the last group with data (no need to force until
+ * this.end). On the contrary, when we set this.end, this means that we want groups until
+ * that date.
+ *
+ * @param {Object} configuration
+ * @param {Object} [context]
+ * @param {boolean} [forceFillingFrom=true] fill_temporal must apply from:
+ * true: this.start
+ * false: the first group with at least one record
+ * @param {boolean} [forceFillingTo=!this.computedEnd] fill_temporal must apply until:
+ * true: this.end
+ * false: the last group with at least one record
+ * @returns {Object} new context
+ */
+ getContext({ context, forceFillingFrom = true, forceFillingTo = !this.computedEnd }) {
+ const fillTemporal = {
+ min_groups: this.minGroups,
+ };
+ if (forceFillingFrom) {
+ fillTemporal.fill_from = this._getFormattedServerDate(this.start);
+ }
+ if (forceFillingTo) {
+ // smallest time interval used in Odoo for the current date type
+ const minGranularity = this.field.type === "date" ? "days" : "seconds";
+ fillTemporal.fill_to = this._getFormattedServerDate(this.end.minus({[minGranularity]: 1}));
+ }
+ context = { ...context, fill_temporal: fillTemporal };
+ return context;
+ }
+ /**
+ * @param {integer} minGroups minimum amount of groups to display, regardless of other
+ * constraints
+ */
+ setMinGroups(minGroups) {
+ this.minGroups = minGroups || 1;
+ }
+ /**
+ * sets the end of the period to the desired DateTime. It must be greater
+ * than start. Changes the default behavior of getContext forceFillingTo
+ * (becomes true instead of false)
+ *
+ * @param {DateTime} end
+ */
+ setEnd(end) {
+ this.end = luxon.DateTime.max(this.start, end);
+ this.computedEnd = false;
+ }
+ /**
+ * sets the start of the period to the desired DateTime. It must be smaller than end
+ *
+ * @param {DateTime} start
+ */
+ setStart(start) {
+ this.start = luxon.DateTime.min(this.end, start);
+ }
+ /**
+ * Adds one "granularity" period to [this.end], to expand the current fill_temporal period
+ */
+ expand() {
+ this.setEnd(this.end.plus({[`${this.granularity}s`]: 1}));
+ }
+}
+
+/**
+ * fill_temporal Service
+ *
+ * This service will be used to generate or retrieve fill_temporal periods
+ *
+ * A specific fill_temporal period configuration will always refer to the same instance
+ * unless forceRecompute is true
+ */
+export const fillTemporalService = {
+ start() {
+ const _fillTemporalPeriods = {};
+
+ /**
+ * Get a fill_temporal period according to the configuration.
+ * The default initial fill_temporal period is the number of [granularity] from [start]
+ * to the end of the [cycle] reached after adding [minGroups]
+ * i.e. we are in october 2020 :
+ * [start] = 2020-10-01
+ * [granularity] = 'month',
+ * [cycle] = 12 (one year)
+ * [minGroups] = 4,
+ * => fillTemporalPeriod = 15 months (until the end of december 2021)
+ * Once created, a fill_temporal period for a specific configuration will be stored
+ * until requested again. This allows to manipulate the period and store the changes
+ * to it. This also allows to keep the configuration when switching to another view
+ *
+ * @param {Object} configuration
+ * @param {string} [modelName] directly taken from model.loadParams.modelName.
+ * this is the `res_model` from the action (i.e. `crm.lead`)
+ * @param {Object} [field] a dictionary with keys "name" and "type".
+ * @param {string} [field.name] name of the field on which the fill_temporal should apply
+ * (i.e. 'date_deadline')
+ * @param {string} [field.type] date field type: 'date' or 'datetime'
+ * @param {string} [granularity] can either be : hour, day, week, month, quarter, year
+ * @param {integer} [minGroups=4] optional minimal amount of desired groups
+ * @param {boolean} [forceRecompute=false] optional whether the fill_temporal period should be
+ * reinstancied
+ * @returns {FillTemporalPeriod}
+ */
+ const getFillTemporalPeriod = ({ modelName, field, granularity, minGroups = 4, forceRecompute = false }) => {
+ if (!(modelName in _fillTemporalPeriods)) {
+ _fillTemporalPeriods[modelName] = {};
+ }
+ if (!(field.name in _fillTemporalPeriods[modelName])) {
+ _fillTemporalPeriods[modelName][field.name] = {};
+ }
+ if (!(granularity in _fillTemporalPeriods[modelName][field.name]) || forceRecompute) {
+ _fillTemporalPeriods[modelName][field.name][granularity] = new FillTemporalPeriod(
+ modelName,
+ field,
+ granularity,
+ minGroups
+ );
+ } else if (_fillTemporalPeriods[modelName][field.name][granularity].minGroups != minGroups) {
+ _fillTemporalPeriods[modelName][field.name][granularity].setMinGroups(minGroups);
+ }
+ return _fillTemporalPeriods[modelName][field.name][granularity];
+ };
+ return { getFillTemporalPeriod };
+ },
+};
+
+registry.category("services").add("fillTemporalService", fillTemporalService);
diff --git a/frontend/crm/static/src/views/forecast_graph/forecast_graph_view.js b/frontend/crm/static/src/views/forecast_graph/forecast_graph_view.js
new file mode 100644
index 0000000..f51386f
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_graph/forecast_graph_view.js
@@ -0,0 +1,10 @@
+import { registry } from "@web/core/registry";
+import { graphView } from "@web/views/graph/graph_view";
+import { ForecastSearchModel } from "@crm/views/forecast_search_model";
+
+export const forecastGraphView = {
+ ...graphView,
+ SearchModel: ForecastSearchModel,
+};
+
+registry.category("views").add("forecast_graph", forecastGraphView);
diff --git a/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_column_quick_create.js b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_column_quick_create.js
new file mode 100644
index 0000000..4f1875b
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_column_quick_create.js
@@ -0,0 +1,22 @@
+import { _t } from "@web/core/l10n/translation";
+import { INTERVAL_OPTIONS } from "@web/search/utils/dates";
+import { KanbanColumnQuickCreate } from "@web/views/kanban/kanban_column_quick_create";
+
+export class ForecastKanbanColumnQuickCreate extends KanbanColumnQuickCreate {
+ /**
+ * @override
+ */
+ get relatedFieldName() {
+ const { granularity = "month" } = this.props.groupByField;
+ const { description } = INTERVAL_OPTIONS[granularity];
+ return _t("next %s", description.toLocaleLowerCase());
+ }
+ /**
+ * @override
+ *
+ * Create column directly upon "unfolding" quick create.
+ */
+ unfold() {
+ this.props.onValidate();
+ }
+}
diff --git a/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_controller.js b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_controller.js
new file mode 100644
index 0000000..89bee3d
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_controller.js
@@ -0,0 +1,7 @@
+import { crmKanbanView } from "@crm/views/crm_kanban/crm_kanban_view";
+
+export class ForecastKanbanController extends crmKanbanView.Controller {
+ isQuickCreateField(field) {
+ return super.isQuickCreateField(...arguments) || (field && field.name === "date_deadline");
+ }
+}
diff --git a/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_model.js b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_model.js
new file mode 100644
index 0000000..6a5e2e7
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_model.js
@@ -0,0 +1,78 @@
+import { CrmKanbanModel } from "@crm/views/crm_kanban/crm_kanban_model";
+
+export class ForecastKanbanModel extends CrmKanbanModel {
+ setup(params, { fillTemporalService }) {
+ super.setup(...arguments);
+ this.fillTemporalService = fillTemporalService;
+ this.forceNextRecompute = !params.state?.groups;
+ this.originalDomain = null;
+ this.fillTemporalDomain = null;
+ }
+
+ async _webReadGroup(config) {
+ if (this.isForecastGroupBy(config)) {
+ config.context = this.fillTemporalPeriod(config).getContext({
+ context: config.context,
+ });
+ // Domain leaves added by the fillTemporalPeriod should be replaced
+ // between 2 _webReadGroup calls, not added on top of each other.
+ // Keep track of the modified domain, and if encountered in the
+ // future, modify the original domain instead. It is not robust
+ // against external modification of `config.domain`, but currently
+ // there are only replacements except this case.
+ if (!this.originalDomain || this.fillTemporalDomain !== config.domain) {
+ this.originalDomain = config.domain || [];
+ }
+ this.fillTemporalDomain = this.fillTemporalPeriod(config).getDomain({
+ domain: this.originalDomain,
+ forceStartBound: false,
+ });
+ config.domain = this.fillTemporalDomain;
+ }
+ return super._webReadGroup(...arguments);
+ }
+
+ async _loadGroupedList(config) {
+ const res = await super._loadGroupedList(...arguments);
+ if (this.isForecastGroupBy(config)) {
+ const lastGroup = res.groups.filter((grp) => grp.value).slice(-1)[0];
+ if (lastGroup) {
+ this.fillTemporalPeriod(config).setEnd(lastGroup.range.to);
+ }
+ }
+ return res;
+ }
+
+ /**
+ * @returns {Boolean} true if the view is grouped by the forecast_field
+ */
+ isForecastGroupBy(config) {
+ const forecastField = config.context.forecast_field;
+ const name = config.groupBy[0].split(":")[0];
+ return forecastField && forecastField === name;
+ }
+
+ /**
+ * return {FillTemporalPeriod} current fillTemporalPeriod according to group by state
+ */
+ fillTemporalPeriod(config) {
+ const [groupByFieldName, granularity] = config.groupBy[0].split(":");
+ const groupByField = config.fields[groupByFieldName];
+ const minGroups = (config.context.fill_temporal && config.context.fill_temporal.min_groups) || undefined;
+ const { name, type } = groupByField;
+ const forceRecompute = this.forceNextRecompute;
+ this.forceNextRecompute = false;
+ return this.fillTemporalService.getFillTemporalPeriod({
+ modelName: config.resModel,
+ field: {
+ name,
+ type,
+ },
+ granularity: granularity || "month",
+ minGroups,
+ forceRecompute,
+ });
+ }
+}
+
+ForecastKanbanModel.services = [...CrmKanbanModel.services, "fillTemporalService"];
diff --git a/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.js b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.js
new file mode 100644
index 0000000..5a48477
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.js
@@ -0,0 +1,50 @@
+import { CrmKanbanRenderer } from "@crm/views/crm_kanban/crm_kanban_renderer";
+import { useService } from "@web/core/utils/hooks";
+import { ForecastKanbanColumnQuickCreate } from "@crm/views/forecast_kanban/forecast_kanban_column_quick_create";
+
+export class ForecastKanbanRenderer extends CrmKanbanRenderer {
+ static template = "crm.ForecastKanbanRenderer";
+ static components = {
+ ...CrmKanbanRenderer.components,
+ ForecastKanbanColumnQuickCreate,
+ };
+
+ setup() {
+ super.setup(...arguments);
+ this.fillTemporalService = useService("fillTemporalService");
+ }
+ /**
+ * @override
+ *
+ * Allow creating groups when grouping by forecast_field.
+ */
+ canCreateGroup() {
+ return super.canCreateGroup(...arguments) || this.isGroupedByForecastField();
+ }
+
+ isGroupedByForecastField() {
+ return (
+ this.props.list.context.forecast_field &&
+ this.props.list.groupByField.name === this.props.list.context.forecast_field
+ );
+ }
+
+ isMovableField(field) {
+ return super.isMovableField(...arguments) || field.name === "date_deadline";
+ }
+
+ async addForecastColumn() {
+ const { name, type, granularity } = this.props.list.groupByField;
+ this.fillTemporalService
+ .getFillTemporalPeriod({
+ modelName: this.props.list.resModel,
+ field: {
+ name,
+ type,
+ },
+ granularity: granularity || "month",
+ })
+ .expand();
+ await this.props.list.load();
+ }
+}
diff --git a/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.xml b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.xml
new file mode 100644
index 0000000..0a708ec
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ $0
+
+
+
diff --git a/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_view.js b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_view.js
new file mode 100644
index 0000000..2cd4af8
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_kanban/forecast_kanban_view.js
@@ -0,0 +1,18 @@
+import { ForecastKanbanController } from "@crm/views/forecast_kanban/forecast_kanban_controller";
+import { CrmKanbanArchParser } from "@crm/views/crm_kanban/crm_kanban_arch_parser";
+import { ForecastKanbanModel } from "@crm/views/forecast_kanban/forecast_kanban_model";
+import { ForecastKanbanRenderer } from "@crm/views/forecast_kanban/forecast_kanban_renderer";
+import { ForecastSearchModel } from "@crm/views/forecast_search_model";
+import { registry } from "@web/core/registry";
+import { kanbanView } from "@web/views/kanban/kanban_view";
+
+export const forecastKanbanView = {
+ ...kanbanView,
+ ArchParser: CrmKanbanArchParser,
+ Model: ForecastKanbanModel,
+ Controller: ForecastKanbanController,
+ Renderer: ForecastKanbanRenderer,
+ SearchModel: ForecastSearchModel,
+};
+
+registry.category("views").add("forecast_kanban", forecastKanbanView);
diff --git a/frontend/crm/static/src/views/forecast_list/forecast_list_view.js b/frontend/crm/static/src/views/forecast_list/forecast_list_view.js
new file mode 100644
index 0000000..94da8aa
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_list/forecast_list_view.js
@@ -0,0 +1,10 @@
+import { registry } from "@web/core/registry";
+import { listView } from "@web/views/list/list_view";
+import { ForecastSearchModel } from "@crm/views/forecast_search_model";
+
+export const forecastListView = {
+ ...listView,
+ SearchModel: ForecastSearchModel,
+};
+
+registry.category("views").add("forecast_list", forecastListView);
diff --git a/frontend/crm/static/src/views/forecast_pivot/forecast_pivot_view.js b/frontend/crm/static/src/views/forecast_pivot/forecast_pivot_view.js
new file mode 100644
index 0000000..e4023d4
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_pivot/forecast_pivot_view.js
@@ -0,0 +1,10 @@
+import { registry } from "@web/core/registry";
+import { pivotView } from "@web/views/pivot/pivot_view";
+import { ForecastSearchModel } from "@crm/views/forecast_search_model";
+
+export const forecastPivotView = {
+ ...pivotView,
+ SearchModel: ForecastSearchModel,
+};
+
+registry.category("views").add("forecast_pivot", forecastPivotView);
diff --git a/frontend/crm/static/src/views/forecast_search_model.js b/frontend/crm/static/src/views/forecast_search_model.js
new file mode 100644
index 0000000..cb8a574
--- /dev/null
+++ b/frontend/crm/static/src/views/forecast_search_model.js
@@ -0,0 +1,86 @@
+import { Domain } from "@web/core/domain";
+import { makeContext } from "@web/core/context";
+import { SearchModel } from "@web/search/search_model";
+import {
+ serializeDate,
+ serializeDateTime,
+} from "@web/core/l10n/dates";
+
+/**
+ * This is the conversion of ForecastModelExtension. See there for more
+ * explanations of what is done here.
+ */
+
+export class ForecastSearchModel extends SearchModel {
+ /**
+ * @override
+ */
+ exportState() {
+ const state = super.exportState();
+ state.forecast = {
+ forecastStart: this.forecastStart,
+ };
+ return state;
+ }
+
+ /**
+ * @override
+ */
+ _getSearchItemDomain(activeItem) {
+ let domain = super._getSearchItemDomain(activeItem);
+ const { searchItemId } = activeItem;
+ const searchItem = this.searchItems[searchItemId];
+ const context = makeContext([searchItem.context || {}]);
+ if (context.forecast_filter) {
+ const forecastField = this.globalContext.forecast_field;
+ const forecastStart = this._getForecastStart(forecastField);
+ const forecastDomain = [
+ "|",
+ [forecastField, "=", false],
+ [forecastField, ">=", forecastStart],
+ ];
+ domain = Domain.and([domain, forecastDomain]);
+ }
+ return domain;
+ }
+
+ /**
+ * @protected
+ * @param {string} forecastField
+ * @returns {string}
+ */
+ _getForecastStart(forecastField) {
+ if (!this.forecastStart) {
+ const { type } = this.searchViewFields[forecastField];
+ const groupBy = this.groupBy;
+ const firstForecastGroupBy = groupBy.find((gb) => gb.includes(forecastField));
+ let granularity = "month";
+ if (firstForecastGroupBy) {
+ granularity = firstForecastGroupBy.split(":")[1] || "month";
+ } else if (groupBy.length) {
+ granularity = "day";
+ }
+ const startDateTime = luxon.DateTime.now().startOf(granularity);
+ this.forecastStart = type === "datetime" ? serializeDateTime(startDateTime) : serializeDate(startDateTime);
+ }
+ return this.forecastStart;
+ }
+
+ /**
+ * @override
+ */
+ _importState(state) {
+ super._importState(...arguments);
+ if (state.forecast) {
+ this.forecastStart = state.forecast.forecastStart;
+ }
+ }
+
+ /**
+ * @override
+ */
+ _reset() {
+ super._reset();
+ this.forecastStart = null;
+ }
+}
diff --git a/frontend/crm/static/tests/crm_kanban_progress_bar_mrr_sum_field.test.js b/frontend/crm/static/tests/crm_kanban_progress_bar_mrr_sum_field.test.js
new file mode 100644
index 0000000..37a3f12
--- /dev/null
+++ b/frontend/crm/static/tests/crm_kanban_progress_bar_mrr_sum_field.test.js
@@ -0,0 +1,282 @@
+import { defineMailModels } from "@mail/../tests/mail_test_helpers";
+import { beforeEach, expect, test } from "@odoo/hoot";
+import { animationFrame, Deferred, queryAllTexts } from "@odoo/hoot-dom";
+import {
+ contains,
+ defineModels,
+ fields,
+ models,
+ mountView,
+ onRpc,
+ patchWithCleanup,
+} from "@web/../tests/web_test_helpers";
+import { user } from "@web/core/user";
+import { AnimatedNumber } from "@web/views/view_components/animated_number";
+
+class Users extends models.Model {
+ name = fields.Char();
+
+ _records = [
+ { id: 1, name: "Dhvanil" },
+ { id: 2, name: "Trivedi" },
+ ];
+}
+
+class Stage extends models.Model {
+ _name = "crm.stage";
+
+ name = fields.Char();
+ is_won = fields.Boolean({ string: "Is won" });
+
+ _records = [
+ { id: 1, name: "New" },
+ { id: 2, name: "Qualified" },
+ { id: 3, name: "Won", is_won: true },
+ ];
+}
+
+class Lead extends models.Model {
+ _name = "crm.lead";
+
+ name = fields.Char();
+ bar = fields.Boolean();
+ activity_state = fields.Char({ string: "Activity State" });
+ expected_revenue = fields.Integer({ string: "Revenue", sortable: true, aggregator: "sum" });
+ recurring_revenue_monthly = fields.Integer({
+ string: "Recurring Revenue",
+ sortable: true,
+ aggregator: "sum",
+ });
+ stage_id = fields.Many2one({ string: "Stage", relation: "crm.stage" });
+ user_id = fields.Many2one({ string: "Salesperson", relation: "users" });
+
+ _records = [
+ {
+ id: 1,
+ bar: false,
+ name: "Lead 1",
+ activity_state: "planned",
+ expected_revenue: 125,
+ recurring_revenue_monthly: 5,
+ stage_id: 1,
+ user_id: 1,
+ },
+ {
+ id: 2,
+ bar: true,
+ name: "Lead 2",
+ activity_state: "today",
+ expected_revenue: 5,
+ stage_id: 2,
+ user_id: 2,
+ },
+ {
+ id: 3,
+ bar: true,
+ name: "Lead 3",
+ activity_state: "planned",
+ expected_revenue: 13,
+ recurring_revenue_monthly: 20,
+ stage_id: 3,
+ user_id: 1,
+ },
+ {
+ id: 4,
+ bar: true,
+ name: "Lead 4",
+ activity_state: "today",
+ expected_revenue: 4,
+ stage_id: 2,
+ user_id: 2,
+ },
+ {
+ id: 5,
+ bar: false,
+ name: "Lead 5",
+ activity_state: "overdue",
+ expected_revenue: 8,
+ recurring_revenue_monthly: 25,
+ stage_id: 3,
+ user_id: 1,
+ },
+ {
+ id: 6,
+ bar: true,
+ name: "Lead 4",
+ activity_state: "today",
+ expected_revenue: 4,
+ recurring_revenue_monthly: 15,
+ stage_id: 1,
+ user_id: 2,
+ },
+ ];
+}
+
+defineModels([Lead, Users, Stage]);
+defineMailModels();
+beforeEach(() => {
+ patchWithCleanup(AnimatedNumber, { enableAnimations: false });
+ patchWithCleanup(user, { hasGroup: (group) => group === "crm.group_use_recurring_revenues" });
+});
+test("Progressbar: do not show sum of MRR if recurring revenues is not enabled", async () => {
+ patchWithCleanup(user, { hasGroup: () => false });
+ await mountView({
+ type: "kanban",
+ resModel: "crm.lead",
+ groupBy: ["stage_id"],
+ arch: `
+
+
+
+
+
+
+
+
+
+ `,
+ });
+
+ expect(queryAllTexts(".o_kanban_counter")).toEqual(["129", "9", "21"], {
+ message: "counter should not display recurring_revenue_monthly content",
+ });
+});
+
+test("Progressbar: ensure correct MRR sum is displayed if recurring revenues is enabled", async () => {
+ await mountView({
+ type: "kanban",
+ resModel: "crm.lead",
+ groupBy: ["stage_id"],
+ arch: `
+
+
+
+
+
+
+
+
+
+ `,
+ });
+
+ // When no values are given in column it should return 0 and counts value if given
+ // MRR=0 shouldn't be displayed, however.
+ expect(queryAllTexts(".o_kanban_counter")).toEqual(["129\n+20", "9", "21\n+45"], {
+ message: "counter should display the sum of recurring_revenue_monthly values",
+ });
+});
+
+test.tags("desktop");
+test("Progressbar: ensure correct MRR updation after state change", async () => {
+ await mountView({
+ type: "kanban",
+ resModel: "crm.lead",
+ groupBy: ["bar"],
+ arch: `
+
+
+
+
+
+
+
+
+
+
+ `,
+ });
+
+ //MRR before state change
+ expect(queryAllTexts(".o_animated_number[data-tooltip='Recurring Revenue']")).toEqual(
+ ["+30", "+35"],
+ {
+ message: "counter should display the sum of recurring_revenue_monthly values",
+ }
+ );
+
+ // Drag the first kanban record from 1st column to the top of the last column
+ await contains(".o_kanban_record:first").dragAndDrop(".o_kanban_record:last");
+
+ //check MRR after drag&drop
+ expect(queryAllTexts(".o_animated_number[data-tooltip='Recurring Revenue']")).toEqual(
+ ["+25", "+40"],
+ {
+ message:
+ "counter should display the sum of recurring_revenue_monthly correctly after drag and drop",
+ }
+ );
+
+ //Activate "planned" filter on first column
+ await contains('.o_kanban_group:eq(1) .progress-bar[aria-valuenow="2"]').click();
+
+ //check MRR after applying filter
+ expect(queryAllTexts(".o_animated_number[data-tooltip='Recurring Revenue']")).toEqual(
+ ["+25", "+25"],
+ {
+ message:
+ "counter should display the sum of recurring_revenue_monthly only of overdue filter in 1st column",
+ }
+ );
+});
+
+test.tags("desktop");
+test("Quickly drag&drop records when grouped by stage_id", async () => {
+ const def = new Deferred();
+ await mountView({
+ type: "kanban",
+ resModel: "crm.lead",
+ groupBy: ["stage_id"],
+ arch: `
+
+
+
+
+
+
+
+
+
+
+ `,
+ });
+ onRpc("web_save", async () => {
+ await def;
+ });
+
+ expect(".o_kanban_group").toHaveCount(3);
+ expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2);
+ expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2);
+ expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2);
+
+ // drag the first record of the first column on top of the second column
+ await contains(".o_kanban_group:eq(0) .o_kanban_record").dragAndDrop(
+ ".o_kanban_group:eq(1) .o_kanban_record"
+ );
+
+ expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
+ expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(3);
+ expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2);
+
+ // drag that same record to the third column -> should have no effect as save still pending
+ // (but mostly, should not crash)
+ await contains(".o_kanban_group:eq(1) .o_kanban_record").dragAndDrop(
+ ".o_kanban_group:eq(2) .o_kanban_record"
+ );
+
+ expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
+ expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(3);
+ expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2);
+
+ def.resolve();
+ await animationFrame();
+
+ // drag that same record to the third column
+ await contains(".o_kanban_group:eq(1) .o_kanban_record").dragAndDrop(
+ ".o_kanban_group:eq(2) .o_kanban_record"
+ );
+
+ expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
+ expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2);
+ expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(3);
+});
diff --git a/frontend/crm/static/tests/crm_mock_server.js b/frontend/crm/static/tests/crm_mock_server.js
new file mode 100644
index 0000000..77cd1a5
--- /dev/null
+++ b/frontend/crm/static/tests/crm_mock_server.js
@@ -0,0 +1,70 @@
+import { onRpc } from "@web/../tests/web_test_helpers";
+import { deserializeDateTime } from "@web/core/l10n/dates";
+
+onRpc("get_rainbowman_message", function getRainbowmanMessage({ args, model }) {
+ let message = false;
+ if (model !== "crm.lead") {
+ return message;
+ }
+ const records = this.env["crm.lead"];
+ const record = records.browse(args[0])[0];
+ const won_stage = this.env["crm.stage"].search_read([["is_won", "=", true]])[0];
+ if (
+ record.stage_id === won_stage.id &&
+ record.user_id &&
+ record.team_id &&
+ record.planned_revenue > 0
+ ) {
+ const now = luxon.DateTime.now();
+ const query_result = {};
+ // Total won
+ query_result["total_won"] = records.filter(
+ (r) => r.stage_id === won_stage.id && r.user_id === record.user_id
+ ).length;
+ // Max team 30 days
+ const recordsTeam30 = records.filter(
+ (r) =>
+ r.stage_id === won_stage.id &&
+ r.team_id === record.team_id &&
+ (!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 30)
+ );
+ query_result["max_team_30"] = Math.max(...recordsTeam30.map((r) => r.planned_revenue));
+ // Max team 7 days
+ const recordsTeam7 = records.filter(
+ (r) =>
+ r.stage_id === won_stage.id &&
+ r.team_id === record.team_id &&
+ (!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 7)
+ );
+ query_result["max_team_7"] = Math.max(...recordsTeam7.map((r) => r.planned_revenue));
+ // Max User 30 days
+ const recordsUser30 = records.filter(
+ (r) =>
+ r.stage_id === won_stage.id &&
+ r.user_id === record.user_id &&
+ (!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 30)
+ );
+ query_result["max_user_30"] = Math.max(...recordsUser30.map((r) => r.planned_revenue));
+ // Max User 7 days
+ const recordsUser7 = records.filter(
+ (r) =>
+ r.stage_id === won_stage.id &&
+ r.user_id === record.user_id &&
+ (!r.date_closed || now.diff(deserializeDateTime(r.date_closed)).as("days") <= 7)
+ );
+ query_result["max_user_7"] = Math.max(...recordsUser7.map((r) => r.planned_revenue));
+
+ if (query_result.total_won === 1) {
+ message = "Go, go, go! Congrats for your first deal.";
+ } else if (query_result.max_team_30 === record.planned_revenue) {
+ message = "Boom! Team record for the past 30 days.";
+ } else if (query_result.max_team_7 === record.planned_revenue) {
+ message = "Yeah! Best deal out of the last 7 days for the team.";
+ } else if (query_result.max_user_30 === record.planned_revenue) {
+ message = "You just beat your personal record for the past 30 days.";
+ } else if (query_result.max_user_7 === record.planned_revenue) {
+ message = "You just beat your personal record for the past 7 days.";
+ }
+ }
+ return message;
+});
diff --git a/frontend/crm/static/tests/crm_rainbowman.test.js b/frontend/crm/static/tests/crm_rainbowman.test.js
new file mode 100644
index 0000000..d63fd2c
--- /dev/null
+++ b/frontend/crm/static/tests/crm_rainbowman.test.js
@@ -0,0 +1,444 @@
+import { defineMailModels } from "@mail/../tests/mail_test_helpers";
+import { expect, test } from "@odoo/hoot";
+import {
+ contains,
+ defineModels,
+ fields,
+ models,
+ mountView,
+ onRpc,
+} from "@web/../tests/web_test_helpers";
+import { serializeDateTime } from "@web/core/l10n/dates";
+
+const now = luxon.DateTime.now();
+class Users extends models.Model {
+ name = fields.Char();
+
+ _records = [
+ { id: 1, name: "Mario" },
+ { id: 2, name: "Luigi" },
+ { id: 3, name: "Link" },
+ { id: 4, name: "Zelda" },
+ ];
+}
+
+class Team extends models.Model {
+ _name = "crm.team";
+
+ name = fields.Char();
+ member_ids = fields.Many2many({ string: "Members", relation: "users" });
+
+ _records = [
+ { id: 1, name: "Mushroom Kingdom", member_ids: [1, 2] },
+ { id: 2, name: "Hyrule", member_ids: [3, 4] },
+ ];
+}
+
+class Stage extends models.Model {
+ _name = "crm.stage";
+
+ name = fields.Char();
+ is_won = fields.Boolean({ string: "Is won" });
+
+ _records = [
+ { id: 1, name: "Start" },
+ { id: 2, name: "Middle" },
+ { id: 3, name: "Won", is_won: true },
+ ];
+}
+
+class Lead extends models.Model {
+ _name = "crm.lead";
+
+ name = fields.Char();
+ planned_revenue = fields.Float({ string: "Revenue" });
+ date_closed = fields.Datetime({ string: "Date closed" });
+ stage_id = fields.Many2one({ string: "Stage", relation: "crm.stage" });
+ user_id = fields.Many2one({ string: "Salesperson", relation: "users" });
+ team_id = fields.Many2one({ string: "Sales Team", relation: "crm.team" });
+
+ _records = [
+ {
+ id: 1,
+ name: "Lead 1",
+ planned_revenue: 5.0,
+ stage_id: 1,
+ team_id: 1,
+ user_id: 1,
+ },
+ {
+ id: 2,
+ name: "Lead 2",
+ planned_revenue: 5.0,
+ stage_id: 2,
+ team_id: 2,
+ user_id: 4,
+ },
+ {
+ id: 3,
+ name: "Lead 3",
+ planned_revenue: 3.0,
+ stage_id: 3,
+ team_id: 1,
+ user_id: 1,
+ date_closed: serializeDateTime(now.minus({ days: 5 })),
+ },
+ {
+ id: 4,
+ name: "Lead 4",
+ planned_revenue: 4.0,
+ stage_id: 3,
+ team_id: 2,
+ user_id: 4,
+ date_closed: serializeDateTime(now.minus({ days: 23 })),
+ },
+ {
+ id: 5,
+ name: "Lead 5",
+ planned_revenue: 7.0,
+ stage_id: 3,
+ team_id: 1,
+ user_id: 1,
+ date_closed: serializeDateTime(now.minus({ days: 20 })),
+ },
+ {
+ id: 6,
+ name: "Lead 6",
+ planned_revenue: 4.0,
+ stage_id: 2,
+ team_id: 1,
+ user_id: 2,
+ },
+ {
+ id: 7,
+ name: "Lead 7",
+ planned_revenue: 1.8,
+ stage_id: 3,
+ team_id: 2,
+ user_id: 3,
+ date_closed: serializeDateTime(now.minus({ days: 23 })),
+ },
+ {
+ id: 8,
+ name: "Lead 8",
+ planned_revenue: 1.9,
+ stage_id: 1,
+ team_id: 2,
+ user_id: 3,
+ },
+ {
+ id: 9,
+ name: "Lead 9",
+ planned_revenue: 1.5,
+ stage_id: 3,
+ team_id: 2,
+ user_id: 3,
+ date_closed: serializeDateTime(now.minus({ days: 5 })),
+ },
+ {
+ id: 10,
+ name: "Lead 10",
+ planned_revenue: 1.7,
+ stage_id: 2,
+ team_id: 2,
+ user_id: 3,
+ },
+ {
+ id: 11,
+ name: "Lead 11",
+ planned_revenue: 2.0,
+ stage_id: 3,
+ team_id: 2,
+ user_id: 4,
+ date_closed: serializeDateTime(now.minus({ days: 5 })),
+ },
+ ];
+}
+
+defineModels([Lead, Users, Stage, Team]);
+defineMailModels();
+
+const testFormView = {
+ arch: `
+ `,
+ type: "form",
+ resModel: "crm.lead",
+};
+const testKanbanView = {
+ arch: `
+
+
+
+
+
+
+ `,
+ resModel: "crm.lead",
+ type: "kanban",
+ groupBy: ["stage_id"],
+};
+
+onRpc("crm.lead", "get_rainbowman_message", ({ parent }) => {
+ const result = parent();
+ expect.step(result || "no rainbowman");
+ return result;
+});
+
+test.tags("desktop");
+test("first lead won, click on statusbar on desktop", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 6,
+ });
+
+ await contains(".o_statusbar_status button[data-value='3']").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
+});
+
+test.tags("mobile");
+test("first lead won, click on statusbar on mobile", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 6,
+ });
+
+ await contains(".o_statusbar_status button.dropdown-toggle").click();
+ await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
+});
+
+test.tags("desktop");
+test("first lead won, click on statusbar in edit mode on desktop", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 6,
+ });
+
+ await contains(".o_statusbar_status button[data-value='3']").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
+});
+
+test.tags("mobile");
+test("first lead won, click on statusbar in edit mode on mobile", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 6,
+ });
+
+ await contains(".o_statusbar_status button.dropdown-toggle").click();
+ await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
+});
+
+test.tags("desktop");
+test("team record 30 days, click on statusbar on desktop", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 2,
+ });
+
+ await contains(".o_statusbar_status button[data-value='3']").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Boom! Team record for the past 30 days."]);
+});
+
+test.tags("mobile");
+test("team record 30 days, click on statusbar on mobile", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 2,
+ });
+
+ await contains(".o_statusbar_status button.dropdown-toggle").click();
+ await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Boom! Team record for the past 30 days."]);
+});
+
+test.tags("desktop");
+test("team record 7 days, click on statusbar on desktop", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 1,
+ });
+
+ await contains(".o_statusbar_status button[data-value='3']").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Yeah! Best deal out of the last 7 days for the team."]);
+});
+
+test.tags("mobile");
+test("team record 7 days, click on statusbar on mobile", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 1,
+ });
+
+ await contains(".o_statusbar_status button.dropdown-toggle").click();
+ await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Yeah! Best deal out of the last 7 days for the team."]);
+});
+
+test.tags("desktop");
+test("user record 30 days, click on statusbar on desktop", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 8,
+ });
+
+ await contains(".o_statusbar_status button[data-value='3']").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["You just beat your personal record for the past 30 days."]);
+});
+
+test.tags("mobile");
+test("user record 30 days, click on statusbar on mobile", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 8,
+ });
+
+ await contains(".o_statusbar_status button.dropdown-toggle").click();
+ await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["You just beat your personal record for the past 30 days."]);
+});
+
+test.tags("desktop");
+test("user record 7 days, click on statusbar on desktop", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 10,
+ });
+
+ await contains(".o_statusbar_status button[data-value='3']").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["You just beat your personal record for the past 7 days."]);
+});
+
+test.tags("mobile");
+test("user record 7 days, click on statusbar on mobile", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 10,
+ });
+
+ await contains(".o_statusbar_status button.dropdown-toggle").click();
+ await contains(".o-dropdown--menu .dropdown-item:contains('Won')").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["You just beat your personal record for the past 7 days."]);
+});
+
+test.tags("desktop");
+test("click on stage (not won) on statusbar on desktop", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 1,
+ });
+
+ await contains(".o_statusbar_status button[data-value='2']").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
+ expect.verifySteps(["no rainbowman"]);
+});
+
+test.tags("mobile");
+test("click on stage (not won) on statusbar on mobile", async () => {
+ await mountView({
+ ...testFormView,
+ resId: 1,
+ });
+
+ await contains(".o_statusbar_status button.dropdown-toggle").click();
+ await contains(".o-dropdown--menu .dropdown-item:contains('Middle')").click();
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
+ expect.verifySteps(["no rainbowman"]);
+});
+
+test.tags("desktop");
+test("first lead won, drag & drop kanban", async () => {
+ await mountView({
+ ...testKanbanView,
+ });
+
+ await contains(".o_kanban_record:contains(Lead 6):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Go, go, go! Congrats for your first deal."]);
+});
+
+test.tags("desktop");
+test("team record 30 days, drag & drop kanban", async () => {
+ await mountView({
+ ...testKanbanView,
+ });
+
+ await contains(".o_kanban_record:contains(Lead 2):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Boom! Team record for the past 30 days."]);
+});
+
+test.tags("desktop");
+test("team record 7 days, drag & drop kanban", async () => {
+ await mountView({
+ ...testKanbanView,
+ });
+
+ await contains(".o_kanban_record:contains(Lead 1):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["Yeah! Best deal out of the last 7 days for the team."]);
+});
+
+test.tags("desktop");
+test("user record 30 days, drag & drop kanban", async () => {
+ await mountView({
+ ...testKanbanView,
+ });
+
+ await contains(".o_kanban_record:contains(Lead 8):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["You just beat your personal record for the past 30 days."]);
+});
+
+test.tags("desktop");
+test("user record 7 days, drag & drop kanban", async () => {
+ await mountView({
+ ...testKanbanView,
+ });
+
+ await contains(".o_kanban_record:contains(Lead 10):eq(0)").dragAndDrop(".o_kanban_group:eq(2)");
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(1);
+ expect.verifySteps(["You just beat your personal record for the past 7 days."]);
+});
+
+test.tags("desktop");
+test("drag & drop record kanban in stage not won", async () => {
+ await mountView({
+ ...testKanbanView,
+ });
+
+ await contains(".o_kanban_record:contains(Lead 8):eq(0)").dragAndDrop(".o_kanban_group:eq(1)");
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
+ expect.verifySteps(["no rainbowman"]);
+});
+
+test.tags("desktop");
+test("drag & drop record in kanban not grouped by stage_id", async () => {
+ await mountView({
+ ...testKanbanView,
+ groupBy: ["user_id"],
+ });
+
+ await contains(".o_kanban_group:eq(0)").dragAndDrop(".o_kanban_group:eq(1)");
+ expect(".o_reward svg.o_reward_rainbow_man").toHaveCount(0);
+ expect.verifySteps([]); // Should never pass by the rpc
+});
diff --git a/frontend/crm/static/tests/crm_test_helpers.js b/frontend/crm/static/tests/crm_test_helpers.js
new file mode 100644
index 0000000..cc03875
--- /dev/null
+++ b/frontend/crm/static/tests/crm_test_helpers.js
@@ -0,0 +1,12 @@
+import { CrmLead } from "@crm/../tests/mock_server/mock_models/crm_lead";
+import { mailModels } from "@mail/../tests/mail_test_helpers";
+import { defineModels } from "@web/../tests/web_test_helpers";
+
+export const crmModels = {
+ ...mailModels,
+ CrmLead
+};
+
+export function defineCrmModels() {
+ defineModels(crmModels);
+}
diff --git a/frontend/crm/static/tests/forecast_kanban.test.js b/frontend/crm/static/tests/forecast_kanban.test.js
new file mode 100644
index 0000000..e302465
--- /dev/null
+++ b/frontend/crm/static/tests/forecast_kanban.test.js
@@ -0,0 +1,371 @@
+import { defineMailModels } from "@mail/../tests/mail_test_helpers";
+import { expect, test } from "@odoo/hoot";
+import { queryAll, queryAllTexts, runAllTimers } from "@odoo/hoot-dom";
+import { mockDate } from "@odoo/hoot-mock";
+import {
+ contains,
+ defineModels,
+ fields,
+ getService,
+ models,
+ mountView,
+ onRpc,
+ quickCreateKanbanColumn,
+} from "@web/../tests/web_test_helpers";
+
+class Lead extends models.Model {
+ _name = "crm.lead";
+
+ name = fields.Char();
+ date_deadline = fields.Date({ string: "Expected closing" });
+}
+
+defineModels([Lead]);
+defineMailModels();
+
+const kanbanArch = `
+
+
+
+
+
+
+
+`;
+
+test.tags("desktop");
+test("filter out every records before the start of the current month with forecast_filter for a date field", async () => {
+ // the filter is used by the forecast model extension, and applies the forecast_filter context key,
+ // which adds a domain constraint on the field marked in the other context key forecast_field
+ mockDate("2021-02-10 00:00:00");
+ Lead._records = [
+ { id: 1, name: "Lead 1", date_deadline: "2021-01-01" },
+ { id: 2, name: "Lead 2", date_deadline: "2021-01-20" },
+ { id: 3, name: "Lead 3", date_deadline: "2021-02-01" },
+ { id: 4, name: "Lead 4", date_deadline: "2021-02-20" },
+ { id: 5, name: "Lead 5", date_deadline: "2021-03-01" },
+ { id: 6, name: "Lead 6", date_deadline: "2021-03-20" },
+ ];
+
+ await mountView({
+ arch: kanbanArch,
+ searchViewArch: `
+
+
+
+ `,
+ resModel: "crm.lead",
+ type: "kanban",
+ context: {
+ search_default_forecast: true,
+ search_default_groupby_date_deadline: true,
+ forecast_field: "date_deadline",
+ },
+ groupBy: ["date_deadline"],
+ });
+
+ // the filter is active
+ expect(".o_kanban_group").toHaveCount(2);
+ expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
+ message: "1st column (February) should contain 2 record",
+ });
+ expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
+ message: "2nd column (March) should contain 2 records",
+ });
+
+ // remove the filter(
+ await contains(".o_searchview_facet:contains(Forecast) .o_facet_remove").click();
+
+ expect(".o_kanban_group").toHaveCount(3);
+ expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
+ message: "1st column (January) should contain 2 record",
+ });
+ expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
+ message: "2nd column (February) should contain 2 records",
+ });
+ expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2, {
+ message: "3nd column (March) should contain 2 records",
+ });
+});
+
+test.tags("desktop");
+test("filter out every records before the start of the current month with forecast_filter for a datetime field", async () => {
+ // same as for the date field
+ mockDate("2021-02-10 00:00:00");
+ Lead._fields.date_closed = fields.Datetime({ string: "Closed Date" });
+ Lead._records = [
+ {
+ id: 1,
+ name: "Lead 1",
+ date_deadline: "2021-01-01",
+ date_closed: "2021-01-01 00:00:00",
+ },
+ {
+ id: 2,
+ name: "Lead 2",
+ date_deadline: "2021-01-20",
+ date_closed: "2021-01-20 00:00:00",
+ },
+ {
+ id: 3,
+ name: "Lead 3",
+ date_deadline: "2021-02-01",
+ date_closed: "2021-02-01 00:00:00",
+ },
+ {
+ id: 4,
+ name: "Lead 4",
+ date_deadline: "2021-02-20",
+ date_closed: "2021-02-20 00:00:00",
+ },
+ {
+ id: 5,
+ name: "Lead 5",
+ date_deadline: "2021-03-01",
+ date_closed: "2021-03-01 00:00:00",
+ },
+ {
+ id: 6,
+ name: "Lead 6",
+ date_deadline: "2021-03-20",
+ date_closed: "2021-03-20 00:00:00",
+ },
+ ];
+ await mountView({
+ arch: kanbanArch,
+ searchViewArch: `
+
+
+
+
+ `,
+ resModel: "crm.lead",
+ type: "kanban",
+ context: {
+ search_default_forecast: true,
+ search_default_groupby_date_closed: true,
+ forecast_field: "date_closed",
+ },
+ groupBy: ["date_closed"],
+ });
+
+ // with the filter
+ expect(".o_kanban_group").toHaveCount(2);
+ expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
+ message: "1st column (February) should contain 2 record",
+ });
+ expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
+ message: "2nd column (March) should contain 2 records",
+ });
+
+ // remove the filter
+ await contains(".o_searchview_facet:contains(Forecast) .o_facet_remove").click();
+
+ expect(".o_kanban_group").toHaveCount(3);
+ expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2, {
+ message: "1st column (January) should contain 2 record",
+ });
+ expect(".o_kanban_group:eq(1) .o_kanban_record").toHaveCount(2, {
+ message: "2nd column (February) should contain 2 records",
+ });
+ expect(".o_kanban_group:eq(2) .o_kanban_record").toHaveCount(2, {
+ message: "3nd column (March) should contain 2 records",
+ });
+});
+
+/**
+ * Since mock_server does not support fill_temporal,
+ * we only check the domain and the context sent to the read_group, as well
+ * as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
+ */
+test("Forecast on months, until the end of the year of the latest data", async () => {
+ expect.assertions(3);
+ mockDate("2021-10-10 00:00:00");
+
+ Lead._records = [
+ { id: 1, name: "Lead 1", date_deadline: "2021-01-01" },
+ { id: 2, name: "Lead 2", date_deadline: "2021-02-01" },
+ { id: 3, name: "Lead 3", date_deadline: "2021-11-01" },
+ { id: 4, name: "Lead 4", date_deadline: "2022-01-01" },
+ ];
+ onRpc("crm.lead", "web_read_group", ({ kwargs }) => {
+ expect(kwargs.context.fill_temporal).toEqual({
+ fill_from: "2021-10-01",
+ min_groups: 4,
+ });
+ expect(kwargs.domain).toEqual([
+ "&",
+ "|",
+ ["date_deadline", "=", false],
+ ["date_deadline", ">=", "2021-10-01"],
+ "|",
+ ["date_deadline", "=", false],
+ ["date_deadline", "<", "2023-01-01"],
+ ]);
+ });
+ await mountView({
+ arch: kanbanArch,
+ searchViewArch: `
+
+
+
+ `,
+ resModel: "crm.lead",
+ type: "kanban",
+ context: {
+ search_default_forecast: true,
+ search_default_groupby_date_deadline: true,
+ forecast_field: "date_deadline",
+ },
+ groupBy: ["date_deadline"],
+ });
+
+ expect(
+ getService("fillTemporalService")
+ .getFillTemporalPeriod({
+ modelName: "crm.lead",
+ field: {
+ name: "date_deadline",
+ type: "date",
+ },
+ granularity: "month",
+ })
+ .end.toFormat("yyyy-MM-dd")
+ ).toBe("2022-02-01");
+});
+
+/**
+ * Since mock_server does not support fill_temporal,
+ * we only check the domain and the context sent to the read_group, as well
+ * as the end value of the FillTemporal Service after the read_group (which should have been updated in the model)
+ */
+test("Forecast on years, until the end of the year of the latest data", async () => {
+ expect.assertions(3);
+ mockDate("2021-10-10 00:00:00");
+
+ Lead._records = [
+ { id: 1, name: "Lead 1", date_deadline: "2021-01-01" },
+ { id: 2, name: "Lead 2", date_deadline: "2022-02-01" },
+ { id: 3, name: "Lead 3", date_deadline: "2027-11-01" },
+ ];
+ onRpc("crm.lead", "web_read_group", ({ kwargs }) => {
+ expect(kwargs.context.fill_temporal).toEqual({
+ fill_from: "2021-01-01",
+ min_groups: 4,
+ });
+ expect(kwargs.domain).toEqual([
+ "&",
+ "|",
+ ["date_deadline", "=", false],
+ ["date_deadline", ">=", "2021-01-01"],
+ "|",
+ ["date_deadline", "=", false],
+ ["date_deadline", "<", "2025-01-01"],
+ ]);
+ });
+ await mountView({
+ arch: kanbanArch,
+ searchViewArch: `
+
+
+
+ `,
+ resModel: "crm.lead",
+ type: "kanban",
+ context: {
+ search_default_forecast: true,
+ search_default_groupby_date_deadline: true,
+ forecast_field: "date_deadline",
+ },
+ groupBy: ["date_deadline:year"],
+ });
+ expect(
+ getService("fillTemporalService")
+ .getFillTemporalPeriod({
+ modelName: "crm.lead",
+ field: {
+ name: "date_deadline",
+ type: "date",
+ },
+ granularity: "year",
+ })
+ .end.toFormat("yyyy-MM-dd")
+ ).toBe("2023-01-01");
+});
+
+test.tags("desktop");
+test("Forecast drag&drop and add column", async () => {
+ mockDate("2023-09-01 00:00:00");
+ Lead._fields.color = fields.Char();
+ Lead._fields.int_field = fields.Integer({ string: "Value" });
+ Lead._records = [
+ { id: 1, int_field: 7, color: "d", name: "Lead 1", date_deadline: "2023-09-03" },
+ { id: 2, int_field: 20, color: "w", name: "Lead 2", date_deadline: "2023-09-05" },
+ { id: 3, int_field: 300, color: "s", name: "Lead 3", date_deadline: "2023-10-10" },
+ ];
+
+ onRpc(({ route, method }) => {
+ expect.step(method || route);
+ });
+ await mountView({
+ arch: `
+
+
+
+
+
+
+
+ `,
+ searchViewArch: `
+
+
+
+ `,
+ resModel: "crm.lead",
+ type: "kanban",
+ context: {
+ search_default_forecast: true,
+ search_default_groupby_date_deadline: true,
+ forecast_field: "date_deadline",
+ },
+ groupBy: ["date_deadline"],
+ });
+
+ const getProgressBarsColors = () =>
+ queryAll(".o_column_progress").map((columnProgressEl) =>
+ queryAll(".progress-bar", { root: columnProgressEl }).map((progressBarEl) =>
+ [...progressBarEl.classList].find((htmlClass) => htmlClass.startsWith("bg-"))
+ )
+ );
+
+ expect(queryAllTexts(".o_animated_number")).toEqual(["27", "300"]);
+ expect(getProgressBarsColors()).toEqual([["bg-warning", "bg-danger"], ["bg-success"]]);
+
+ await contains(".o_kanban_group:first .o_kanban_record").dragAndDrop(".o_kanban_group:eq(1)");
+ await runAllTimers();
+
+ expect(queryAllTexts(".o_animated_number")).toEqual(["20", "307"]);
+ expect(getProgressBarsColors()).toEqual([["bg-warning"], ["bg-success", "bg-danger"]]);
+
+ await quickCreateKanbanColumn();
+
+ // Counters and progressbars should be unchanged after adding a column.
+ expect(queryAllTexts(".o_animated_number")).toEqual(["20", "307"]);
+ expect(getProgressBarsColors()).toEqual([["bg-warning"], ["bg-success", "bg-danger"]]);
+
+ expect.verifySteps([
+ // mountView
+ "get_views",
+ "read_progress_bar",
+ "web_read_group",
+ "has_group",
+ // drag&drop
+ "web_save",
+ "read_progress_bar",
+ "formatted_read_group",
+ // add column
+ "read_progress_bar",
+ "web_read_group",
+ ]);
+});
diff --git a/frontend/crm/static/tests/forecast_view.test.js b/frontend/crm/static/tests/forecast_view.test.js
new file mode 100644
index 0000000..fd61a2e
--- /dev/null
+++ b/frontend/crm/static/tests/forecast_view.test.js
@@ -0,0 +1,119 @@
+import { defineMailModels } from "@mail/../tests/mail_test_helpers";
+import { expect, test } from "@odoo/hoot";
+import { mockDate } from "@odoo/hoot-mock";
+import {
+ defineModels,
+ fields,
+ models,
+ mountView,
+ onRpc,
+ toggleMenuItem,
+ toggleMenuItemOption,
+ toggleSearchBarMenu,
+} from "@web/../tests/web_test_helpers";
+
+class Foo extends models.Model {
+ date_field = fields.Date({ store: true, sortable: true });
+ bar = fields.Many2one({ store: true, relation: "partner", sortable: true });
+ value = fields.Float({ store: true, sortable: true });
+ number = fields.Integer({ store: true, sortable: true });
+
+ _views = {
+ "graph,1": ``,
+ };
+}
+
+class Partner extends models.Model {}
+
+defineModels([Foo, Partner]);
+defineMailModels();
+
+const forecastDomain = (forecastStart) => [
+ "|",
+ ["date_field", "=", false],
+ ["date_field", ">=", forecastStart],
+];
+
+test("Forecast graph view", async () => {
+ expect.assertions(5);
+ mockDate("2021-09-16 16:54:00");
+
+ const expectedDomains = [
+ forecastDomain("2021-09-01"), // month granularity due to no groupby
+ forecastDomain("2021-09-16"), // day granularity due to simple bar groupby
+ // quarter granularity due to date field groupby activated with quarter interval option
+ forecastDomain("2021-07-01"),
+ // quarter granularity due to date field groupby activated with quarter and year interval option
+ forecastDomain("2021-01-01"),
+ // forecast filter no more active
+ [],
+ ];
+
+ onRpc("formatted_read_group", ({ kwargs }) => {
+ expect(kwargs.domain).toEqual(expectedDomains.shift());
+ });
+ await mountView({
+ resModel: "foo",
+ viewId: 1,
+ type: "graph",
+ searchViewArch: `
+
+
+
+
+
+ `,
+ context: {
+ search_default_forecast_filter: 1,
+ forecast_field: "date_field",
+ },
+ });
+
+ await toggleSearchBarMenu();
+ await toggleMenuItem("Bar");
+
+ await toggleMenuItem("Date Field");
+ await toggleMenuItemOption("Date Field", "Quarter");
+
+ await toggleMenuItemOption("Date Field", "Year");
+
+ await toggleMenuItem("Forecast Filter");
+});
+
+test("forecast filter domain is combined with other domains following the same rules as other filters (OR in same group, AND between groups)", async () => {
+ expect.assertions(1);
+ mockDate("2021-09-16 16:54:00");
+
+ onRpc("formatted_read_group", ({ kwargs }) => {
+ expect(kwargs.domain).toEqual([
+ "&",
+ ["number", ">", 2],
+ "|",
+ ["bar", "=", 2],
+ "&",
+ ["value", ">", 0.0],
+ "|",
+ ["date_field", "=", false],
+ ["date_field", ">=", "2021-09-01"],
+ ]);
+ });
+ await mountView({
+ resModel: "foo",
+ type: "graph",
+ viewId: 1,
+ searchViewArch: `
+
+
+
+
+
+
+ `,
+ context: {
+ search_default_same_group_filter: 1,
+ search_default_forecast_filter: 1,
+ search_default_other_group_filter: 1,
+ forecast_field: "date_field",
+ },
+ });
+});
diff --git a/frontend/crm/static/tests/mock_server/mock_models/crm_lead.js b/frontend/crm/static/tests/mock_server/mock_models/crm_lead.js
new file mode 100644
index 0000000..b837e49
--- /dev/null
+++ b/frontend/crm/static/tests/mock_server/mock_models/crm_lead.js
@@ -0,0 +1,13 @@
+import { models } from "@web/../tests/web_test_helpers";
+
+export class CrmLead extends models.ServerModel {
+ _name = "crm.lead";
+ _views = {
+ form: /* xml */ `
+ `,
+ };
+}
diff --git a/frontend/crm/static/tests/tours/create_crm_team_tour.js b/frontend/crm/static/tests/tours/create_crm_team_tour.js
new file mode 100644
index 0000000..f380471
--- /dev/null
+++ b/frontend/crm/static/tests/tours/create_crm_team_tour.js
@@ -0,0 +1,38 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+registry.category("web_tour.tours").add('create_crm_team_tour', {
+ url: "/odoo",
+ steps: () => [
+ ...stepUtils.goToAppSteps('crm.crm_menu_root'),
+{
+ trigger: 'button[data-menu-xmlid="crm.crm_menu_config"]',
+ run: "click",
+}, {
+ trigger: 'a[data-menu-xmlid="crm.crm_team_config"]',
+ run: "click",
+}, {
+ trigger: 'button.o_list_button_add',
+ run: "click",
+}, {
+ trigger: 'input[id="name_0"]',
+ run: "edit My CRM Team",
+}, {
+ trigger: '.btn.o-kanban-button-new',
+ run: "click",
+}, {
+ trigger: 'div.modal-dialog tr:contains("Test Salesman") input.form-check-input',
+ run: 'click',
+}, {
+ trigger: 'div.modal-dialog tr:contains("Test Sales Manager") input.form-check-input',
+ run: 'click',
+}, {
+ trigger: 'div.modal-dialog tr:contains("Test Sales Manager") input.form-check-input:checked',
+}, {
+ trigger: '.o_selection_box:contains(2)',
+}, {
+ trigger: 'button.o_select_button',
+ run: "click",
+},
+ ...stepUtils.saveForm()
+]});
diff --git a/frontend/crm/static/tests/tours/crm_email_and_phone_propagation.js b/frontend/crm/static/tests/tours/crm_email_and_phone_propagation.js
new file mode 100644
index 0000000..a39531c
--- /dev/null
+++ b/frontend/crm/static/tests/tours/crm_email_and_phone_propagation.js
@@ -0,0 +1,25 @@
+ import { registry } from "@web/core/registry";
+ import { stepUtils } from "@web_tour/tour_utils";
+
+ registry.category("web_tour.tours").add('crm_email_and_phone_propagation_edit_save', {
+ url: '/odoo',
+ steps: () => [
+ stepUtils.showAppsMenuItem(),
+ {
+ trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
+ content: 'open crm app',
+ run: "click",
+ }, {
+ trigger: '.o_kanban_record:contains(Test Lead Propagation)',
+ content: 'Open the first lead',
+ run: 'click',
+ },
+ {
+ trigger: ".o_form_editable .o_field_widget[name=email_from] input",
+ },
+ {
+ trigger: ".o_form_button_save:not(:visible)",
+ content: 'Save the lead',
+ run: 'click',
+ },
+ ]});
diff --git a/frontend/crm/static/tests/tours/crm_forecast_tour.js b/frontend/crm/static/tests/tours/crm_forecast_tour.js
new file mode 100644
index 0000000..0189442
--- /dev/null
+++ b/frontend/crm/static/tests/tours/crm_forecast_tour.js
@@ -0,0 +1,93 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+const today = luxon.DateTime.now();
+
+registry.category("web_tour.tours").add('crm_forecast', {
+ url: "/odoo",
+ steps: () => [
+ stepUtils.showAppsMenuItem(),
+ {
+ trigger: ".o_app[data-menu-xmlid='crm.crm_menu_root']",
+ content: "open crm app",
+ run: "click",
+ }, {
+ trigger: '.dropdown-toggle[data-menu-xmlid="crm.crm_menu_report"]',
+ content: 'Open Reporting menu',
+ run: 'click',
+ }, {
+ trigger: '.dropdown-item[data-menu-xmlid="crm.crm_menu_forecast"]',
+ content: 'Open Forecast menu',
+ run: 'click',
+ }, {
+ trigger: '.o_column_quick_create',
+ content: 'Wait page loading',
+ }, {
+ trigger: ".o-kanban-button-new",
+ content: "click create",
+ run: 'click',
+ }, {
+ trigger: ".o_field_widget[name=name] input",
+ content: "complete name",
+ run: "edit Test Opportunity 1",
+ }, {
+ trigger: ".o_field_widget[name=expected_revenue] input",
+ content: "complete expected revenue",
+ run: "edit 999999",
+ }, {
+ trigger: "button.o_kanban_edit",
+ content: "edit lead",
+ run: "click",
+ }, {
+ trigger: "div[name=date_deadline] button",
+ content: "open date picker",
+ run: "click",
+ }, {
+ trigger: "div[name=date_deadline] input",
+ content: "complete expected closing",
+ run: `edit ${today.toFormat("MM/dd/yyyy")}`,
+ }, {
+ trigger: "div[name=date_deadline] input",
+ content: "click to make the datepicker disappear",
+ run: "click"
+ }, {
+ trigger: '.o_back_button',
+ content: 'navigate back to the kanban view',
+ tooltipPosition: "bottom",
+ run: "click"
+ }, {
+ trigger: ".o_kanban_record:contains('Test Opportunity 1')",
+ content: "move to the next month",
+ async run({ queryAll, drag_and_drop }) {
+ const undefined_groups = queryAll('.o_column_title:contains("None")').length;
+ await drag_and_drop(`.o_opportunity_kanban .o_kanban_group:eq(${1 + undefined_groups})`);
+ },
+ }, {
+ trigger: ".o_kanban_record:contains('Test Opportunity 1')",
+ content: "edit lead",
+ run: "click"
+ }, {
+ trigger: "div[name=date_deadline] button",
+ content: "open date picker",
+ run: "click",
+ }, {
+ trigger: ".o_field_widget[name=date_deadline] input",
+ content: "complete expected closing",
+ run: `edit ${today.plus({ months: 5 }).startOf("month").minus({ days: 1 }).toFormat("MM/dd/yyyy")} && press Escape`,
+ }, {
+ trigger: "button[name=action_set_won_rainbowman]",
+ content: "win the lead",
+ run:"click"
+ }, {
+ trigger: '.o_back_button',
+ content: 'navigate back to the kanban view',
+ tooltipPosition: "bottom",
+ run: "click"
+ }, {
+ trigger: '.o_column_quick_create.o_quick_create_folded div',
+ content: "add next month",
+ run: "click"
+ }, {
+ trigger: ".o_kanban_record:contains('Test Opportunity 1'):contains('Won')",
+ content: "assert that the opportunity has the Won banner",
+ }
+]});
diff --git a/frontend/crm/static/tests/tours/crm_rainbowman.js b/frontend/crm/static/tests/tours/crm_rainbowman.js
new file mode 100644
index 0000000..2c3e6a1
--- /dev/null
+++ b/frontend/crm/static/tests/tours/crm_rainbowman.js
@@ -0,0 +1,119 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+registry.category("web_tour.tours").add("crm_rainbowman", {
+ url: "/odoo",
+ steps: () => [
+ stepUtils.showAppsMenuItem(),
+ {
+ trigger: ".o_app[data-menu-xmlid='crm.crm_menu_root']",
+ content: "open crm app",
+ run: "click",
+ },
+ {
+ trigger: "body:has(.o_kanban_renderer) .o-kanban-button-new",
+ content: "click create",
+ run: "click",
+ },
+ {
+ trigger: ".o_field_widget[name=name] input",
+ content: "complete name",
+ run: "edit Test Lead 1",
+ },
+ {
+ trigger: ".o_field_widget[name=expected_revenue] input",
+ content: "complete expected revenue",
+ run: "edit 999999997",
+ },
+ {
+ trigger: "button.o_kanban_add",
+ content: "create lead",
+ run: "click",
+ },
+ {
+ trigger: ".o_kanban_record:contains('Test Lead 1')",
+ content: "move to won stage",
+ run: "drag_and_drop (.o_opportunity_kanban .o_kanban_group:has(.o_column_title:contains('Won')))",
+ },
+ {
+ trigger: ".o_reward_rainbow",
+ },
+ {
+ // This step and the following simulates the fact that after drag and drop,
+ // from the previous steps, a click event is triggered on the window element,
+ // which closes the currently shown .o_kanban_quick_create.
+ trigger: ".o_kanban_renderer",
+ run: "click",
+ },
+ {
+ trigger: ".o_kanban_renderer:not(:has(.o_kanban_quick_create))",
+ },
+ {
+ trigger: ".o-kanban-button-new",
+ content: "create second lead",
+ run: "click",
+ },
+ {
+ trigger: ".o_field_widget[name=name] input",
+ content: "complete name",
+ run: "edit Test Lead 2",
+ },
+ {
+ trigger: ".o_field_widget[name=expected_revenue] input",
+ content: "complete expected revenue",
+ run: "edit 999999998",
+ },
+ {
+ trigger: "button.o_kanban_add",
+ content: "create lead",
+ run: "click",
+ },
+ {
+ trigger: ".o_kanban_record:contains('Test Lead 2')",
+ },
+ {
+ // move first test back to new stage to be able to test rainbowman a second time
+ trigger: ".o_kanban_record:contains('Test Lead 1')",
+ content: "move back to new stage",
+ run: "drag_and_drop .o_opportunity_kanban .o_kanban_group:eq(0) ",
+ },
+ {
+ trigger: ".o_kanban_record:contains('Test Lead 2')",
+ content: "click on second lead",
+ run: "click",
+ },
+ {
+ trigger: ".o_statusbar_status button[data-value='4']",
+ content: "move lead to won stage",
+ run: "click",
+ },
+ {
+ content: "wait for save completion",
+ trigger: ".o_form_readonly, .o_form_saved",
+ },
+ {
+ trigger: ".o_reward_rainbow",
+ },
+ {
+ trigger: ".o_statusbar_status button[data-value='1']",
+ content: "move lead to previous stage & rainbowman appears",
+ run: "click",
+ },
+ {
+ trigger: "button[name=action_set_won_rainbowman]",
+ content: "click button mark won",
+ run: "click",
+ },
+ {
+ content: "wait for save completion",
+ trigger: ".o_form_readonly, .o_form_saved",
+ },
+ {
+ trigger: ".o_reward_rainbow",
+ },
+ {
+ trigger: ".o_menu_brand",
+ content: "last rainbowman appears",
+ },
+ ],
+});
diff --git a/frontend/crm/static/xls/crm_lead.xls b/frontend/crm/static/xls/crm_lead.xls
new file mode 100644
index 0000000..a8db450
Binary files /dev/null and b/frontend/crm/static/xls/crm_lead.xls differ
diff --git a/frontend/fleet/static/description/icon.png b/frontend/fleet/static/description/icon.png
new file mode 100644
index 0000000..c581d96
Binary files /dev/null and b/frontend/fleet/static/description/icon.png differ
diff --git a/frontend/fleet/static/description/icon.svg b/frontend/fleet/static/description/icon.svg
new file mode 100644
index 0000000..808dc78
--- /dev/null
+++ b/frontend/fleet/static/description/icon.svg
@@ -0,0 +1 @@
+
diff --git a/frontend/fleet/static/description/icon_hi.png b/frontend/fleet/static/description/icon_hi.png
new file mode 100644
index 0000000..a6937ad
Binary files /dev/null and b/frontend/fleet/static/description/icon_hi.png differ
diff --git a/frontend/fleet/static/img/brand_abarth-image.png b/frontend/fleet/static/img/brand_abarth-image.png
new file mode 100644
index 0000000..24c9f62
Binary files /dev/null and b/frontend/fleet/static/img/brand_abarth-image.png differ
diff --git a/frontend/fleet/static/img/brand_acura-image.png b/frontend/fleet/static/img/brand_acura-image.png
new file mode 100644
index 0000000..ae670ea
Binary files /dev/null and b/frontend/fleet/static/img/brand_acura-image.png differ
diff --git a/frontend/fleet/static/img/brand_alfa-image.png b/frontend/fleet/static/img/brand_alfa-image.png
new file mode 100644
index 0000000..68f300c
Binary files /dev/null and b/frontend/fleet/static/img/brand_alfa-image.png differ
diff --git a/frontend/fleet/static/img/brand_audi-image.png b/frontend/fleet/static/img/brand_audi-image.png
new file mode 100644
index 0000000..5d7e5b4
Binary files /dev/null and b/frontend/fleet/static/img/brand_audi-image.png differ
diff --git a/frontend/fleet/static/img/brand_austin-image.png b/frontend/fleet/static/img/brand_austin-image.png
new file mode 100644
index 0000000..b8fd9f0
Binary files /dev/null and b/frontend/fleet/static/img/brand_austin-image.png differ
diff --git a/frontend/fleet/static/img/brand_bentley-image.png b/frontend/fleet/static/img/brand_bentley-image.png
new file mode 100644
index 0000000..3f89c64
Binary files /dev/null and b/frontend/fleet/static/img/brand_bentley-image.png differ
diff --git a/frontend/fleet/static/img/brand_bmw-image.png b/frontend/fleet/static/img/brand_bmw-image.png
new file mode 100644
index 0000000..f75724c
Binary files /dev/null and b/frontend/fleet/static/img/brand_bmw-image.png differ
diff --git a/frontend/fleet/static/img/brand_bugatti-image.png b/frontend/fleet/static/img/brand_bugatti-image.png
new file mode 100644
index 0000000..f85544c
Binary files /dev/null and b/frontend/fleet/static/img/brand_bugatti-image.png differ
diff --git a/frontend/fleet/static/img/brand_buick-image.png b/frontend/fleet/static/img/brand_buick-image.png
new file mode 100644
index 0000000..05f3b60
Binary files /dev/null and b/frontend/fleet/static/img/brand_buick-image.png differ
diff --git a/frontend/fleet/static/img/brand_byd-image.png b/frontend/fleet/static/img/brand_byd-image.png
new file mode 100644
index 0000000..b3df937
Binary files /dev/null and b/frontend/fleet/static/img/brand_byd-image.png differ
diff --git a/frontend/fleet/static/img/brand_cadillac-image.png b/frontend/fleet/static/img/brand_cadillac-image.png
new file mode 100644
index 0000000..fb3ee06
Binary files /dev/null and b/frontend/fleet/static/img/brand_cadillac-image.png differ
diff --git a/frontend/fleet/static/img/brand_chevrolet-image.png b/frontend/fleet/static/img/brand_chevrolet-image.png
new file mode 100644
index 0000000..ea44a45
Binary files /dev/null and b/frontend/fleet/static/img/brand_chevrolet-image.png differ
diff --git a/frontend/fleet/static/img/brand_chrysler-image.png b/frontend/fleet/static/img/brand_chrysler-image.png
new file mode 100644
index 0000000..74779e4
Binary files /dev/null and b/frontend/fleet/static/img/brand_chrysler-image.png differ
diff --git a/frontend/fleet/static/img/brand_citroen-image.png b/frontend/fleet/static/img/brand_citroen-image.png
new file mode 100644
index 0000000..e09747f
Binary files /dev/null and b/frontend/fleet/static/img/brand_citroen-image.png differ
diff --git a/frontend/fleet/static/img/brand_corre-la-licorne-image.png b/frontend/fleet/static/img/brand_corre-la-licorne-image.png
new file mode 100644
index 0000000..1730f9e
Binary files /dev/null and b/frontend/fleet/static/img/brand_corre-la-licorne-image.png differ
diff --git a/frontend/fleet/static/img/brand_daewoo-image.png b/frontend/fleet/static/img/brand_daewoo-image.png
new file mode 100644
index 0000000..bc0dca7
Binary files /dev/null and b/frontend/fleet/static/img/brand_daewoo-image.png differ
diff --git a/frontend/fleet/static/img/brand_dodge-image.png b/frontend/fleet/static/img/brand_dodge-image.png
new file mode 100644
index 0000000..e1aeeb4
Binary files /dev/null and b/frontend/fleet/static/img/brand_dodge-image.png differ
diff --git a/frontend/fleet/static/img/brand_ferrari-image.png b/frontend/fleet/static/img/brand_ferrari-image.png
new file mode 100644
index 0000000..d71d4a5
Binary files /dev/null and b/frontend/fleet/static/img/brand_ferrari-image.png differ
diff --git a/frontend/fleet/static/img/brand_fiat-image.png b/frontend/fleet/static/img/brand_fiat-image.png
new file mode 100644
index 0000000..fdc2a69
Binary files /dev/null and b/frontend/fleet/static/img/brand_fiat-image.png differ
diff --git a/frontend/fleet/static/img/brand_ford-image.png b/frontend/fleet/static/img/brand_ford-image.png
new file mode 100644
index 0000000..1f277bf
Binary files /dev/null and b/frontend/fleet/static/img/brand_ford-image.png differ
diff --git a/frontend/fleet/static/img/brand_gmc-image.png b/frontend/fleet/static/img/brand_gmc-image.png
new file mode 100644
index 0000000..63b27f9
Binary files /dev/null and b/frontend/fleet/static/img/brand_gmc-image.png differ
diff --git a/frontend/fleet/static/img/brand_holden-image.png b/frontend/fleet/static/img/brand_holden-image.png
new file mode 100644
index 0000000..c266949
Binary files /dev/null and b/frontend/fleet/static/img/brand_holden-image.png differ
diff --git a/frontend/fleet/static/img/brand_honda-image.png b/frontend/fleet/static/img/brand_honda-image.png
new file mode 100644
index 0000000..c130969
Binary files /dev/null and b/frontend/fleet/static/img/brand_honda-image.png differ
diff --git a/frontend/fleet/static/img/brand_hyundai-image.png b/frontend/fleet/static/img/brand_hyundai-image.png
new file mode 100644
index 0000000..264f53a
Binary files /dev/null and b/frontend/fleet/static/img/brand_hyundai-image.png differ
diff --git a/frontend/fleet/static/img/brand_infiniti-image.png b/frontend/fleet/static/img/brand_infiniti-image.png
new file mode 100644
index 0000000..82ca2bd
Binary files /dev/null and b/frontend/fleet/static/img/brand_infiniti-image.png differ
diff --git a/frontend/fleet/static/img/brand_isuzu-image.png b/frontend/fleet/static/img/brand_isuzu-image.png
new file mode 100644
index 0000000..5d4248a
Binary files /dev/null and b/frontend/fleet/static/img/brand_isuzu-image.png differ
diff --git a/frontend/fleet/static/img/brand_jaguar-image.png b/frontend/fleet/static/img/brand_jaguar-image.png
new file mode 100644
index 0000000..dbd5058
Binary files /dev/null and b/frontend/fleet/static/img/brand_jaguar-image.png differ
diff --git a/frontend/fleet/static/img/brand_jeep-image.png b/frontend/fleet/static/img/brand_jeep-image.png
new file mode 100644
index 0000000..9381cba
Binary files /dev/null and b/frontend/fleet/static/img/brand_jeep-image.png differ
diff --git a/frontend/fleet/static/img/brand_kia-image.png b/frontend/fleet/static/img/brand_kia-image.png
new file mode 100644
index 0000000..d1f9d56
Binary files /dev/null and b/frontend/fleet/static/img/brand_kia-image.png differ
diff --git a/frontend/fleet/static/img/brand_koenigsegg-image.png b/frontend/fleet/static/img/brand_koenigsegg-image.png
new file mode 100644
index 0000000..a7a3b88
Binary files /dev/null and b/frontend/fleet/static/img/brand_koenigsegg-image.png differ
diff --git a/frontend/fleet/static/img/brand_lagonda-image.png b/frontend/fleet/static/img/brand_lagonda-image.png
new file mode 100644
index 0000000..bf33f94
Binary files /dev/null and b/frontend/fleet/static/img/brand_lagonda-image.png differ
diff --git a/frontend/fleet/static/img/brand_lamborghini-image.png b/frontend/fleet/static/img/brand_lamborghini-image.png
new file mode 100644
index 0000000..c874e32
Binary files /dev/null and b/frontend/fleet/static/img/brand_lamborghini-image.png differ
diff --git a/frontend/fleet/static/img/brand_lancia-image.png b/frontend/fleet/static/img/brand_lancia-image.png
new file mode 100644
index 0000000..5c04ad3
Binary files /dev/null and b/frontend/fleet/static/img/brand_lancia-image.png differ
diff --git a/frontend/fleet/static/img/brand_land-rover-image.png b/frontend/fleet/static/img/brand_land-rover-image.png
new file mode 100644
index 0000000..c845dd2
Binary files /dev/null and b/frontend/fleet/static/img/brand_land-rover-image.png differ
diff --git a/frontend/fleet/static/img/brand_lexus-image.png b/frontend/fleet/static/img/brand_lexus-image.png
new file mode 100644
index 0000000..2b1dfc9
Binary files /dev/null and b/frontend/fleet/static/img/brand_lexus-image.png differ
diff --git a/frontend/fleet/static/img/brand_lincoln-image.png b/frontend/fleet/static/img/brand_lincoln-image.png
new file mode 100644
index 0000000..07ecaa2
Binary files /dev/null and b/frontend/fleet/static/img/brand_lincoln-image.png differ
diff --git a/frontend/fleet/static/img/brand_lotus-image.png b/frontend/fleet/static/img/brand_lotus-image.png
new file mode 100644
index 0000000..3952ba0
Binary files /dev/null and b/frontend/fleet/static/img/brand_lotus-image.png differ
diff --git a/frontend/fleet/static/img/brand_maserati-image.png b/frontend/fleet/static/img/brand_maserati-image.png
new file mode 100644
index 0000000..e27934e
Binary files /dev/null and b/frontend/fleet/static/img/brand_maserati-image.png differ
diff --git a/frontend/fleet/static/img/brand_maybach-image.png b/frontend/fleet/static/img/brand_maybach-image.png
new file mode 100644
index 0000000..1c1d2b5
Binary files /dev/null and b/frontend/fleet/static/img/brand_maybach-image.png differ
diff --git a/frontend/fleet/static/img/brand_mazda-image.png b/frontend/fleet/static/img/brand_mazda-image.png
new file mode 100644
index 0000000..8f7158b
Binary files /dev/null and b/frontend/fleet/static/img/brand_mazda-image.png differ
diff --git a/frontend/fleet/static/img/brand_mercedes-image.png b/frontend/fleet/static/img/brand_mercedes-image.png
new file mode 100644
index 0000000..14eaab0
Binary files /dev/null and b/frontend/fleet/static/img/brand_mercedes-image.png differ
diff --git a/frontend/fleet/static/img/brand_mg-image.png b/frontend/fleet/static/img/brand_mg-image.png
new file mode 100644
index 0000000..cac343b
Binary files /dev/null and b/frontend/fleet/static/img/brand_mg-image.png differ
diff --git a/frontend/fleet/static/img/brand_mini-image.png b/frontend/fleet/static/img/brand_mini-image.png
new file mode 100644
index 0000000..00e5f96
Binary files /dev/null and b/frontend/fleet/static/img/brand_mini-image.png differ
diff --git a/frontend/fleet/static/img/brand_mitsubishi-image.png b/frontend/fleet/static/img/brand_mitsubishi-image.png
new file mode 100644
index 0000000..1b51add
Binary files /dev/null and b/frontend/fleet/static/img/brand_mitsubishi-image.png differ
diff --git a/frontend/fleet/static/img/brand_morgan-image.png b/frontend/fleet/static/img/brand_morgan-image.png
new file mode 100644
index 0000000..985663e
Binary files /dev/null and b/frontend/fleet/static/img/brand_morgan-image.png differ
diff --git a/frontend/fleet/static/img/brand_nissan-image.png b/frontend/fleet/static/img/brand_nissan-image.png
new file mode 100644
index 0000000..417c771
Binary files /dev/null and b/frontend/fleet/static/img/brand_nissan-image.png differ
diff --git a/frontend/fleet/static/img/brand_oldsmobile-image.png b/frontend/fleet/static/img/brand_oldsmobile-image.png
new file mode 100644
index 0000000..e39d936
Binary files /dev/null and b/frontend/fleet/static/img/brand_oldsmobile-image.png differ
diff --git a/frontend/fleet/static/img/brand_opel-image.png b/frontend/fleet/static/img/brand_opel-image.png
new file mode 100644
index 0000000..69bcd31
Binary files /dev/null and b/frontend/fleet/static/img/brand_opel-image.png differ
diff --git a/frontend/fleet/static/img/brand_peugeot-image.png b/frontend/fleet/static/img/brand_peugeot-image.png
new file mode 100644
index 0000000..1de894a
Binary files /dev/null and b/frontend/fleet/static/img/brand_peugeot-image.png differ
diff --git a/frontend/fleet/static/img/brand_pontiac-image.png b/frontend/fleet/static/img/brand_pontiac-image.png
new file mode 100644
index 0000000..4614b29
Binary files /dev/null and b/frontend/fleet/static/img/brand_pontiac-image.png differ
diff --git a/frontend/fleet/static/img/brand_porsche-image.png b/frontend/fleet/static/img/brand_porsche-image.png
new file mode 100644
index 0000000..f166ef3
Binary files /dev/null and b/frontend/fleet/static/img/brand_porsche-image.png differ
diff --git a/frontend/fleet/static/img/brand_rambler-image.png b/frontend/fleet/static/img/brand_rambler-image.png
new file mode 100644
index 0000000..1ae27b1
Binary files /dev/null and b/frontend/fleet/static/img/brand_rambler-image.png differ
diff --git a/frontend/fleet/static/img/brand_renault-image.png b/frontend/fleet/static/img/brand_renault-image.png
new file mode 100644
index 0000000..47107ea
Binary files /dev/null and b/frontend/fleet/static/img/brand_renault-image.png differ
diff --git a/frontend/fleet/static/img/brand_rolls-royce-image.png b/frontend/fleet/static/img/brand_rolls-royce-image.png
new file mode 100644
index 0000000..e56a63a
Binary files /dev/null and b/frontend/fleet/static/img/brand_rolls-royce-image.png differ
diff --git a/frontend/fleet/static/img/brand_saab-image.png b/frontend/fleet/static/img/brand_saab-image.png
new file mode 100644
index 0000000..b578781
Binary files /dev/null and b/frontend/fleet/static/img/brand_saab-image.png differ
diff --git a/frontend/fleet/static/img/brand_scion-image.png b/frontend/fleet/static/img/brand_scion-image.png
new file mode 100644
index 0000000..348a85d
Binary files /dev/null and b/frontend/fleet/static/img/brand_scion-image.png differ
diff --git a/frontend/fleet/static/img/brand_skoda-image.png b/frontend/fleet/static/img/brand_skoda-image.png
new file mode 100644
index 0000000..24bfa0b
Binary files /dev/null and b/frontend/fleet/static/img/brand_skoda-image.png differ
diff --git a/frontend/fleet/static/img/brand_smart-image.png b/frontend/fleet/static/img/brand_smart-image.png
new file mode 100644
index 0000000..99a6783
Binary files /dev/null and b/frontend/fleet/static/img/brand_smart-image.png differ
diff --git a/frontend/fleet/static/img/brand_steyr-image.png b/frontend/fleet/static/img/brand_steyr-image.png
new file mode 100644
index 0000000..f9fc80f
Binary files /dev/null and b/frontend/fleet/static/img/brand_steyr-image.png differ
diff --git a/frontend/fleet/static/img/brand_subaru-image.png b/frontend/fleet/static/img/brand_subaru-image.png
new file mode 100644
index 0000000..00b6dcd
Binary files /dev/null and b/frontend/fleet/static/img/brand_subaru-image.png differ
diff --git a/frontend/fleet/static/img/brand_suzuki-image.png b/frontend/fleet/static/img/brand_suzuki-image.png
new file mode 100644
index 0000000..1e00db0
Binary files /dev/null and b/frontend/fleet/static/img/brand_suzuki-image.png differ
diff --git a/frontend/fleet/static/img/brand_tesla-motors-image.png b/frontend/fleet/static/img/brand_tesla-motors-image.png
new file mode 100644
index 0000000..25fd96b
Binary files /dev/null and b/frontend/fleet/static/img/brand_tesla-motors-image.png differ
diff --git a/frontend/fleet/static/img/brand_toyota-image.png b/frontend/fleet/static/img/brand_toyota-image.png
new file mode 100644
index 0000000..0072950
Binary files /dev/null and b/frontend/fleet/static/img/brand_toyota-image.png differ
diff --git a/frontend/fleet/static/img/brand_trabant-image.png b/frontend/fleet/static/img/brand_trabant-image.png
new file mode 100644
index 0000000..15efb1f
Binary files /dev/null and b/frontend/fleet/static/img/brand_trabant-image.png differ
diff --git a/frontend/fleet/static/img/brand_volkswagen-image.png b/frontend/fleet/static/img/brand_volkswagen-image.png
new file mode 100644
index 0000000..81dbf78
Binary files /dev/null and b/frontend/fleet/static/img/brand_volkswagen-image.png differ
diff --git a/frontend/fleet/static/img/brand_volvo-image.png b/frontend/fleet/static/img/brand_volvo-image.png
new file mode 100644
index 0000000..048d2a6
Binary files /dev/null and b/frontend/fleet/static/img/brand_volvo-image.png differ
diff --git a/frontend/fleet/static/img/brand_willys-image.png b/frontend/fleet/static/img/brand_willys-image.png
new file mode 100644
index 0000000..1da06b8
Binary files /dev/null and b/frontend/fleet/static/img/brand_willys-image.png differ
diff --git a/frontend/fleet/static/src/js/fleet_form.js b/frontend/fleet/static/src/js/fleet_form.js
new file mode 100644
index 0000000..e3da51f
--- /dev/null
+++ b/frontend/fleet/static/src/js/fleet_form.js
@@ -0,0 +1,32 @@
+import { _t } from "@web/core/l10n/translation";
+import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
+import { registry } from "@web/core/registry";
+import { FormController } from "@web/views/form/form_controller";
+import { formView } from "@web/views/form/form_view";
+
+export class FleetFormController extends FormController {
+ /**
+ * @override
+ **/
+ getStaticActionMenuItems() {
+ const menuItems = super.getStaticActionMenuItems();
+ menuItems.archive.callback = () => {
+ const dialogProps = {
+ body: _t(
+ "Every service and contract of this vehicle will be considered as archived. Are you sure that you want to archive this record?"
+ ),
+ confirm: () => this.model.root.archive(),
+ cancel: () => {},
+ };
+ this.dialogService.add(ConfirmationDialog, dialogProps);
+ };
+ return menuItems;
+ }
+}
+
+export const fleetFormView = {
+ ...formView,
+ Controller: FleetFormController,
+};
+
+registry.category("views").add("fleet_form", fleetFormView);
diff --git a/frontend/fleet/static/src/scss/fleet_form.scss b/frontend/fleet/static/src/scss/fleet_form.scss
new file mode 100644
index 0000000..a3dc4af
--- /dev/null
+++ b/frontend/fleet/static/src/scss/fleet_form.scss
@@ -0,0 +1,21 @@
+.o_fleet_form_view .o_form_renderer {
+
+ .oe_stat_button.text-warning,
+ .oe_stat_button.text-danger {
+ .o_stat_text {
+ color: $o-main-text-color;
+ }
+ }
+}
+
+.o_fleet_narrow_field {
+ max-width: 14rem;
+
+ .o_fleet_odometer_value {
+ max-width: 6rem;
+ }
+
+ .o_fleet_odometer_unit {
+ max-width: 3rem;
+ }
+}
diff --git a/frontend/hr/static/description/icon.png b/frontend/hr/static/description/icon.png
new file mode 100644
index 0000000..88e8f3d
Binary files /dev/null and b/frontend/hr/static/description/icon.png differ
diff --git a/frontend/hr/static/description/icon.svg b/frontend/hr/static/description/icon.svg
new file mode 100644
index 0000000..95b3e24
--- /dev/null
+++ b/frontend/hr/static/description/icon.svg
@@ -0,0 +1 @@
+
diff --git a/frontend/hr/static/description/icon_hi.png b/frontend/hr/static/description/icon_hi.png
new file mode 100644
index 0000000..ae5d373
Binary files /dev/null and b/frontend/hr/static/description/icon_hi.png differ
diff --git a/frontend/hr/static/img/employee-image.png b/frontend/hr/static/img/employee-image.png
new file mode 100644
index 0000000..db35d76
Binary files /dev/null and b/frontend/hr/static/img/employee-image.png differ
diff --git a/frontend/hr/static/img/employee_al-image.jpg b/frontend/hr/static/img/employee_al-image.jpg
new file mode 100644
index 0000000..abddef0
Binary files /dev/null and b/frontend/hr/static/img/employee_al-image.jpg differ
diff --git a/frontend/hr/static/img/employee_awa-image.jpg b/frontend/hr/static/img/employee_awa-image.jpg
new file mode 100644
index 0000000..1070377
Binary files /dev/null and b/frontend/hr/static/img/employee_awa-image.jpg differ
diff --git a/frontend/hr/static/img/employee_chs-image.jpg b/frontend/hr/static/img/employee_chs-image.jpg
new file mode 100644
index 0000000..ea4a22e
Binary files /dev/null and b/frontend/hr/static/img/employee_chs-image.jpg differ
diff --git a/frontend/hr/static/img/employee_fme-image.jpg b/frontend/hr/static/img/employee_fme-image.jpg
new file mode 100644
index 0000000..89f03a9
Binary files /dev/null and b/frontend/hr/static/img/employee_fme-image.jpg differ
diff --git a/frontend/hr/static/img/employee_fpi-image.jpg b/frontend/hr/static/img/employee_fpi-image.jpg
new file mode 100644
index 0000000..6dff1de
Binary files /dev/null and b/frontend/hr/static/img/employee_fpi-image.jpg differ
diff --git a/frontend/hr/static/img/employee_han-image.jpg b/frontend/hr/static/img/employee_han-image.jpg
new file mode 100644
index 0000000..8968d3c
Binary files /dev/null and b/frontend/hr/static/img/employee_han-image.jpg differ
diff --git a/frontend/hr/static/img/employee_hne-image.jpg b/frontend/hr/static/img/employee_hne-image.jpg
new file mode 100644
index 0000000..402ac41
Binary files /dev/null and b/frontend/hr/static/img/employee_hne-image.jpg differ
diff --git a/frontend/hr/static/img/employee_jep-image.jpg b/frontend/hr/static/img/employee_jep-image.jpg
new file mode 100644
index 0000000..98fa7b1
Binary files /dev/null and b/frontend/hr/static/img/employee_jep-image.jpg differ
diff --git a/frontend/hr/static/img/employee_jgo-image.jpg b/frontend/hr/static/img/employee_jgo-image.jpg
new file mode 100644
index 0000000..dfc34c2
Binary files /dev/null and b/frontend/hr/static/img/employee_jgo-image.jpg differ
diff --git a/frontend/hr/static/img/employee_jod-image.jpg b/frontend/hr/static/img/employee_jod-image.jpg
new file mode 100644
index 0000000..3e89648
Binary files /dev/null and b/frontend/hr/static/img/employee_jod-image.jpg differ
diff --git a/frontend/hr/static/img/employee_jog-image.jpg b/frontend/hr/static/img/employee_jog-image.jpg
new file mode 100644
index 0000000..0d810bd
Binary files /dev/null and b/frontend/hr/static/img/employee_jog-image.jpg differ
diff --git a/frontend/hr/static/img/employee_jth-image.jpg b/frontend/hr/static/img/employee_jth-image.jpg
new file mode 100644
index 0000000..d4ec655
Binary files /dev/null and b/frontend/hr/static/img/employee_jth-image.jpg differ
diff --git a/frontend/hr/static/img/employee_jve-image.jpg b/frontend/hr/static/img/employee_jve-image.jpg
new file mode 100644
index 0000000..2818c5d
Binary files /dev/null and b/frontend/hr/static/img/employee_jve-image.jpg differ
diff --git a/frontend/hr/static/img/employee_lur-image.jpg b/frontend/hr/static/img/employee_lur-image.jpg
new file mode 100644
index 0000000..0b32267
Binary files /dev/null and b/frontend/hr/static/img/employee_lur-image.jpg differ
diff --git a/frontend/hr/static/img/employee_mit-image.jpg b/frontend/hr/static/img/employee_mit-image.jpg
new file mode 100644
index 0000000..b6dd066
Binary files /dev/null and b/frontend/hr/static/img/employee_mit-image.jpg differ
diff --git a/frontend/hr/static/img/employee_ngh-image.jpg b/frontend/hr/static/img/employee_ngh-image.jpg
new file mode 100644
index 0000000..fc9f3fa
Binary files /dev/null and b/frontend/hr/static/img/employee_ngh-image.jpg differ
diff --git a/frontend/hr/static/img/employee_niv-image.jpg b/frontend/hr/static/img/employee_niv-image.jpg
new file mode 100644
index 0000000..8d0815e
Binary files /dev/null and b/frontend/hr/static/img/employee_niv-image.jpg differ
diff --git a/frontend/hr/static/img/employee_qdp-image.png b/frontend/hr/static/img/employee_qdp-image.png
new file mode 100644
index 0000000..87caf31
Binary files /dev/null and b/frontend/hr/static/img/employee_qdp-image.png differ
diff --git a/frontend/hr/static/img/employee_stw-image.jpg b/frontend/hr/static/img/employee_stw-image.jpg
new file mode 100644
index 0000000..02fa1ca
Binary files /dev/null and b/frontend/hr/static/img/employee_stw-image.jpg differ
diff --git a/frontend/hr/static/img/employee_vad-image.jpg b/frontend/hr/static/img/employee_vad-image.jpg
new file mode 100644
index 0000000..97466f8
Binary files /dev/null and b/frontend/hr/static/img/employee_vad-image.jpg differ
diff --git a/frontend/hr/static/img/partner_root-image.jpg b/frontend/hr/static/img/partner_root-image.jpg
new file mode 100644
index 0000000..6438e80
Binary files /dev/null and b/frontend/hr/static/img/partner_root-image.jpg differ
diff --git a/frontend/hr/static/src/@types/models.d.ts b/frontend/hr/static/src/@types/models.d.ts
new file mode 100644
index 0000000..47d66c0
--- /dev/null
+++ b/frontend/hr/static/src/@types/models.d.ts
@@ -0,0 +1,5 @@
+declare module "models" {
+ export interface Store {
+ employees: {[key: number]: {id: number, user_id: number, hasCheckedUser: boolean}};
+ }
+}
diff --git a/frontend/hr/static/src/components/avatar_card/avatar_card_popover_patch.js b/frontend/hr/static/src/components/avatar_card/avatar_card_popover_patch.js
new file mode 100644
index 0000000..a720776
--- /dev/null
+++ b/frontend/hr/static/src/components/avatar_card/avatar_card_popover_patch.js
@@ -0,0 +1,28 @@
+import { patch } from "@web/core/utils/patch";
+import { AvatarCardPopover } from "@mail/discuss/web/avatar_card/avatar_card_popover";
+import { useService } from "@web/core/utils/hooks";
+
+export const patchAvatarCardPopover = {
+ setup() {
+ super.setup();
+ this.orm = useService("orm");
+ this.userInfoTemplate = "hr.avatarCardUserInfos";
+ },
+ get email() {
+ return this.employeeId?.work_email || super.email;
+ },
+ get phone() {
+ return this.employeeId?.work_phone || super.phone;
+ },
+ get employeeId() {
+ return this.partner?.employee_id;
+ },
+ async getProfileAction() {
+ if (!this.employeeId) {
+ return super.getProfileAction(...arguments);
+ }
+ return this.orm.call("hr.employee", "get_formview_action", [this.employeeId.id]);
+ },
+};
+
+export const unpatchAvatarCardPopover = patch(AvatarCardPopover.prototype, patchAvatarCardPopover);
diff --git a/frontend/hr/static/src/components/avatar_card/avatar_card_popover_patch.xml b/frontend/hr/static/src/components/avatar_card/avatar_card_popover_patch.xml
new file mode 100644
index 0000000..12e979d
--- /dev/null
+++ b/frontend/hr/static/src/components/avatar_card/avatar_card_popover_patch.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/hr/static/src/components/avatar_card_employee/avatar_card_employee_popover.js b/frontend/hr/static/src/components/avatar_card_employee/avatar_card_employee_popover.js
new file mode 100644
index 0000000..d78bb94
--- /dev/null
+++ b/frontend/hr/static/src/components/avatar_card_employee/avatar_card_employee_popover.js
@@ -0,0 +1,17 @@
+import { AvatarCardResourcePopover } from "@resource_mail/components/avatar_card_resource/avatar_card_resource_popover";
+
+export class AvatarCardEmployeePopover extends AvatarCardResourcePopover {
+ static defaultProps = {
+ ...AvatarCardResourcePopover.defaultProps,
+ recordModel: "hr.employee",
+ };
+ async onWillStart() {
+ await super.onWillStart();
+ this.record.employee_id = [this.props.id];
+ }
+
+ get fieldNames() {
+ const excludedFields = ["employee_id", "resource_type"];
+ return super.fieldNames.filter((field) => !excludedFields.includes(field));
+ }
+}
diff --git a/frontend/hr/static/src/components/avatar_card_resource/avatar_card_resource_popover.xml b/frontend/hr/static/src/components/avatar_card_resource/avatar_card_resource_popover.xml
new file mode 100644
index 0000000..9abb579
--- /dev/null
+++ b/frontend/hr/static/src/components/avatar_card_resource/avatar_card_resource_popover.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ this.record.employee_id?.length
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/hr/static/src/components/avatar_card_resource/avatar_card_resource_popover_patch.js b/frontend/hr/static/src/components/avatar_card_resource/avatar_card_resource_popover_patch.js
new file mode 100644
index 0000000..9a47c0d
--- /dev/null
+++ b/frontend/hr/static/src/components/avatar_card_resource/avatar_card_resource_popover_patch.js
@@ -0,0 +1,49 @@
+import { patch } from "@web/core/utils/patch";
+import { AvatarCardResourcePopover } from "@resource_mail/components/avatar_card_resource/avatar_card_resource_popover";
+import { useService } from "@web/core/utils/hooks";
+import { TagsList } from "@web/core/tags_list/tags_list";
+
+const patchAvatarCardResourcePopover = {
+ setup() {
+ super.setup();
+ (this.userInfoTemplate = "hr.avatarCardResourceInfos"),
+ (this.actionService = useService("action"));
+ },
+ get fieldNames() {
+ return [
+ ...super.fieldNames,
+ "department_id",
+ this.props.recordModel ? "employee_id" : "employee_ids",
+ "hr_icon_display",
+ "job_title",
+ "show_hr_icon_display",
+ "work_email",
+ "work_location_id",
+ "work_phone",
+ ];
+ },
+ get email() {
+ return this.record.work_email || this.record.email;
+ },
+ get phone() {
+ return this.record.work_phone || this.record.phone;
+ },
+ get showViewProfileBtn() {
+ return this.record.employee_id?.length > 0;
+ },
+ async getProfileAction() {
+ return await this.orm.call("hr.employee", "get_formview_action", [
+ this.record.employee_id[0],
+ ]);
+ },
+};
+
+patch(AvatarCardResourcePopover.prototype, patchAvatarCardResourcePopover);
+// Adding TagsList component allows display tag lists on the resource/employee avatar card
+// This is used by multiple modules depending on hr (planning for roles and hr_skills for skills)
+patch(AvatarCardResourcePopover, {
+ components: {
+ ...AvatarCardResourcePopover.components,
+ TagsList,
+ },
+});
diff --git a/frontend/hr/static/src/components/avatar_employee/avatar_employee.js b/frontend/hr/static/src/components/avatar_employee/avatar_employee.js
new file mode 100644
index 0000000..83af43d
--- /dev/null
+++ b/frontend/hr/static/src/components/avatar_employee/avatar_employee.js
@@ -0,0 +1,13 @@
+import { Avatar } from "@mail/views/web/fields/avatar/avatar";
+import { AvatarCardEmployeePopover } from "../avatar_card_employee/avatar_card_employee_popover";
+
+export class AvatarEmployee extends Avatar {
+ static components = { ...super.components, Popover: AvatarCardEmployeePopover };
+
+ get popoverProps() {
+ return {
+ ...super.popoverProps,
+ recordModel: this.props.resModel,
+ };
+ }
+}
diff --git a/frontend/hr/static/src/components/background_image/background_image.js b/frontend/hr/static/src/components/background_image/background_image.js
new file mode 100644
index 0000000..79accf0
--- /dev/null
+++ b/frontend/hr/static/src/components/background_image/background_image.js
@@ -0,0 +1,14 @@
+import { registry } from '@web/core/registry';
+
+import { ImageField, imageField } from '@web/views/fields/image/image_field';
+
+export class BackgroundImageField extends ImageField {
+ static template = "hr.BackgroundImage";
+}
+
+export const backgroundImageField = {
+ ...imageField,
+ component: BackgroundImageField,
+};
+
+registry.category("fields").add("background_image", backgroundImageField);
diff --git a/frontend/hr/static/src/components/background_image/background_image.scss b/frontend/hr/static/src/components/background_image/background_image.scss
new file mode 100644
index 0000000..877c6ed
--- /dev/null
+++ b/frontend/hr/static/src/components/background_image/background_image.scss
@@ -0,0 +1,21 @@
+div.o_field_widget.o_field_background_image {
+ display: inline-block;
+
+ @include media-breakpoint-down(md) {
+ height: calc(100% + var(--KanbanRecord-padding-v)* 2);
+ margin-top: calc(var(--KanbanRecord-padding-v)* -1);
+ margin-bottom: calc(var(--KanbanRecord-padding-v)* -1);
+ margin-left: calc(var(--KanbanRecord-padding-h)* -1);
+ }
+
+ > img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center;
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+}
diff --git a/frontend/hr/static/src/components/background_image/background_image.xml b/frontend/hr/static/src/components/background_image/background_image.xml
new file mode 100644
index 0000000..e929353
--- /dev/null
+++ b/frontend/hr/static/src/components/background_image/background_image.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/frontend/hr/static/src/components/button_new_contract/button_new_contract.js b/frontend/hr/static/src/components/button_new_contract/button_new_contract.js
new file mode 100644
index 0000000..e3871be
--- /dev/null
+++ b/frontend/hr/static/src/components/button_new_contract/button_new_contract.js
@@ -0,0 +1,68 @@
+import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
+import { serializeDate } from "@web/core/l10n/dates";
+import { registry } from "@web/core/registry";
+import { useService } from "@web/core/utils/hooks";
+import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
+import { Component } from "@odoo/owl";
+
+export class ButtonNewContractWidget extends Component {
+ static template = "hr.ButtonNewContract";
+ static props = {
+ ...standardWidgetProps,
+ };
+
+ /** @override **/
+ setup() {
+ super.setup();
+ this.orm = useService("orm");
+
+ this.dateTimePicker = useDateTimePicker({
+ target: `datetime-picker-target-new-contract`,
+ onApply: (date) => {
+ if (date) {
+ this.tryAndCreateContract(serializeDate(date));
+ }
+ },
+ get pickerProps() {
+ return { type: "date" };
+ },
+ });
+ }
+
+ async onClickNewContractBtn() {
+ await this.props.record.save();
+ await this.orm.call("hr.version", "check_contract_finished", [
+ [this.props.record.data.version_id.id],
+ ]);
+ this.dateTimePicker.open();
+ }
+
+ async tryAndCreateContract(date) {
+ await this.orm.call("hr.employee", "check_no_existing_contract", [
+ [this.props.record.resId],
+ date,
+ ]);
+ const contract = await this.orm.call("hr.employee", "create_contract", [
+ [this.props.record.resId],
+ date,
+ ]);
+ await this.loadVersion(contract);
+ }
+
+ async loadVersion(version_id) {
+ const { record } = this.props;
+ await record.save();
+ await this.props.record.model.load({
+ context: {
+ ...this.props.record.model.env.searchModel.context,
+ version_id: version_id,
+ },
+ });
+ }
+}
+
+export const buttonNewContractWidget = {
+ component: ButtonNewContractWidget,
+};
+
+registry.category("view_widgets").add("button_new_contract", buttonNewContractWidget);
diff --git a/frontend/hr/static/src/components/button_new_contract/button_new_contract.xml b/frontend/hr/static/src/components/button_new_contract/button_new_contract.xml
new file mode 100644
index 0000000..8abdd47
--- /dev/null
+++ b/frontend/hr/static/src/components/button_new_contract/button_new_contract.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/hr/static/src/components/department_chart/department_chart.js b/frontend/hr/static/src/components/department_chart/department_chart.js
new file mode 100644
index 0000000..4ef4f5a
--- /dev/null
+++ b/frontend/hr/static/src/components/department_chart/department_chart.js
@@ -0,0 +1,48 @@
+import { registry } from "@web/core/registry";
+import { useService } from "@web/core/utils/hooks";
+
+import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
+import { onWillStart, useState, onWillUpdateProps, Component } from "@odoo/owl";
+
+export class DepartmentChart extends Component {
+ static template = "hr.DepartmentChart";
+ static props = {
+ ...standardWidgetProps,
+ };
+
+ setup() {
+ super.setup();
+
+ this.action = useService("action");
+ this.orm = useService("orm");
+ this.state = useState({
+ hierarchy: {},
+ });
+ onWillStart(async () => await this.fetchHierarchy(this.props.record.resId));
+
+ onWillUpdateProps(async (nextProps) => {
+ await this.fetchHierarchy(nextProps.record.resId);
+ });
+ }
+
+ async fetchHierarchy(departmentId) {
+ this.state.hierarchy = await this.orm.call("hr.department", "get_department_hierarchy", [
+ departmentId,
+ ]);
+ }
+
+ async openDepartmentEmployees(departmentId) {
+ const dialogAction = await this.orm.call(
+ this.props.record.resModel,
+ "action_employee_from_department",
+ [departmentId],
+ {}
+ );
+ this.action.doAction(dialogAction);
+ }
+}
+
+export const departmentChart = {
+ component: DepartmentChart,
+};
+registry.category("view_widgets").add("hr_department_chart", departmentChart);
diff --git a/frontend/hr/static/src/components/department_chart/department_chart.scss b/frontend/hr/static/src/components/department_chart/department_chart.scss
new file mode 100644
index 0000000..b6b25fc
--- /dev/null
+++ b/frontend/hr/static/src/components/department_chart/department_chart.scss
@@ -0,0 +1,16 @@
+.o_widget_hr_department_chart {
+ .o_hr_department_chart {
+ .o_hr_department_chart_self {
+ .department_name {
+ font-weight: bold;
+ }
+ }
+
+ @include media-breakpoint-up(sm) {
+ width: 50%;
+ }
+ @include media-breakpoint-down(md) {
+ flex-grow: 1;
+ }
+ }
+}
diff --git a/frontend/hr/static/src/components/department_chart/department_chart.xml b/frontend/hr/static/src/components/department_chart/department_chart.xml
new file mode 100644
index 0000000..0b6d334
--- /dev/null
+++ b/frontend/hr/static/src/components/department_chart/department_chart.xml
@@ -0,0 +1,48 @@
+
+
+
+
+ Use the state to inform your colleagues that a task is approved for the next stage.
+
+ Use the state to indicate a request for changes or a need for discussion on a task.
+
+ Use the state to mark the task as complete.
+
+ Use the state to mark the task as cancelled.
+
+ Look for the icon to see tasks waiting on other ones. Once a task is marked as complete or cancelled, all of its dependencies will be unblocked.
+
+ Use the icon to organize your daily activities.
+
+
+
+ Use the state to inform your colleagues that a task is approved for the next stage.
+
+ Use the state to indicate a request for changes or a need for discussion on a task.
+
+ Use the icon to organize your daily activities.
+
+ Sort your tasks by sprint using milestones, tags, or a dedicated property. At the end of each sprint, just pick the remaining tasks in your list and move them all at once to the next sprint by editing the milestone, tag, or property.
+
+
+
+ Everyone can propose ideas, and the Editor marks the best ones as .
+ Attach all documents or links to the task directly, to have all research information centralized.
+
+ Use the icon to organize your daily activities.
+
+
+
+ Customers propose feedbacks by email; Odoo creates tasks automatically, and you can
+ communicate on the task directly. Your managers decide which feedback is accepted
+ and which feedback is
+ moved to the "Refused" column.
+
+ Use the icon to organize your daily activities.
+
+
+
+ Manage the lifecycle of your project using the kanban view. Add newly acquired projects,
+ assign them and use the and
+ to define if the project is
+ ready for the next step.
+
+ Use the icon to organize your daily activities.
+
+
+
+ Handle your idea gathering within Tasks of your new Project and discuss them in the chatter of the tasks. Use the
+ and
+ to signalize what is the current status of your Idea.
+
+ Use the icon to organize your daily activities.
+
+
+
+ Communicate with customers on the task using the email gateway. Attach logo designs to the task, so that information flows from
+ designers to the workers who print the t-shirt. Organize priorities amongst orders using the
+ icon.
+
+ Use the icon to organize your daily activities.
+
+
+
diff --git a/frontend/project/static/tests/mock_server/mock_models/project_task.js b/frontend/project/static/tests/mock_server/mock_models/project_task.js
new file mode 100644
index 0000000..953655e
--- /dev/null
+++ b/frontend/project/static/tests/mock_server/mock_models/project_task.js
@@ -0,0 +1,9 @@
+import { models } from "@web/../tests/web_test_helpers";
+
+export class ProjectTask extends models.ServerModel {
+ _name = "project.task";
+
+ plan_task_in_calendar(idOrIds, values) {
+ return this.write(idOrIds, values);
+ }
+}
diff --git a/frontend/project/static/tests/project_is_favorite_field.test.js b/frontend/project/static/tests/project_is_favorite_field.test.js
new file mode 100644
index 0000000..71644fb
--- /dev/null
+++ b/frontend/project/static/tests/project_is_favorite_field.test.js
@@ -0,0 +1,71 @@
+import { beforeEach, expect, test } from "@odoo/hoot";
+import { click } from "@odoo/hoot-dom";
+import { animationFrame } from "@odoo/hoot-mock";
+
+import { mountView, onRpc } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectProject } from "./project_models";
+
+defineProjectModels();
+beforeEach(() => {
+ ProjectProject._records = [
+ {
+ id: 1,
+ name: "Project A",
+ },
+ ];
+ ProjectProject._views = {
+ kanban: `
+
+
+
+
+
+
+
+
+ `,
+ };
+});
+
+test("Check is_favorite field is still editable even if the record/view is in readonly.", async () => {
+ onRpc("project.project", "web_save", ({ args }) => {
+ const [ids, vals] = args;
+ expect(ids).toEqual([1]);
+ expect(vals).toEqual({ is_favorite: true });
+ expect.step("web_save");
+ });
+
+ await mountView({
+ resModel: "project.project",
+ type: "kanban",
+ });
+
+ expect("div[name=is_favorite] .o_favorite").toHaveCount(1);
+ expect.verifySteps([]);
+ await click("div[name=is_favorite] .o_favorite");
+ await animationFrame();
+ expect.verifySteps(["web_save"]);
+});
+
+test("Check is_favorite field is readonly if the field is readonly", async () => {
+ onRpc("project.project", "web_save", () => {
+ expect.step("web_save");
+ });
+
+ ProjectProject._views["kanban"] = ProjectProject._views["kanban"].replace(
+ 'widget="project_is_favorite"',
+ 'widget="project_is_favorite" readonly="1"'
+ );
+
+ await mountView({
+ resModel: "project.project",
+ type: "kanban",
+ });
+
+ expect("div[name=is_favorite] .o_favorite").toHaveCount(1);
+ expect.verifySteps([]);
+ await click("div[name=is_favorite] .o_favorite");
+ await animationFrame();
+ expect.verifySteps([]);
+});
diff --git a/frontend/project/static/tests/project_models.js b/frontend/project/static/tests/project_models.js
new file mode 100644
index 0000000..59ff6d4
--- /dev/null
+++ b/frontend/project/static/tests/project_models.js
@@ -0,0 +1,192 @@
+import { defineMailModels } from "@mail/../tests/mail_test_helpers";
+import { defineModels, fields, models } from "@web/../tests/web_test_helpers";
+
+export class ProjectProject extends models.Model {
+ _name = "project.project";
+
+ name = fields.Char();
+ is_favorite = fields.Boolean();
+ is_template = fields.Boolean();
+ active = fields.Boolean({ default: true });
+ stage_id = fields.Many2one({ relation: "project.project.stage" });
+ date = fields.Date({ string: "Expiration Date" });
+ date_start = fields.Date();
+ user_id = fields.Many2one({ relation: "res.users", falsy_value_label: "👤 Unassigned" });
+ allow_task_dependencies = fields.Boolean({ string: "Task Dependencies", default: false });
+ allow_milestones = fields.Boolean({ string: "Milestones", default: false });
+ allow_recurring_tasks = fields.Boolean({ string: "Recurring Tasks", default: false });
+
+ _records = [
+ {
+ id: 1,
+ name: "Project 1",
+ stage_id: 1,
+ date: "2024-01-09 07:00:00",
+ date_start: "2024-01-03 12:00:00",
+ },
+ { id: 2, name: "Project 2", stage_id: 2 },
+ ];
+
+ _views = {
+ list: '',
+ form: '',
+ };
+
+ check_access_rights() {
+ return Promise.resolve(true);
+ }
+
+ get_template_tasks(projectId) {
+ return this.env["project.task"].search_read(
+ [
+ ["project_id", "=", projectId],
+ ["is_template", "=", true],
+ ],
+ ["id", "name"]
+ );
+ }
+
+ check_features_enabled() {
+ let allow_task_dependencies = false;
+ let allow_milestones = false;
+ let allow_recurring_tasks = false;
+ for (const record of this) {
+ if (record.allow_task_dependencies) {
+ allow_task_dependencies = true;
+ }
+ if (record.allow_milestones) {
+ allow_milestones = true;
+ }
+ if (record.allow_recurring_tasks) {
+ allow_recurring_tasks = true;
+ }
+ }
+ return { allow_task_dependencies, allow_milestones, allow_recurring_tasks };
+ }
+}
+
+export class ProjectProjectStage extends models.Model {
+ _name = "project.project.stage";
+
+ name = fields.Char();
+
+ _records = [
+ { id: 1, name: "Stage 1" },
+ { id: 2, name: "Stage 2" },
+ ];
+
+ _views = {
+ list: '',
+ form: '',
+ };
+}
+
+export class ProjectTask extends models.Model {
+ _name = "project.task";
+
+ name = fields.Char();
+ parent_id = fields.Many2one({ relation: "project.task" });
+ child_ids = fields.One2many({
+ relation: "project.task",
+ relation_field: "parent_id",
+ });
+ subtask_count = fields.Integer();
+ sequence = fields.Integer({ string: "Sequence", default: 10 });
+ closed_subtask_count = fields.Integer();
+ project_id = fields.Many2one({ relation: "project.project", falsy_value_label: "🔒 Private" });
+ display_in_project = fields.Boolean({ default: true });
+ stage_id = fields.Many2one({ relation: "project.task.type" });
+ milestone_id = fields.Many2one({ relation: "project.milestone" });
+ state = fields.Selection({
+ selection: [
+ ["01_in_progress", "In Progress"],
+ ["02_changes_requested", "Changes Requested"],
+ ["03_approved", "Approved"],
+ ["1_canceled", "Cancelled"],
+ ["1_done", "Done"],
+ ["04_waiting_normal", "Waiting Normal"],
+ ],
+ });
+ user_ids = fields.Many2many({
+ string: "Assignees",
+ relation: "res.users",
+ falsy_value_label: "👤 Unassigned",
+ });
+ priority = fields.Selection({
+ selection: [
+ ["0", "Low"],
+ ["1", "High"],
+ ],
+ });
+ partner_id = fields.Many2one({ string: "Partner", relation: "res.partner" });
+ planned_date_begin = fields.Datetime({ string: "Start Date" });
+ date_deadline = fields.Datetime({ string: "Stop Date" });
+ depend_on_ids = fields.Many2many({ relation: "project.task" });
+ closed_depend_on_count = fields.Integer();
+ is_closed = fields.Boolean();
+ is_template = fields.Boolean({ string: "Is Template", default: false });
+
+ plan_task_in_calendar(idOrIds, values) {
+ return this.write(idOrIds, values);
+ }
+
+ _records = [
+ {
+ id: 1,
+ name: "Regular task 1",
+ project_id: 1,
+ stage_id: 1,
+ milestone_id: 1,
+ state: "01_in_progress",
+ user_ids: [7],
+ },
+ {
+ id: 2,
+ name: "Regular task 2",
+ project_id: 1,
+ stage_id: 1,
+ state: "03_approved",
+ },
+ {
+ id: 3,
+ name: "Private task 1",
+ project_id: false,
+ stage_id: 1,
+ state: "04_waiting_normal",
+ },
+ ];
+}
+
+export class ProjectTaskType extends models.Model {
+ _name = "project.task.type";
+
+ name = fields.Char();
+ sequence = fields.Integer();
+
+ _records = [
+ { id: 1, name: "Todo" },
+ { id: 2, name: "In Progress" },
+ { id: 3, name: "Done" },
+ ];
+}
+
+export class ProjectMilestone extends models.Model {
+ _name = "project.milestone";
+
+ name = fields.Char();
+
+ _records = [{ id: 1, name: "Milestone 1" }];
+}
+
+export function defineProjectModels() {
+ defineMailModels();
+ defineModels(projectModels);
+}
+
+export const projectModels = {
+ ProjectProject,
+ ProjectProjectStage,
+ ProjectTask,
+ ProjectTaskType,
+ ProjectMilestone,
+};
diff --git a/frontend/project/static/tests/project_notebook_task_list.test.js b/frontend/project/static/tests/project_notebook_task_list.test.js
new file mode 100644
index 0000000..58f6868
--- /dev/null
+++ b/frontend/project/static/tests/project_notebook_task_list.test.js
@@ -0,0 +1,154 @@
+import { beforeEach, expect, describe, test } from "@odoo/hoot";
+import { click } from "@odoo/hoot-dom";
+import { animationFrame } from "@odoo/hoot-mock";
+import { mountView } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectProject, ProjectTask } from "./project_models";
+
+defineProjectModels();
+
+describe.current.tags("desktop");
+
+beforeEach(() => {
+ ProjectProject._records = [
+ {
+ id:5,
+ name: "Project One"
+ },
+ ];
+
+ ProjectTask._records = [
+ {
+ id: 1,
+ name: 'task one',
+ project_id: 5,
+ closed_subtask_count: 1,
+ closed_depend_on_count: 1,
+ subtask_count: 4,
+ child_ids: [2, 3, 4, 7],
+ depend_on_ids: [5,6],
+ state: '04_waiting_normal',
+ },
+ {
+ name: 'task two',
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: '03_approved'
+ },
+ {
+ name: 'task three',
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: '02_changes_requested'
+ },
+ {
+ name: 'task four',
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: '1_done'
+ },
+ {
+ name: 'task five',
+ closed_subtask_count: 0,
+ subtask_count: 1,
+ child_ids: [6],
+ depend_on_ids: [],
+ state: '03_approved'
+ },
+ {
+ name: 'task six',
+ parent_id: 5,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: '1_canceled'
+ },
+ {
+ name: 'task seven',
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: '01_in_progress',
+ },
+ ];
+
+ ProjectTask._views = {
+ form: `
+
+ `,
+ };
+});
+
+test("test Project Task Calendar Popover with task_stage_with_state_selection widget", async () => {
+ await mountView({
+ resModel: "project.task",
+ type: "form",
+ resId: 1,
+ });
+
+ expect('div[name="child_ids"] .o_data_row').toHaveCount(4, {
+ message: "The subtasks list should display all subtasks by default, thus we are looking for 4 in total"
+ });
+ expect('div[name="depend_on_ids"] .o_data_row').toHaveCount(2, {
+ message: "The depend on tasks list should display all blocking tasks by default, thus we are looking for 2 in total"
+ });
+
+ expect("div[name='child_ids'] .o_field_x2many_list_row_add a.o_toggle_closed_task_button").toHaveText("Hide closed tasks");
+ expect("div[name='depend_on_ids'] .o_field_x2many_list_row_add a.o_toggle_closed_task_button").toHaveText("Hide closed tasks");
+
+ await click("div[name='child_ids'] .o_field_x2many_list_row_add a.o_toggle_closed_task_button");
+ await animationFrame();
+
+ expect("div[name='child_ids'] .o_field_x2many_list_row_add a.o_toggle_closed_task_button").toHaveText("Show closed tasks");
+ expect("div[name='depend_on_ids'] .o_field_x2many_list_row_add a.o_toggle_closed_task_button").toHaveText("Hide closed tasks");
+
+ expect('div[name="child_ids"] .o_data_row').toHaveCount(3, {
+ message: "The subtasks list should only display the open subtasks of the task, in this case 3"
+ });
+ expect('div[name="depend_on_ids"] .o_data_row').toHaveCount(2, {
+ message: "The depend on tasks list should still display all blocking tasks, in this case 2"
+ });
+
+ await click("div[name='depend_on_ids'] .o_field_x2many_list_row_add a.o_toggle_closed_task_button");
+ await animationFrame();
+
+ expect("div[name='child_ids'] .o_field_x2many_list_row_add a.o_toggle_closed_task_button").toHaveText("Show closed tasks");
+ expect("div[name='depend_on_ids'] .o_field_x2many_list_row_add a.o_toggle_closed_task_button").toHaveText("1 closed tasks");
+
+ expect('div[name="child_ids"] .o_data_row').toHaveCount(3, {
+ message: "The subtasks list should only display the open subtasks of the task, in this case 3"
+ });
+ expect('div[name="depend_on_ids"] .o_data_row').toHaveCount(1, {
+ message: "The depend on tasks list should only display open tasks, in this case 1"
+ });
+});
diff --git a/frontend/project/static/tests/project_notebook_task_list_mobile.test.js b/frontend/project/static/tests/project_notebook_task_list_mobile.test.js
new file mode 100644
index 0000000..e160dc0
--- /dev/null
+++ b/frontend/project/static/tests/project_notebook_task_list_mobile.test.js
@@ -0,0 +1,178 @@
+import { beforeEach, expect, describe, test } from "@odoo/hoot";
+import { animationFrame, click } from "@odoo/hoot-dom";
+import { getService, mountWithCleanup } from "@web/../tests/web_test_helpers";
+import { WebClient } from "@web/webclient/webclient";
+
+import { defineProjectModels, ProjectProject, ProjectTask } from "./project_models";
+
+defineProjectModels();
+
+describe.current.tags("mobile");
+
+beforeEach(() => {
+ ProjectProject._records = [
+ {
+ id: 5,
+ name: "Project One",
+ },
+ ];
+
+ ProjectTask._records = [
+ {
+ id: 1,
+ name: "task one",
+ project_id: 5,
+ closed_subtask_count: 1,
+ closed_depend_on_count: 1,
+ subtask_count: 4,
+ child_ids: [2, 3, 4, 7],
+ depend_on_ids: [5, 6],
+ state: "04_waiting_normal",
+ },
+ {
+ id: 2,
+ name: "task two",
+ project_id: 5,
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: "03_approved",
+ },
+ {
+ id: 3,
+ name: "task three",
+ project_id: 5,
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: "02_changes_requested",
+ },
+ {
+ id: 4,
+ name: "task four",
+ project_id: 5,
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: "1_done",
+ },
+ {
+ id: 5,
+ name: "task five",
+ project_id: 5,
+ closed_subtask_count: 0,
+ subtask_count: 1,
+ child_ids: [6],
+ depend_on_ids: [],
+ state: "03_approved",
+ },
+ {
+ id: 6,
+ name: "task six",
+ project_id: 5,
+ parent_id: 5,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: "1_canceled",
+ },
+ {
+ id: 7,
+ name: "task seven",
+ project_id: 5,
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ depend_on_ids: [],
+ state: "01_in_progress",
+ },
+ ];
+
+ ProjectTask._views = {
+ form: `
+
+ `,
+ search: ``,
+ };
+});
+
+test("test open subtask in form view instead of form view dialog", async () => {
+ await mountWithCleanup(WebClient);
+
+ await getService("action").doAction({
+ name: "Tasks",
+ res_model: "project.task",
+ type: "ir.actions.act_window",
+ res_id: 1,
+ views: [[false, "form"]],
+ });
+
+ expect("div[name='name'] input").toHaveValue("task one");
+ expect("div[name='child_ids'] .o_kanban_record:not(.o_kanban_ghost,.o-kanban-button-new)").toHaveCount(4, {
+ message:
+ "The subtasks list should display all subtasks by default, thus we are looking for 4 in total",
+ });
+
+ await click("div[name='child_ids'] .o_kanban_record:first-child");
+ await animationFrame();
+ expect(document.body).not.toHaveClass("modal-open");
+ expect("div[name='name'] input").toHaveValue("task two");
+});
+
+test("test open task dependencies in form view instead of form view dialog", async () => {
+ await mountWithCleanup(WebClient);
+
+ await getService("action").doAction({
+ name: "Tasks",
+ res_model: "project.task",
+ type: "ir.actions.act_window",
+ res_id: 1,
+ views: [[false, "form"]],
+ });
+
+ expect("div[name='name'] input").toHaveValue("task one");
+ expect("div[name='depend_on_ids'] .o_kanban_record:not(.o_kanban_ghost,.o-kanban-button-new)").toHaveCount(2, {
+ message:
+ "The depend on tasks list should display all blocking tasks by default, thus we are looking for 2 in total",
+ });
+ await click("div[name='depend_on_ids'] .o_kanban_record:first-child");
+ await animationFrame();
+ expect(document.body).not.toHaveClass("modal-open");
+ expect("div[name='name'] input").toHaveValue("task five");
+});
diff --git a/frontend/project/static/tests/project_project.test.js b/frontend/project/static/tests/project_project.test.js
new file mode 100644
index 0000000..c568d1b
--- /dev/null
+++ b/frontend/project/static/tests/project_project.test.js
@@ -0,0 +1,68 @@
+import { expect, test, describe } from "@odoo/hoot";
+import { animationFrame } from "@odoo/hoot-mock";
+import {
+ mountView,
+ onRpc,
+ contains,
+ toggleKanbanColumnActions
+} from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels } from "./project_models";
+
+defineProjectModels();
+describe.current.tags("desktop");
+
+const listViewParams = {
+ resModel: "project.project",
+ type: "list",
+ actionMenus: {},
+ arch: `
+
+
+
+ `,
+}
+
+test("project.project (list) show archive/unarchive action for project manager", async () => {
+ onRpc("has_group", ({ args }) => args[1] === "project.group_project_manager");
+ await mountView(listViewParams);
+ await contains("input.form-check-input").click();
+ await contains(`.o_cp_action_menus .dropdown-toggle`).click();
+ expect(`.oi-archive`).toHaveCount(1, { message: "Archive action should be visible" });
+ expect(`.oi-unarchive`).toHaveCount(1, { message: "Unarchive action should be visible" });
+});
+
+test("project.project (list) hide archive/unarchive action for project user", async () => {
+ onRpc("has_group", ({ args }) => args[1] === "project.group_project_user");
+ await mountView(listViewParams);
+ await contains("input.form-check-input").click();
+ await contains(`.o_cp_action_menus .dropdown-toggle`).click();
+ expect(`.o-dropdown--menu span:contains(Archive)`).toHaveCount(0, { message: "Archive action should not be visible" });
+ expect(`.o-dropdown--menu span:contains(Unarchive)`).toHaveCount(0, { message: "Unarchive action should not be visible" });
+});
+
+test("project.project (kanban) hide archive/unarchive action for project user", async () => {
+ onRpc("has_group", ({ args }) => args[1] === "project.group_project_user");
+ await mountView({
+ resModel: "project.project",
+ type: "kanban",
+ actionMenus: {},
+ arch: `
+
+
+
+
+
+
+
+
+
+
+ `,
+ groupBy: ['stage_id']
+ });
+ toggleKanbanColumnActions();
+ await animationFrame();
+ await expect('.o_column_archive_records').toHaveCount(0, { message: "Archive action should not be visible" });
+ await expect('.o_column_unarchive_records').toHaveCount(0, { message: "Unarchive action should not be visible" });
+});
diff --git a/frontend/project/static/tests/project_project_calendar.test.js b/frontend/project/static/tests/project_project_calendar.test.js
new file mode 100644
index 0000000..7f86860
--- /dev/null
+++ b/frontend/project/static/tests/project_project_calendar.test.js
@@ -0,0 +1,44 @@
+import { expect, test, describe } from "@odoo/hoot";
+import { mockDate, runAllTimers } from "@odoo/hoot-mock";
+import { click, queryAllTexts } from "@odoo/hoot-dom";
+
+import { mountView, onRpc } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels } from "./project_models";
+
+describe.current.tags("desktop");
+defineProjectModels();
+
+test("check 'Edit' and 'View Tasks' buttons are in Project Calendar Popover", async () => {
+ mockDate("2024-01-03 12:00:00", 0);
+ onRpc(({ method, model, args }) => {
+ if (model === "project.project" && method === "action_view_tasks") {
+ expect.step("view tasks");
+ return false;
+ } else if (method === "has_access") {
+ return true;
+ }
+ });
+
+ await mountView({
+ resModel: "project.project",
+ type: "calendar",
+ arch: `
+
+
+
+ `,
+ });
+
+ expect(".fc-event-main").toHaveCount(1);
+ await click(".fc-event-main");
+ await runAllTimers();
+ expect(".o_popover").toHaveCount(1);
+ expect(".o_popover .card-footer .btn").toHaveCount(3);
+ expect(queryAllTexts(".o_popover .card-footer .btn")).toEqual(["Edit", "View Tasks", ""]);
+ expect(".o_popover .card-footer .btn i.fa-trash").toHaveCount(1);
+
+ await click(".o_popover .card-footer a:contains(View Tasks)");
+ await click(".o_popover .card-footer a:contains(Edit)");
+ expect.verifySteps(["view tasks"]);
+});
diff --git a/frontend/project/static/tests/project_project_form_view.test.js b/frontend/project/static/tests/project_project_form_view.test.js
new file mode 100644
index 0000000..ba03652
--- /dev/null
+++ b/frontend/project/static/tests/project_project_form_view.test.js
@@ -0,0 +1,228 @@
+import { expect, test, beforeEach } from "@odoo/hoot";
+import { click } from "@odoo/hoot-dom";
+import {
+ mountView,
+ contains,
+ onRpc,
+ toggleMenuItem,
+ toggleActionMenu,
+ clickSave,
+ mockService,
+} from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectProject } from "./project_models";
+
+defineProjectModels();
+
+beforeEach(() => {
+ ProjectProject._records = [
+ {
+ id: 1,
+ name: "Project 1",
+ allow_milestones: false,
+ allow_task_dependencies: false,
+ allow_recurring_tasks: false,
+ },
+ {
+ id: 2,
+ name: "Project 2",
+ allow_milestones: false,
+ allow_task_dependencies: false,
+ allow_recurring_tasks: false,
+ },
+ ];
+
+ mockService("action", {
+ doAction(actionRequest) {
+ if (actionRequest === "reload_context") {
+ expect.step("reload_context");
+ } else {
+ return super.doAction(...arguments);
+ }
+ },
+ });
+});
+
+test("project.project (form)", async () => {
+ await mountView({
+ resModel: "project.project",
+ resId: 1,
+ type: "form",
+ arch: `
+
+ `,
+ });
+ expect(".o_form_view").toHaveCount(1);
+});
+
+const formViewParams = {
+ resModel: "project.project",
+ type: "form",
+ actionMenus: {},
+ resId: 1,
+ arch: `
+
+ `,
+};
+
+onRpc("project.project", "check_features_enabled", ({ method }) => expect.step(method));
+
+onRpc("web_save", ({ method }) => expect.step(method));
+
+test("project.project (form) hide archive action for project user", async () => {
+ onRpc("has_group", ({ args }) => args[1] === "project.group_project_user");
+ await mountView(formViewParams);
+ await toggleActionMenu();
+ expect(`.o-dropdown--menu span:contains(Archive)`).toHaveCount(0, { message: "Archive action should not be visible" });
+ expect.verifySteps(["check_features_enabled"]);
+});
+
+test("project.project (form) show archive action for project manager", async () => {
+ onRpc("has_group", () => true);
+ await mountView(formViewParams);
+ await toggleActionMenu();
+ expect(`.o-dropdown--menu span:contains(Archive)`).toHaveCount(1, { message: "Arhive action should be visible" });
+ await toggleMenuItem("Archive");
+ await contains(`.modal-footer .btn-primary`).click();
+ await toggleActionMenu();
+ expect(`.o-dropdown--menu span:contains(Unarchive)`).toHaveCount(1, { message: "Unarchive action should be visible" });
+ await toggleMenuItem("UnArchive");
+ expect.verifySteps(["check_features_enabled"]);
+});
+
+test("reload the page when allow_milestones is enabled on at least one project", async () => {
+ // No project has allow_milestones enabled
+ await mountView(formViewParams);
+
+ await click("div[name='allow_milestones'] input");
+ await clickSave();
+
+ expect.verifySteps([
+ "check_features_enabled",
+ "web_save",
+ "check_features_enabled",
+ "reload_context",
+ ]);
+});
+
+test("do not reload the page when allow_milestones is enabled and there already exists one project with the feature enabled", async () => {
+ // Set a project with allow_milestones enabled
+ ProjectProject._records[1].allow_milestones = true;
+ await mountView(formViewParams);
+
+ await click("div[name='allow_milestones'] input");
+ await clickSave();
+
+ // No reload should be triggered
+ expect.verifySteps(["check_features_enabled", "web_save", "check_features_enabled"]);
+});
+
+test("reload the page when allow_milestones is disabled on all projects", async () => {
+ // Set a project with allow_milestones enabled
+ ProjectProject._records[0].allow_milestones = true;
+ await mountView(formViewParams);
+
+ await click("div[name='allow_milestones'] input");
+ await clickSave();
+
+ expect.verifySteps([
+ "check_features_enabled",
+ "web_save",
+ "check_features_enabled",
+ "reload_context",
+ ]);
+});
+
+test("reload the page when allow_task_dependencies is enabled on at least one project", async () => {
+ // No project has allow_task_dependencies enabled
+ await mountView(formViewParams);
+
+ await click("div[name='allow_task_dependencies'] input");
+ await clickSave();
+
+ expect.verifySteps([
+ "check_features_enabled",
+ "web_save",
+ "check_features_enabled",
+ "reload_context",
+ ]);
+});
+
+test("do not reload the page when allow_task_dependencies is enabled and there already exists one project with the feature enabled", async () => {
+ // Set a project with allow_task_dependencies enabled
+ ProjectProject._records[1].allow_task_dependencies = true;
+ await mountView(formViewParams);
+
+ await click("div[name='allow_task_dependencies'] input");
+ await clickSave();
+
+ // No reload should be triggered
+ expect.verifySteps(["check_features_enabled", "web_save", "check_features_enabled"]);
+});
+
+test("reload the page when allow_task_dependencies is disabled on all projects", async () => {
+ // Set a project with allow_task_dependencies enabled
+ ProjectProject._records[0].allow_task_dependencies = true;
+ await mountView(formViewParams);
+
+ await click("div[name='allow_task_dependencies'] input");
+ await clickSave();
+
+ expect.verifySteps([
+ "check_features_enabled",
+ "web_save",
+ "check_features_enabled",
+ "reload_context",
+ ]);
+});
+
+test("reload the page when allow_recurring_tasks is enabled on at least one project", async () => {
+ // No project has allow_recurring_tasks enabled
+ await mountView(formViewParams);
+
+ await click("div[name='allow_recurring_tasks'] input");
+ await clickSave();
+
+ expect.verifySteps([
+ "check_features_enabled",
+ "web_save",
+ "check_features_enabled",
+ "reload_context",
+ ]);
+});
+
+test("do not reload the page when allow_recurring_tasks is enabled and there already exists one project with the feature enabled", async () => {
+ // Set a project with allow_recurring_tasks enabled
+ ProjectProject._records[1].allow_recurring_tasks = true;
+ await mountView(formViewParams);
+
+ await click("div[name='allow_recurring_tasks'] input");
+ await clickSave();
+
+ // No reload should be triggered
+ expect.verifySteps(["check_features_enabled", "web_save", "check_features_enabled"]);
+});
+
+test("reload the page when allow_recurring_tasks is disabled on all projects", async () => {
+ // Set a project with allow_recurring_tasks enabled
+ ProjectProject._records[0].allow_recurring_tasks = true;
+ await mountView(formViewParams);
+
+ await click("div[name='allow_recurring_tasks'] input");
+ await clickSave();
+
+ expect.verifySteps([
+ "check_features_enabled",
+ "web_save",
+ "check_features_enabled",
+ "reload_context",
+ ]);
+});
diff --git a/frontend/project/static/tests/project_project_state_selection.test.js b/frontend/project/static/tests/project_project_state_selection.test.js
new file mode 100644
index 0000000..99e5567
--- /dev/null
+++ b/frontend/project/static/tests/project_project_state_selection.test.js
@@ -0,0 +1,45 @@
+import { expect, test } from "@odoo/hoot";
+import { click } from "@odoo/hoot-dom";
+import { fields, mountView } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectProject } from "./project_models";
+
+defineProjectModels();
+
+test("project.project (kanban): check that ProjectStateSelectionField does not propose `Set Status`", async () => {
+ Object.assign(ProjectProject._fields, {
+ last_update_status: fields.Selection({
+ string: "Status",
+ selection: [
+ ["on_track", "On Track"],
+ ["at_risk", "At Risk"],
+ ["off_track", "Off Track"],
+ ["on_hold", "On Hold"],
+ ],
+ }),
+ last_update_color: fields.Integer({ string: "Update State Color" }),
+ });
+ ProjectProject._records = [
+ {
+ id: 1,
+ last_update_status: "on_track",
+ last_update_color: 20,
+ },
+ ];
+
+ await mountView({
+ resModel: "project.project",
+ type: "kanban",
+ arch: `
+
+
+
+
+
+
+
+ `,
+ });
+ await click("div[name='last_update_status'] button.dropdown-toggle");
+ expect(".dropdown-menu .dropdown-item:contains('Set Status')").toHaveCount(0);
+});
diff --git a/frontend/project/static/tests/project_right_side_panel.test.js b/frontend/project/static/tests/project_right_side_panel.test.js
new file mode 100644
index 0000000..9458284
--- /dev/null
+++ b/frontend/project/static/tests/project_right_side_panel.test.js
@@ -0,0 +1,124 @@
+import { expect, test, describe } from "@odoo/hoot";
+import { queryAll } from "@odoo/hoot-dom";
+
+import { mountWithCleanup, onRpc } from "@web/../tests/web_test_helpers";
+
+import { defineMailModels } from "@mail/../tests/mail_test_helpers";
+
+import { ProjectRightSidePanel } from "@project/components/project_right_side_panel/project_right_side_panel";
+
+defineMailModels();
+describe.current.tags("desktop");
+
+const FAKE_DATA = {
+ user: {
+ is_project_user: true,
+ },
+ buttons: [
+ {
+ icon: "check",
+ text: "Tasks",
+ number: "0 / 0",
+ action_type: "object",
+ action: "action_view_tasks",
+ show: true,
+ sequence: 1,
+ },
+ ],
+ show_project_profitability_helper: false,
+ show_milestones: true,
+ milestones: {
+ data: [
+ {
+ id: 1,
+ name: "Milestone Zero",
+ },
+ ],
+ },
+ profitability_items: {
+ costs: {
+ data: [],
+ },
+ revenues: {
+ data: [],
+ },
+ },
+};
+
+test("Right side panel will not be rendered without data and settings set false", async () => {
+ onRpc(() => {
+ const deepCopy = JSON.parse(JSON.stringify(FAKE_DATA));
+ deepCopy.buttons.pop();
+ deepCopy.milestones.data.pop();
+ deepCopy.show_milestones = false;
+ return { ...deepCopy };
+ });
+
+ await mountWithCleanup(ProjectRightSidePanel, {
+ props: {
+ context: { active_id: 1 },
+ domain: new Array(),
+ },
+ });
+
+ expect(queryAll("div.o_rightpanel").length).toBe(0, {
+ message: "Right panel should not be rendered",
+ });
+});
+
+test("Right side panel will be rendered if settings are turned on but doesnt have any data", async () => {
+ onRpc(() => {
+ const deepCopy = JSON.parse(JSON.stringify(FAKE_DATA));
+ deepCopy.buttons.pop();
+ deepCopy.milestones.data.pop();
+ deepCopy.show_milestones = true;
+ return { ...deepCopy };
+ });
+
+ await mountWithCleanup(ProjectRightSidePanel, {
+ props: {
+ context: { active_id: 1 },
+ domain: new Array(),
+ },
+ });
+
+ expect(queryAll("div.o_rightpanel").length).toBe(1, {
+ message: "Right panel should be rendered",
+ });
+});
+
+test("Right side panel will be not rendered if settings are turned off but does have data", async () => {
+ onRpc(() => {
+ const deepCopy = JSON.parse(JSON.stringify(FAKE_DATA));
+ deepCopy.show_milestones = false;
+ return { ...deepCopy };
+ });
+
+ await mountWithCleanup(ProjectRightSidePanel, {
+ props: {
+ context: { active_id: 1 },
+ domain: new Array(),
+ },
+ });
+
+ expect(queryAll("div.o_rightpanel").length).toBe(0, {
+ message: "Right panel should not be rendered",
+ });
+});
+
+test("Right side panel will be rendered if both setting is turned on and does have data", async () => {
+ onRpc(() => {
+ return { ...FAKE_DATA };
+ });
+
+ await mountWithCleanup(ProjectRightSidePanel, {
+ props: {
+ context: { active_id: 1 },
+ domain: new Array(),
+ },
+ });
+
+ expect(queryAll("div.o_rightpanel").length).toBe(1, {
+ message: "Right panel should be rendered",
+ });
+});
diff --git a/frontend/project/static/tests/project_task_analysis.test.js b/frontend/project/static/tests/project_task_analysis.test.js
new file mode 100644
index 0000000..3745641
--- /dev/null
+++ b/frontend/project/static/tests/project_task_analysis.test.js
@@ -0,0 +1,134 @@
+import { describe, expect, test } from "@odoo/hoot";
+import { animationFrame } from "@odoo/hoot-mock";
+
+import { WebClient } from "@web/webclient/webclient";
+import { clickOnDataset, setupChartJsForTests } from "@web/../tests/views/graph/graph_test_helpers";
+import {
+ contains,
+ fields,
+ getService,
+ mockService,
+ models,
+ mountWithCleanup,
+} from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, projectModels } from "./project_models";
+
+describe.current.tags("desktop");
+
+class ReportProjectTaskUser extends models.Model {
+ _name = "report.project.task.user";
+ project_id = fields.Many2one({ relation: "project.project" });
+ display_in_project = fields.Boolean();
+ task_id = fields.Many2one({ relation: "project.task" });
+ nbr = fields.Integer({ string: "# of Tasks" });
+
+ _records = [
+ { id: 4, project_id: 1, display_in_project: true },
+ { id: 6, project_id: 1, display_in_project: true },
+ { id: 9, project_id: 2, display_in_project: true },
+ ];
+ _views = {
+ graph: /* xml */ `
+
+
+
+ `,
+ pivot: /* xml */ `
+
+
+
+ `,
+ };
+}
+projectModels.ReportProjectTaskUser = ReportProjectTaskUser;
+projectModels.ProjectTask._views = {
+ form: /* xml */ ``,
+ list: /* xml */ ``,
+ search: /* xml */ ``,
+};
+defineProjectModels();
+setupChartJsForTests();
+
+async function mountView(viewName, ctx = {}) {
+ const view = await mountWithCleanup(WebClient);
+ await getService("action").doAction({
+ id: 1,
+ name: "tasks analysis",
+ res_model: "report.project.task.user",
+ type: "ir.actions.act_window",
+ views: [[false, viewName]],
+ context: ctx,
+ });
+ return view;
+}
+
+test("report.project.task.user (graph): clicking on a bar leads to project.task list", async () => {
+ mockService("action", {
+ doAction({ res_model }) {
+ expect.step(res_model);
+ return super.doAction(...arguments);
+ },
+ });
+
+ const view = await mountView("graph");
+ await animationFrame();
+ await clickOnDataset(view);
+ await animationFrame();
+
+ expect(".o_list_renderer").toBeDisplayed({
+ message: "Clicking on a bar should open a list view",
+ });
+ // The model of the list view that is opened consequently should be "project.task"
+ expect.verifySteps(["report.project.task.user", "project.task"]);
+});
+
+test("report.project.task.user (pivot): clicking on a cell leads to project.task list", async () => {
+ mockService("action", {
+ doAction({ res_model }) {
+ expect.step(res_model);
+ return super.doAction(...arguments);
+ },
+ });
+
+ await mountView("pivot");
+ await animationFrame();
+ await contains(".o_pivot_cell_value").click();
+ await animationFrame();
+
+ expect(".o_list_renderer").toBeDisplayed({
+ message: "Clicking on a cell should open a list view",
+ });
+ // The model of the list view that is opened consequently should be "project.task"
+ expect.verifySteps(["report.project.task.user", "project.task"]);
+});
+
+test("report.project.task.user: fix the domain, in case field is not present in main model", async () => {
+ mockService("action", {
+ doAction({ domain, res_model }) {
+ if (res_model === "project.task") {
+ expect(domain).toEqual(["&", ["display_in_project", "=", true], "&", [1, "=", 1], ["id", "=", 1]]);
+ }
+ return super.doAction(...arguments);
+ },
+ });
+
+ ReportProjectTaskUser._records = [
+ { id: 1, nbr: 1, task_id: 1, display_in_project: true },
+ { id: 2, nbr: 1, task_id: 2, display_in_project: true },
+ ];
+ ReportProjectTaskUser._views = {
+ graph: /* xml */ `
+
+
+
+
+ `
+ };
+
+ const view = await mountView("graph", { group_by: ["task_id", "nbr"] });
+ await animationFrame();
+ await clickOnDataset(view);
+ await animationFrame();
+ expect(`.o_list_renderer .o_data_row`).toHaveCount(1);
+});
diff --git a/frontend/project/static/tests/project_task_burndown_chart.test.js b/frontend/project/static/tests/project_task_burndown_chart.test.js
new file mode 100644
index 0000000..83d8f51
--- /dev/null
+++ b/frontend/project/static/tests/project_task_burndown_chart.test.js
@@ -0,0 +1,182 @@
+import { describe, expect, test } from "@odoo/hoot";
+import { click, queryAll } from "@odoo/hoot-dom";
+import {
+ defineModels,
+ fields,
+ mockService,
+ models,
+ mountView,
+ toggleMenuItem,
+ toggleMenuItemOption,
+ toggleSearchBarMenu,
+} from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels } from "./project_models";
+
+class ProjectTaskBurndownChartReport extends models.Model {
+ _name = "project.task.burndown.chart.report";
+
+ date = fields.Date();
+ project_id = fields.Many2one({ relation: "project.project" });
+ stage_id = fields.Many2one({ relation: "project.task.type" });
+ is_closed = fields.Selection({
+ string: "Burnup chart",
+ selection: [
+ ["closed", "Closed tasks"],
+ ["open", "Open tasks"],
+ ],
+ });
+ nb_tasks = fields.Integer({
+ string: "Number of Tasks",
+ type: "integer",
+ aggregator: "sum",
+ });
+
+ _records = [
+ {
+ id: 1,
+ project_id: 1,
+ stage_id: 1,
+ is_closed: "open",
+ date: "2020-01-01",
+ nb_tasks: 10,
+ },
+ {
+ id: 2,
+ project_id: 1,
+ stage_id: 2,
+ is_closed: "open",
+ date: "2020-02-01",
+ nb_tasks: 5,
+ },
+ {
+ id: 3,
+ project_id: 1,
+ stage_id: 3,
+ is_closed: "closed",
+ date: "2020-03-01",
+ nb_tasks: 2,
+ },
+ ];
+}
+
+defineProjectModels();
+defineModels([ProjectTaskBurndownChartReport]);
+
+describe.current.tags("desktop");
+
+mockService("notification", () => ({
+ add() {
+ expect.step("notification");
+ },
+}));
+
+const mountViewParams = {
+ resModel: "project.task.burndown.chart.report",
+ type: "graph",
+ arch: `
+
+
+
+
+
+
+ `,
+};
+
+async function mountViewWithSearch(mountViewContext = null) {
+ await mountView({
+ ...mountViewParams,
+ searchViewId: false,
+ searchViewArch: `
+
+
+
+
+
+ `,
+ context: mountViewContext || {
+ search_default_date: 1,
+ search_default_stage: 1,
+ },
+ });
+}
+
+async function toggleGroupBy(fieldLabel) {
+ await toggleSearchBarMenu();
+ await toggleMenuItem(fieldLabel);
+}
+
+function checkGroupByOrder() {
+ const searchFacets = queryAll(".o_facet_value");
+ expect(searchFacets).toHaveCount(2);
+ const [dateSearchFacet, stageSearchFacet] = searchFacets;
+ expect(dateSearchFacet).toHaveText("Date: Month");
+ expect(stageSearchFacet).toHaveText("Stage");
+}
+
+test("burndown.chart: check that the sort buttons are invisible", async () => {
+ await mountView(mountViewParams);
+ expect(".o_cp_bottom_left:has(.btn-group[role=toolbar][aria-label='Sort graph'])").toHaveCount(
+ 0,
+ {
+ message: "The sort buttons shouldn't be rendered",
+ }
+ );
+});
+
+test("burndown.chart: check that removing the group by 'Date: Month > Stage' in the search bar triggers a notification", async () => {
+ await mountViewWithSearch();
+ await click(".o_facet_remove");
+ // Only the notification will be triggered and the file won't be uploaded.
+ expect.verifySteps(["notification"]);
+});
+
+test("burndown.chart: check that removing the group by 'Date' triggers a notification", async () => {
+ await mountViewWithSearch();
+ await toggleGroupBy("Date");
+ await toggleMenuItemOption("Date", "Month");
+ // Only the notification will be triggered and the file won't be uploaded.
+ expect.verifySteps(["notification"]);
+});
+
+test("burndown.chart: check that adding a group by 'Date' actually toggles it", async () => {
+ await mountViewWithSearch();
+ await toggleGroupBy("Date");
+ await toggleMenuItemOption("Date", "Year");
+ expect(".o_accordion_values .selected").toHaveCount(1, {
+ message: "There should be only one selected item",
+ });
+ expect(".o_accordion_values .selected").toHaveText("Year", {
+ message: "The selected item should be the one we clicked on",
+ });
+});
+
+test("burndown.chart: check that groupby 'Date > Stage' results in 'Date > Stage'", async () => {
+ await mountViewWithSearch({
+ search_default_date: 1,
+ search_default_stage: 1,
+ });
+ checkGroupByOrder();
+});
+
+test("burndown.chart: check that groupby 'Stage > Date' results in 'Date > Stage'", async () => {
+ await mountViewWithSearch({
+ search_default_stage: 1,
+ search_default_date: 1,
+ });
+ checkGroupByOrder();
+});
+
+test("burndown.chart: check the toggle between 'Stage' and 'Burnup chart'", async () => {
+ await mountViewWithSearch();
+ await toggleGroupBy("Stage");
+ const searchFacets = queryAll(".o_facet_value");
+ expect(searchFacets).toHaveCount(2);
+
+ const [dateSearchFacet, stageSearchFacet] = searchFacets;
+ expect(dateSearchFacet).toHaveText("Date: Month");
+ expect(stageSearchFacet).toHaveText("Burnup chart");
+ await toggleMenuItem("Burnup chart");
+ checkGroupByOrder();
+});
diff --git a/frontend/project/static/tests/project_task_calendar.test.js b/frontend/project/static/tests/project_task_calendar.test.js
new file mode 100644
index 0000000..2738f2c
--- /dev/null
+++ b/frontend/project/static/tests/project_task_calendar.test.js
@@ -0,0 +1,313 @@
+import { expect, test, beforeEach, describe } from "@odoo/hoot";
+import { mockDate, animationFrame, runAllTimers } from "@odoo/hoot-mock";
+import { click, queryAllTexts, queryFirst, queryOne, waitFor } from "@odoo/hoot-dom";
+
+import { contains, mountView, onRpc } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectTask } from "./project_models";
+import { serializeDateTime } from "@web/core/l10n/dates";
+
+describe.current.tags("desktop");
+defineProjectModels();
+
+beforeEach(() => {
+ mockDate("2024-01-03 12:00:00", +0);
+
+ ProjectTask._views["form"] = `
+
+ `;
+
+ ProjectTask._records = [
+ {
+ id: 1,
+ name: "Task-1",
+ date_deadline: "2024-01-09 07:00:00",
+ create_date: "2024-01-03 12:00:00",
+ project_id: 1,
+ stage_id: 1,
+ state: "01_in_progress",
+ user_ids: [],
+ display_name: "Task-1",
+ },
+ {
+ id: 10,
+ name: "Task-10",
+ project_id: 1,
+ stage_id: 1,
+ state: "01_in_progress",
+ user_ids: [],
+ display_name: "Task-10",
+ },
+ {
+ id: 11,
+ name: "Task-11",
+ project_id: 1,
+ stage_id: 1,
+ state: "1_done",
+ user_ids: [],
+ display_name: "Task-11",
+ is_closed: true,
+ },
+ ];
+
+ onRpc("has_access", () => true);
+});
+
+const calendarMountParams = {
+ resModel: "project.task",
+ type: "calendar",
+ arch: `
+
+
+
+
+ `,
+};
+
+test("test Project Task Calendar Popover with task_stage_with_state_selection widget", async () => {
+ await mountView(calendarMountParams);
+
+ await click("a.fc-daygrid-event");
+
+ // Skipping setTimeout while clicking event in calendar for calendar popover to appear.
+ // There is a timeout set in the useCalendarPopover.
+ await runAllTimers();
+
+ expect(queryOne(".o_field_task_stage_with_state_selection > div").childElementCount).toBe(2);
+});
+
+test("test task_stage_with_state_selection widget with non-editable state", async () => {
+ await mountView({
+ ...calendarMountParams,
+ arch: `
+
+
+
+
+ `,
+ });
+
+ await click("a.fc-daygrid-event");
+
+ // Skipping setTimeout while clicking event in calendar for calendar popover to appear.
+ // There is a timeout set in the useCalendarPopover.
+ await runAllTimers();
+
+ await click("button[title='In Progress']");
+
+ expect(".project_task_state_selection_menu").toHaveCount(0);
+});
+
+test("test task_stage_with_state_selection widget with editable state", async () => {
+ await mountView({
+ ...calendarMountParams,
+ arch: `
+
+
+
+
+ `,
+ });
+
+ await click("a.fc-daygrid-event");
+
+ // Skipping setTimeout while clicking event in calendar for calendar popover to appear.
+ // There is a timeout set in the useCalendarPopover.
+ await runAllTimers();
+
+ await click(".o-dropdown div[title='In Progress']");
+ await animationFrame();
+ expect(".project_task_state_selection_menu").toHaveCount(1);
+
+ await click(".o_status_green"); // Checking if click on the state in selection menu works(changes in record)
+ await animationFrame();
+ expect(".o-dropdown .o_status").toHaveStyle({ color: "rgb(0, 136, 24)" });
+});
+
+test("Display closed tasks as past event", async () => {
+ ProjectTask._records.push({
+ id: 2,
+ name: "Task-2",
+ date_deadline: "2024-01-09 07:00:00",
+ create_date: "2024-01-03 12:00:00",
+ project_id: 1,
+ stage_id: 1,
+ state: "1_done",
+ user_ids: [],
+ display_name: "Task-2",
+ });
+ ProjectTask._records.push({
+ id: 3,
+ name: "Task-3",
+ date_deadline: "2024-01-09 07:00:00",
+ create_date: "2024-01-03 12:00:00",
+ project_id: 1,
+ stage_id: 1,
+ state: "1_canceled",
+ user_ids: [],
+ display_name: "Task-3",
+ });
+ ProjectTask._records.push({
+ id: 4,
+ name: "Task-4",
+ date_deadline: "2024-01-09 07:00:00",
+ create_date: "2024-01-03 12:00:00",
+ project_id: 1,
+ stage_id: 1,
+ state: "1_canceled",
+ user_ids: [],
+ display_name: "Task-4",
+ is_closed: true,
+ });
+ await mountView(calendarMountParams);
+ expect(".o_event").toHaveCount(4);
+ expect(".o_event.o_past_event").toHaveCount(3);
+});
+
+test("tasks to schedule should not be visible in the sidebar if no default project set in the context", async () => {
+ onRpc("project.task", "search_read", ({ method }) => {
+ expect.step(method);
+ });
+ onRpc("project.task", "web_search_read", () => {
+ expect.step("fetch tasks to schedule");
+ });
+
+ await mountView(calendarMountParams);
+ expect(".o_calendar_view").toHaveCount(1);
+ expect(".o_task_to_plan_draggable").toHaveCount(0);
+ expect.verifySteps(["search_read"]);
+});
+
+test("tasks to plan should be visible in the sidebar when `default_project_id` is set in the context", async () => {
+ onRpc("project.task", "search_read", ({ method }) => {
+ expect.step(method);
+ });
+ onRpc("project.task", "web_search_read", () => {
+ expect.step("fetch tasks to schedule");
+ });
+
+ await mountView({
+ ...calendarMountParams,
+ context: { default_project_id: 1 },
+ });
+ expect(".o_calendar_view").toHaveCount(1);
+ expect(".o_task_to_plan_draggable").toHaveCount(2);
+ expect(queryAllTexts(".o_task_to_plan_draggable")).toEqual(['Task-10', 'Task-11']);
+ expect(".o_calendar_view .o_calendar_sidebar h5").toHaveText("Drag Tasks to Schedule");
+ expect.verifySteps(["search_read", "fetch tasks to schedule"]);
+});
+
+test("search domain should be taken into account in Tasks to Schedule", async () => {
+ onRpc("project.task", "search_read", ({ method }) => {
+ expect.step(method);
+ });
+ onRpc("project.task", "web_search_read", ({ method }) => {
+ expect.step("fetch tasks to schedule");
+ });
+
+ await mountView({
+ ...calendarMountParams,
+ context: { default_project_id: 1 },
+ domain: [['is_closed', '=', false]],
+ });
+ expect(".o_calendar_view").toHaveCount(1);
+ expect(".o_task_to_plan_draggable").toHaveCount(1);
+ expect(".o_task_to_plan_draggable").toHaveText('Task-10');
+ expect(".o_calendar_view .o_calendar_sidebar h5").toHaveText("Drag Tasks to Schedule");
+ expect.verifySteps(["search_read", "fetch tasks to schedule"]);
+});
+
+test("planned dates used in search domain should not be taken into account in Tasks to Schedule", async () => {
+ onRpc("project.task", "search_read", ({ method }) => {
+ expect.step(method);
+ });
+ onRpc("project.task", "web_search_read", ({ method }) => {
+ expect.step("fetch tasks to schedule");
+ });
+
+ await mountView({
+ ...calendarMountParams,
+ context: { default_project_id: 1 },
+ domain: [['is_closed', '=', false], ['date_deadline', '!=', false], ['planned_date_begin', '!=', false]],
+ });
+ expect(".o_calendar_view").toHaveCount(1);
+ expect(".o_task_to_plan_draggable").toHaveCount(1);
+ expect(".o_task_to_plan_draggable").toHaveText('Task-10');
+ expect(".o_calendar_view .o_calendar_sidebar h5").toHaveText("Drag Tasks to Schedule");
+ expect.verifySteps(["search_read", "fetch tasks to schedule"]);
+});
+
+test("test drag and drop a task to schedule in calendar view in month scale", async () => {
+ let expectedDate = null;
+
+ onRpc("project.task", "search_read", ({ method }) => {
+ expect.step(method);
+ });
+ onRpc("project.task", "web_search_read", ({ method }) => {
+ expect.step("fetch tasks to schedule");
+ });
+ onRpc("project.task", "plan_task_in_calendar", ({ args }) => {
+ const [taskIds, vals] = args;
+ expect(taskIds).toEqual([10]);
+ const expectedDateDeadline = serializeDateTime(expectedDate.set({ hours: 19 }));
+ expect(vals).toEqual({
+ date_deadline: expectedDateDeadline,
+ });
+ expect.step("plan task");
+ });
+
+ await mountView({
+ ...calendarMountParams,
+ context: { default_project_id: 1 },
+ });
+ expect(".o_task_to_plan_draggable").toHaveCount(2);
+ const { drop, moveTo } = await contains(".o_task_to_plan_draggable:first").drag();
+ const dateCell = queryFirst(".fc-day.fc-day-today.fc-daygrid-day");
+ expectedDate = luxon.DateTime.fromISO(dateCell.dataset.date);
+ await moveTo(dateCell);
+ expect(dateCell).toHaveClass("o-highlight");
+ await drop();
+ expect.verifySteps(["search_read", "fetch tasks to schedule", "plan task", "search_read"]);
+ expect(".o_task_to_plan_draggable").toHaveCount(1);
+ expect(".o_task_to_plan_draggable").toHaveText("Task-11");
+});
+
+test("project.task (calendar): toggle sub-tasks", async () => {
+ ProjectTask._records = [
+ {
+ id: 1,
+ project_id: 1,
+ name: "Task 1",
+ stage_id: 1,
+ display_in_project: true,
+ date_deadline: "2024-01-09 07:00:00",
+ create_date: "2024-01-03 12:00:00",
+ },
+ {
+ id: 2,
+ project_id: 1,
+ name: "Task 2",
+ stage_id: 1,
+ display_in_project: false,
+ date_deadline: "2024-01-09 07:00:00",
+ create_date: "2024-01-03 12:00:00",
+ }
+ ];
+ await mountView(calendarMountParams);
+ expect(".o_event").toHaveCount(1);
+ expect(".o_control_panel_navigation button i.fa-sliders").toHaveCount(1);
+ await click(".o_control_panel_navigation button i.fa-sliders");
+ await waitFor("span.o-dropdown-item");
+ expect("span.o-dropdown-item").toHaveText("Show Sub-Tasks");
+ await click("span.o-dropdown-item");
+ await animationFrame();
+ expect(".o_event").toHaveCount(2);
+});
diff --git a/frontend/project/static/tests/project_task_groupby.test.js b/frontend/project/static/tests/project_task_groupby.test.js
new file mode 100644
index 0000000..356b557
--- /dev/null
+++ b/frontend/project/static/tests/project_task_groupby.test.js
@@ -0,0 +1,59 @@
+import { beforeEach, expect, test } from "@odoo/hoot";
+import { mountView, quickCreateKanbanColumn } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectTask } from "./project_models";
+
+defineProjectModels();
+
+const kanbanViewParams = {
+ resModel: "project.task",
+ type: "kanban",
+ arch: `
+
+
+
+ `,
+};
+
+beforeEach(() => {
+ ProjectTask._records = [
+ {
+ id: 1,
+ name: "My task",
+ project_id: false,
+ user_ids: [],
+ date_deadline: false,
+ },
+ ];
+});
+
+test("project.task (kanban): Can create stage if we are in tasks of specific project", async () => {
+ await mountView({
+ ...kanbanViewParams,
+ context: {
+ default_project_id: 1,
+ },
+ });
+ expect(".o_column_quick_create").toHaveCount(1, {
+ message: "should have a quick create column",
+ });
+ expect(".o_column_quick_create.o_quick_create_folded").toHaveCount(1, {
+ message: "Add column button should be visible",
+ });
+ await quickCreateKanbanColumn();
+ expect(".o_column_quick_create input").toHaveCount(1, {
+ message: "the input should be visible",
+ });
+});
+
+test("project.task (kanban): Cannot create stage if we are not in tasks of specific project", async () => {
+ await mountView({
+ ...kanbanViewParams,
+ });
+ expect(".o_column_quick_create").toHaveCount(0, {
+ message: "quick create column should not be visible",
+ });
+ expect(".o_column_quick_create.o_quick_create_folded").toHaveCount(0, {
+ message: "Add column button should not be visible",
+ });
+});
diff --git a/frontend/project/static/tests/project_task_kanban_view.test.js b/frontend/project/static/tests/project_task_kanban_view.test.js
new file mode 100644
index 0000000..459fedf
--- /dev/null
+++ b/frontend/project/static/tests/project_task_kanban_view.test.js
@@ -0,0 +1,93 @@
+import { describe, expect, test } from "@odoo/hoot";
+import { animationFrame, click, waitFor } from "@odoo/hoot-dom";
+
+import { mountView, onRpc } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectTask } from "./project_models";
+
+defineProjectModels();
+describe.current.tags("desktop");
+
+const viewParams = {
+ resModel: "project.task",
+ type: "kanban",
+ arch: `
+
+
+
+
+
+
+ `,
+ context: {
+ active_model: "project.project",
+ default_project_id: 1,
+ },
+};
+
+test("stages nocontent helper should be displayed in the project Kanban", async () => {
+ ProjectTask._records = [];
+
+ await mountView({
+ resModel: "project.task",
+ type: "kanban",
+ arch: `
+
+
+
+
+
+
+
+ `,
+ context: {
+ active_model: "project.task.type.delete.wizard",
+ default_project_id: 1,
+ },
+ });
+
+ expect(".o_kanban_header").toHaveCount(1);
+ expect(".o_kanban_stages_nocontent").toHaveCount(1);
+});
+
+test("quick create button is visible when the user has access rights.", async () => {
+ onRpc("has_group", () => true);
+ await mountView(viewParams);
+ await animationFrame();
+ expect(".o_column_quick_create").toHaveCount(1);
+});
+
+test("quick create button is not visible when the user not have access rights", async () => {
+ onRpc("has_group", () => false);
+ await mountView(viewParams);
+ await animationFrame();
+ expect(".o_column_quick_create").toHaveCount(0);
+});
+
+test("project.task (kanban): toggle sub-tasks", async () => {
+ ProjectTask._records = [
+ {
+ id: 1,
+ project_id: 1,
+ name: "Task 1",
+ stage_id: 1,
+ display_in_project: true,
+ },
+ {
+ id: 2,
+ project_id: 1,
+ name: "Task 2",
+ stage_id: 1,
+ display_in_project: false,
+ }
+ ];
+ await mountView(viewParams);
+ expect(".o_kanban_record").toHaveCount(1);
+ expect(".o_control_panel_navigation button i.fa-sliders").toHaveCount(1);
+ await click(".o_control_panel_navigation button i.fa-sliders");
+ await waitFor("span.o-dropdown-item");
+ expect("span.o-dropdown-item").toHaveText("Show Sub-Tasks");
+ await click("span.o-dropdown-item");
+ await animationFrame();
+ expect(".o_kanban_record").toHaveCount(2);
+});
diff --git a/frontend/project/static/tests/project_task_list_view.test.js b/frontend/project/static/tests/project_task_list_view.test.js
new file mode 100644
index 0000000..c587140
--- /dev/null
+++ b/frontend/project/static/tests/project_task_list_view.test.js
@@ -0,0 +1,83 @@
+import { describe, expect, test } from "@odoo/hoot";
+import { check, click, queryAll, queryOne, waitFor } from "@odoo/hoot-dom";
+import { animationFrame } from "@odoo/hoot-mock";
+import { mountView } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectTask } from "./project_models";
+
+defineProjectModels();
+
+describe.current.tags("desktop");
+
+test("project.task (list): cannot edit stage_id with different projects", async () => {
+ ProjectTask._records = [
+ {
+ id: 1,
+ project_id: 1,
+ stage_id: 1,
+ },
+ {
+ id: 2,
+ project_id: 2,
+ stage_id: 1,
+ },
+ ];
+
+ await mountView({
+ resModel: "project.task",
+ type: "list",
+ arch: `
+
+
+
+
+ `,
+ });
+
+ const [firstRow, secondRow] = queryAll(".o_data_row");
+ await check(".o_list_record_selector input", { root: firstRow });
+ await animationFrame();
+ expect(queryAll("[name=stage_id]")).not.toHaveClass("o_readonly_modifier");
+
+ await check(".o_list_record_selector input", { root: secondRow });
+ await animationFrame();
+ expect(queryOne("[name=stage_id]", { root: firstRow })).toHaveClass("o_readonly_modifier");
+ expect(queryOne("[name=stage_id]", { root: secondRow })).toHaveClass("o_readonly_modifier");
+});
+
+test("project.task (list): toggle sub-tasks", async () => {
+ ProjectTask._records = [
+ {
+ id: 1,
+ project_id: 1,
+ name: "Task 1",
+ stage_id: 1,
+ display_in_project: true,
+ },
+ {
+ id: 2,
+ project_id: 1,
+ name: "Task 2",
+ stage_id: 1,
+ display_in_project: false,
+ }
+ ];
+ await mountView({
+ resModel: "project.task",
+ type: "list",
+ arch: `
+
+
+
+
+ `,
+ });
+ expect(".o_data_row").toHaveCount(1);
+ expect(".o_control_panel_navigation button i.fa-sliders").toHaveCount(1);
+ await click(".o_control_panel_navigation button i.fa-sliders");
+ await waitFor("span.o-dropdown-item");
+ expect("span.o-dropdown-item").toHaveText("Show Sub-Tasks");
+ await click("span.o-dropdown-item");
+ await animationFrame();
+ expect(".o_data_row").toHaveCount(2);
+});
diff --git a/frontend/project/static/tests/project_task_priority_switch.test.js b/frontend/project/static/tests/project_task_priority_switch.test.js
new file mode 100644
index 0000000..29d524e
--- /dev/null
+++ b/frontend/project/static/tests/project_task_priority_switch.test.js
@@ -0,0 +1,32 @@
+import { expect, test } from "@odoo/hoot";
+import { press } from "@odoo/hoot-dom";
+import { animationFrame } from "@odoo/hoot-mock";
+import { mountView } from "@web/../tests/web_test_helpers";
+
+import { ProjectTask, defineProjectModels } from "./project_models";
+
+defineProjectModels();
+
+test("project.task (form): check ProjectTaskPrioritySwitch", async () => {
+ ProjectTask._records = [{ id: 1, priority: "0" }];
+
+ await mountView({
+ resModel: "project.task",
+ type: "form",
+ arch: `
+
+ `,
+ });
+
+ expect("div[name='priority'] .fa-star-o").toHaveCount(1, {
+ message: "The low priority should display the fa-star-o (empty) icon",
+ });
+ await press("alt+r");
+ await animationFrame();
+ expect("div[name='priority'] .fa-star").toHaveCount(1, {
+ message:
+ "After using the alt+r hotkey the priority should be set to high and the widget should display the fa-star (filled) icon",
+ });
+});
diff --git a/frontend/project/static/tests/project_task_project_many2one_field.test.js b/frontend/project/static/tests/project_task_project_many2one_field.test.js
new file mode 100644
index 0000000..3c4bb7b
--- /dev/null
+++ b/frontend/project/static/tests/project_task_project_many2one_field.test.js
@@ -0,0 +1,40 @@
+import { expect, test } from "@odoo/hoot";
+
+import { mountView } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels } from "./project_models";
+
+defineProjectModels();
+
+test("ProjectMany2one: project.task form view with private task", async () => {
+ await mountView({
+ resModel: "project.task",
+ resId: 3,
+ type: "form",
+ arch: `
+
+ `,
+ });
+ expect("div[name='project_id'] .o_many2one").toHaveClass("o_many2one private_placeholder w-100");
+ expect("div[name='project_id'] .o_many2one input").toHaveAttribute("placeholder", "Private");
+});
+
+test("ProjectMany2one: project.task list view", async () => {
+ await mountView({
+ resModel: "project.task",
+ type: "list",
+ arch: `
+
+
+
+
+ `,
+ });
+ expect("div[name='project_id']").toHaveCount(3);
+ expect("div[name='project_id'] .o_many2one").toHaveCount(2);
+ expect("div[name='project_id'] span.text-danger.fst-italic.text-muted").toHaveCount(1);
+ expect("div[name='project_id'] span.text-danger.fst-italic.text-muted").toHaveText("🔒 Private");
+});
diff --git a/frontend/project/static/tests/project_task_state_selection.test.js b/frontend/project/static/tests/project_task_state_selection.test.js
new file mode 100644
index 0000000..6c80bc6
--- /dev/null
+++ b/frontend/project/static/tests/project_task_state_selection.test.js
@@ -0,0 +1,85 @@
+import { expect, test, describe } from "@odoo/hoot";
+import { click, queryAllTexts } from "@odoo/hoot-dom";
+import { animationFrame } from "@odoo/hoot-mock";
+
+import { mountView } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectTask } from "./project_models";
+
+describe.current.tags("desktop");
+defineProjectModels();
+
+test("project.task (kanban): check task state widget", async () => {
+ await mountView({
+ resModel: "project.task",
+ type: "kanban",
+ arch: `
+
+
+
+
+
+
+
+ `,
+ });
+
+ expect(".o-dropdown--menu").toHaveCount(0, {
+ message: "If the state button has not been pressed yet, no dropdown should be displayed",
+ });
+ await click("div[name='state']:first-child button.dropdown-toggle");
+ await animationFrame();
+ expect(".o-dropdown--menu").toHaveCount(1, {
+ message: "Once the button has been pressed the dropdown should appear",
+ });
+
+ await click(".o-dropdown--menu span.text-danger");
+ await animationFrame();
+ expect("div[name='state']:first-child button.dropdown-toggle i.fa-times-circle").toBeVisible({
+ message:
+ "If the canceled state as been selected, the fa-times-circle icon should be displayed",
+ });
+
+ await click("div[name='state'] i.fa-hourglass-o");
+ await animationFrame();
+ expect(".o-dropdown--menu").toHaveCount(0, {
+ message: "When trying to click on the waiting icon, no dropdown menu should display",
+ });
+});
+
+test("project.task (form): check task state widget", async () => {
+ ProjectTask._views = {
+ form: ``,
+ };
+ await mountView({
+ resModel: "project.task",
+ resId: 1,
+ type: "form",
+ });
+ await click("button.o_state_button");
+ await animationFrame();
+ expect(queryAllTexts(".state_selection_field_menu > .dropdown-item")).toEqual([
+ "In Progress",
+ "Changes Requested",
+ "Approved",
+ "Cancelled",
+ "Done",
+ ]);
+ await click("button.o_state_button");
+
+ await mountView({
+ resModel: "project.task",
+ resId: 3,
+ type: "form",
+ });
+ await click("button.o_state_button:contains('Waiting')");
+ await animationFrame();
+ expect(queryAllTexts(".state_selection_field_menu > .dropdown-item")).toEqual([
+ "Cancelled",
+ "Done",
+ ]);
+});
diff --git a/frontend/project/static/tests/project_task_subtask.test.js b/frontend/project/static/tests/project_task_subtask.test.js
new file mode 100644
index 0000000..c35db34
--- /dev/null
+++ b/frontend/project/static/tests/project_task_subtask.test.js
@@ -0,0 +1,345 @@
+import { beforeEach, describe, destroy, expect, test } from "@odoo/hoot";
+import { animationFrame } from "@odoo/hoot-mock";
+import { click, edit, queryOne } from "@odoo/hoot-dom";
+import { Command, mountView, MockServer, mockService, onRpc } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectTask } from "./project_models";
+
+defineProjectModels();
+
+describe.current.tags("desktop");
+
+beforeEach(() => {
+ ProjectTask._records = [
+ {
+ id: 1,
+ name: "Task 1 (Project 1)",
+ project_id: 1,
+ child_ids: [2, 3, 4, 7],
+ closed_subtask_count: 1,
+ subtask_count: 4,
+ user_ids: [7],
+ state: "01_in_progress",
+ },
+ {
+ id: 2,
+ name: "Task 2 (Project 1)",
+ project_id: 1,
+ parent_id: 1,
+ child_ids: [],
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ state: "03_approved",
+ },
+ {
+ id: 3,
+ name: "Task 3 (Project 1)",
+ project_id: 1,
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ state: "02_changes_requested",
+ },
+ {
+ id: 4,
+ name: "Task 4 (Project 1)",
+ project_id: 1,
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ state: "1_done",
+ },
+ {
+ id: 5,
+ name: "Task 5 (Private)",
+ closed_subtask_count: 0,
+ subtask_count: 1,
+ child_ids: [6],
+ state: "03_approved",
+ },
+ {
+ id: 6,
+ name: "Task 6 (Private)",
+ parent_id: 5,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ state: "1_canceled",
+ },
+ {
+ id: 7,
+ name: "Task 7 (Project 1)",
+ project_id: 1,
+ parent_id: 1,
+ closed_subtask_count: 0,
+ subtask_count: 0,
+ child_ids: [],
+ state: "01_in_progress",
+ user_ids: [7],
+ },
+ {
+ id: 8,
+ name: "Task 1 (Project 2)",
+ project_id: 2,
+ child_ids: [],
+ },
+ ];
+ ProjectTask._views = {
+ kanban: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ form: `
+
+ `,
+ };
+});
+
+test("project.task (kanban): check subtask list", async () => {
+ await mountView({
+ resModel: "project.task",
+ type: "kanban",
+ });
+
+ expect(".o_field_name_with_subtask_count:contains('(1/4 sub-tasks)')").toHaveCount(1, {
+ message:
+ "Task title should also display the number of (closed) sub-tasks linked to the task",
+ });
+ expect(".subtask_list_button").toHaveCount(1, {
+ message:
+ "Only kanban boxes of parent tasks having open subtasks should have the drawdown button, in this case this is 1",
+ });
+ expect(".subtask_list").toHaveCount(0, {
+ message: "If the drawdown button is not clicked, the subtasks list should be hidden",
+ });
+
+ await click(".subtask_list_button");
+ await animationFrame();
+ expect(".subtask_list").toHaveCount(1, {
+ message:
+ "Clicking on the button should make the subtask list render, in this case we are expectig 1 list",
+ });
+ expect(".subtask_list_row").toHaveCount(3, {
+ message: "The list rendered should show the open subtasks of the task, in this case 3",
+ });
+ expect(".subtask_state_widget_col").toHaveCount(3, {
+ message:
+ "Each of the list's rows should have 1 state widget, thus we are looking for 3 in total",
+ });
+ expect(".subtask_user_widget_col").toHaveCount(3, {
+ message:
+ "Each of the list's rows should have 1 user widgets, thus we are looking for 3 in total",
+ });
+ expect(".subtask_name_col").toHaveCount(3, {
+ message:
+ "Each of the list's rows should display the subtask's name, thus we are looking for 3 in total",
+ });
+
+ await click(".subtask_list_button");
+ await animationFrame();
+ expect(".subtask_list").toHaveCount(0, {
+ message:
+ "If the drawdown button is clicked again, the subtasks list should be hidden again",
+ });
+});
+
+test("project.task (kanban): check closed subtask count update", async () => {
+ let checkSteps = false;
+ onRpc(({ method, model }) => {
+ if (checkSteps) {
+ expect.step(`${model}/${method}`);
+ }
+ });
+ await mountView({
+ resModel: "project.task",
+ type: "kanban",
+ });
+ checkSteps = true;
+
+ expect(queryOne(".subtask_list_button").parentNode).toHaveText("1/4");
+ await click(".subtask_list_button");
+ await animationFrame();
+ const inProgressStatesSelector = `
+ .subtask_list
+ .o_field_widget.o_field_project_task_state_selection.subtask_state_widget_col
+ .o_status:not(.o_status_green,.o_status_bubble)
+ `;
+ expect(inProgressStatesSelector).toHaveCount(1, {
+ message: "The state of the subtask should be in progress",
+ });
+
+ await click(inProgressStatesSelector);
+ await animationFrame();
+ await click(".project_task_state_selection_menu .fa-check-circle");
+ await animationFrame();
+ expect(inProgressStatesSelector).toHaveCount(0, {
+ message: "The state of the subtask should no longer be in progress",
+ });
+ expect.verifySteps([
+ "project.task/web_read",
+ "project.task/onchange",
+ "project.task/web_save",
+ ]);
+});
+
+test("project.task (kanban): check subtask creation", async () => {
+ let checkSteps = false;
+ onRpc(({ args, method, model }) => {
+ if (checkSteps) {
+ expect.step(`${model}/${method}`);
+ }
+ if (model === "project.task" && method === "create") {
+ const [{ display_name, parent_id, sequence }] = args[0];
+ expect(display_name).toBe("New Subtask");
+ expect(parent_id).toBe(1);
+ expect(sequence).toBe(11, { message: "Sequence should be 11" });
+ const newSubtaskId = MockServer.env["project.task"].create({
+ name: display_name,
+ parent_id,
+ state: "01_in_progress",
+ sequence: sequence,
+ });
+ MockServer.env["project.task"].write(parent_id, {
+ child_ids: [Command.link(newSubtaskId)],
+ });
+ return [newSubtaskId];
+ }
+ });
+ await mountView({
+ resModel: "project.task",
+ type: "kanban",
+ });
+ checkSteps = true;
+
+ expect(queryOne(".subtask_list_button").parentNode).toHaveText("1/4");
+ await click(".subtask_list_button");
+ await animationFrame();
+ await click(".subtask_create");
+ await animationFrame();
+ await click(".subtask_create_input input");
+ await edit("New Subtask", { confirm: "enter" });
+ await animationFrame();
+ expect(".subtask_list_row").toHaveCount(4, {
+ message:
+ "The subtasks list should now display the subtask created on the card, thus we are looking for 4 in total",
+ });
+ expect.verifySteps([
+ "project.task/web_read",
+ "project.task/create",
+ "project.task/web_read",
+ ]);
+});
+
+test("project.task (form): check that the subtask of another project can be added", async () => {
+ await mountView({
+ resModel: "project.task",
+ resId: 7,
+ type: "form",
+ });
+
+ await click(".o_field_x2many_list_row_add a");
+ await animationFrame();
+ await click(".o_field_project input");
+ await animationFrame();
+ await click(".o_field_project li");
+ await animationFrame();
+ await click(".o_field_project input");
+ await edit("aaa");
+ await click(".o_form_button_save");
+ await animationFrame();
+ expect(".o_field_project").toHaveText("Project 1");
+});
+
+test("project.task (form): check focus on new subtask's name", async () => {
+ await mountView({
+ resModel: "project.task",
+ type: "form",
+ });
+
+ await click(".o_field_x2many_list_row_add a");
+ await animationFrame();
+ expect(".o_field_char input").toBeFocused({
+ message: "Upon clicking on 'Add a line', the new subtask's name should be focused.",
+ });
+});
+
+test("project.task (kanban): check subtask creation when input is empty", async () => {
+ await mountView({
+ resModel: "project.task",
+ type: "kanban",
+ });
+ await click(".subtask_list_button");
+ await animationFrame();
+ await click(".subtask_create");
+ await animationFrame();
+ await click(".subtask_create_input input");
+ await edit("");
+ await click(".subtask_create_input button");
+ await animationFrame();
+ expect(".subtask_create_input input").toHaveClass("o_field_invalid", {
+ message: "input field should be displayed as invalid",
+ });
+ expect(".o_notification_content").toHaveInnerHTML("Invalid Display Name", {
+ message: "The content of the notification should contain 'Display Name'.",
+ });
+ expect(".o_notification_bar").toHaveClass("bg-danger", {
+ message: "The notification bar should have type 'danger'.",
+ });
+});
+
+test("project.task: Parent id is set when creating new task from subtask form's 'View' button", async () => {
+ mockService("action", {
+ doAction(params) {
+ return mountView({
+ resModel: params.res_model,
+ resId: params.res_id,
+ type: "form",
+ context: params.context,
+ });
+ },
+ });
+
+ const taskFormView = await mountView({
+ resModel: "project.task",
+ resId: 1,
+ type: "form",
+ });
+ await click("tbody .o_data_row:nth-child(1) .o_list_record_open_form_view button.btn-link");
+ // Destroying this view for sanicity of display
+ destroy(taskFormView);
+ await animationFrame();
+
+ await click(".o_form_view .o_form_button_create");
+ await animationFrame();
+ expect("div[name='parent_id'] input").toHaveValue(
+ MockServer.current._models[ProjectTask._name].find((rec) => rec.id === 1).name
+ );
+});
diff --git a/frontend/project/static/tests/project_task_template_dropdown.test.js b/frontend/project/static/tests/project_task_template_dropdown.test.js
new file mode 100644
index 0000000..7ab4603
--- /dev/null
+++ b/frontend/project/static/tests/project_task_template_dropdown.test.js
@@ -0,0 +1,148 @@
+import { beforeEach, expect, test } from "@odoo/hoot";
+import { animationFrame, hover } from "@odoo/hoot-dom";
+import { contains, mockService, mountView, onRpc } from "@web/../tests/web_test_helpers";
+
+import { defineProjectModels, ProjectTask } from "./project_models";
+
+defineProjectModels();
+
+function addTemplateTasks() {
+ ProjectTask._records.push(
+ {
+ id: 4,
+ name: "Template Task 1",
+ project_id: 1,
+ stage_id: 1,
+ state: "01_in_progress",
+ is_template: true,
+ },
+ {
+ id: 5,
+ name: "Template Task 2",
+ project_id: 1,
+ stage_id: 1,
+ state: "01_in_progress",
+ is_template: true,
+ }
+ );
+}
+
+beforeEach(() => {
+ ProjectTask._views = {
+ form: `
+
+ `,
+ kanban: `
+
+
+
+
+
+
+
+ `,
+ list: `
+
+
+
+ `,
+ };
+});
+
+for (const [viewType, newButtonClass] of [
+ ["form", ".o_form_button_create"],
+ ["kanban", ".o-kanban-button-new"],
+ ["list", ".o_list_button_add"],
+]) {
+ test(`template dropdown in ${viewType} view of a project with no template`, async () => {
+ await mountView({
+ resModel: "project.task",
+ resId: 1,
+ type: viewType,
+ context: {
+ default_project_id: 1,
+ },
+ });
+ expect(newButtonClass).toHaveCount(1, {
+ message: "The “New” button should be displayed",
+ });
+ expect(newButtonClass).not.toHaveClass("dropdown-toggle", {
+ message: "The “New” button should not be a dropdown since there is no template",
+ });
+
+ // Test that we can create a new record without errors
+ await contains(`${newButtonClass}`).click();
+ });
+
+ test(`template dropdown in ${viewType} view of a project with one template with showing Edit and Delete actions`, async () => {
+ addTemplateTasks();
+
+ onRpc(({ method }) => {
+ if (method === "unlink") {
+ expect.step(method);
+ }
+ });
+
+ mockService("action", {
+ doAction(action) {
+ if (action.res_id === 4 && action.res_model === "project.task") {
+ expect.step("task template opened");
+ }
+ },
+ });
+
+ await mountView({
+ resModel: "project.task",
+ resId: 1,
+ type: viewType,
+ context: {
+ default_project_id: 1,
+ },
+ });
+ expect(newButtonClass).toHaveCount(1, {
+ message: "The “New” button should be displayed",
+ });
+ expect(newButtonClass).toHaveClass("dropdown-toggle", {
+ message: "The “New” button should be a dropdown since there is a template",
+ });
+
+ await contains(newButtonClass).click();
+ expect("button.dropdown-item:contains('New Task')").toHaveCount(1, {
+ message: "The “New Task” button should be in the dropdown",
+ });
+ expect("button.dropdown-item:contains('Template Task 1')").toHaveCount(1, {
+ message: "There should be a button named after the task template",
+ });
+
+ await hover("button.dropdown-item:contains('Template Task 1')");
+ await animationFrame();
+
+ await contains(".o_template_icon_group:first > i.fa-trash").click();
+ expect(".modal-body").toHaveCount(1, {
+ message: "A confirmation modal should appear when deleting a template",
+ });
+
+ await contains(".modal-footer .btn-primary").click();
+ expect.verifySteps(["unlink"]);
+
+ await animationFrame();
+ await contains(".o_template_icon_group:first > i.fa-pencil").click();
+ expect.verifySteps(["task template opened"]);
+
+ });
+}
+
+test("template dropdown should not appear when not in the context of a specific project", async () => {
+ addTemplateTasks();
+ await mountView({
+ resModel: "project.task",
+ type: "kanban",
+ });
+
+ expect(".o-kanban-button-new").not.toHaveClass("dropdown-toggle", {
+ message:
+ "The “New” button should not be a dropdown since there is no project in the context",
+ });
+});
diff --git a/frontend/project/static/tests/project_update_with_color.test.js b/frontend/project/static/tests/project_update_with_color.test.js
new file mode 100644
index 0000000..a7e5334
--- /dev/null
+++ b/frontend/project/static/tests/project_update_with_color.test.js
@@ -0,0 +1,48 @@
+import { defineMailModels } from "@mail/../tests/mail_test_helpers";
+import { expect, test } from "@odoo/hoot";
+import { defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
+
+class ProjectUpdate extends models.Model {
+ _name = "project.update";
+
+ status = fields.Selection({
+ selection: [
+ ["on_track", "On Track"],
+ ["at_risk", "At Risk"],
+ ["off_track", "Off Track"],
+ ["on_hold", "On Hold"],
+ ["done", "Done"],
+ ],
+ });
+
+ _records = [{ id: 1, status: "on_track" }];
+}
+
+defineMailModels();
+defineModels([ProjectUpdate]);
+
+test("project.update (kanban): check that ProjectStatusWithColorSelectionField is displaying the correct informations", async () => {
+ await mountView({
+ resModel: "project.update",
+ type: "kanban",
+ arch: `
+
+
+
+
+
+
+
+ `,
+ });
+
+ expect("div[name='status'] .o_color_bubble_20").toHaveCount(1, {
+ message: "In readonly a status bubble should be displayed",
+ });
+ expect("div[name='status'] .o_stat_text:contains('test status label')").toHaveCount(1, {
+ message: "If the status_label prop has been set, its value should be displayed as well",
+ });
+ expect("div[name='status'] .o_stat_value:contains('On Track')").toHaveCount(1, {
+ message: "The value of the selection should be displayed",
+ });
+});
diff --git a/frontend/project/static/tests/tours/personal_stage_tour.js b/frontend/project/static/tests/tours/personal_stage_tour.js
new file mode 100644
index 0000000..5b813f1
--- /dev/null
+++ b/frontend/project/static/tests/tours/personal_stage_tour.js
@@ -0,0 +1,78 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+registry.category("web_tour.tours").add('personal_stage_tour', {
+ url: '/odoo',
+ steps: () => [stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
+ run: "click",
+}, {
+ content: "Open Pig Project",
+ trigger: '.o_kanban_record:contains("Pig")',
+ run: "click",
+}, {
+ // Default is grouped by stage, user should not be able to create/edit a column
+ content: "Check that there is no create column",
+ trigger: "body:not(.o_column_quick_create)",
+}, {
+ content: "Check that there is no create column",
+ trigger: "body:not(.o_group_edit)",
+}, {
+ content: "Check that there is no create column",
+ trigger: "body:not(.o_group_delete)",
+}, {
+ content: "Go to tasks",
+ trigger: 'button[data-menu-xmlid="project.menu_project_management"]',
+ run: "click",
+},{
+ content: "Go to my tasks", // My tasks is grouped by personal stage by default
+ trigger: 'a[data-menu-xmlid="project.menu_project_management_my_tasks"]',
+ run: "click",
+}, {
+ content: "Check that we can create a new stage",
+ trigger: '.o_column_quick_create.o_quick_create_folded div',
+ run: "click",
+}, {
+ content: "Create a new personal stage",
+ trigger: 'input.form-control',
+ run: "edit Never",
+}, {
+ content: "Confirm create",
+ trigger: '.o_kanban_add',
+ run: "click",
+}, {
+ content: "Check that column exists && Open column edit dropdown",
+ trigger: ".o_kanban_header:contains(Never)",
+ run: "hover && click .o_kanban_header:contains(Never) .dropdown-toggle",
+}, {
+ content: "Try editing inbox",
+ trigger: ".dropdown-item.o_group_edit",
+ run: "click",
+}, {
+ content: "Change title",
+ trigger: 'div.o_field_char[name="name"] input',
+ run: "edit ((Todo))",
+}, {
+ content: "Save changes",
+ trigger: '.btn-primary:contains("Save")',
+ run: "click",
+}, {
+ content: "Check that column was updated",
+ trigger: '.o_kanban_header:contains("Todo")',
+ run: "click",
+}, {
+ content: "Create a personal task from the quick create form",
+ trigger: '.o-kanban-button-new',
+ run: "click",
+}, {
+ content: "Create a new personal task",
+ trigger: 'input.o_input:not(.o_searchview_input)',
+ run: "edit New Test Task",
+}, {
+ content: "Confirm create",
+ trigger: '.o_kanban_add',
+ run: "click",
+}, {
+ content: "Check that task exists",
+ trigger: '.o_kanban_record:contains("New Test Task")',
+}]});
diff --git a/frontend/project/static/tests/tours/project_burndown_chart_tour.js b/frontend/project/static/tests/tours/project_burndown_chart_tour.js
new file mode 100644
index 0000000..8007a9e
--- /dev/null
+++ b/frontend/project/static/tests/tours/project_burndown_chart_tour.js
@@ -0,0 +1,85 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+registry.category("web_tour.tours").add('burndown_chart_tour', {
+ url: '/odoo',
+ steps: () => [stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
+ run: "click",
+}, {
+ content: 'Open "Burndown Chart Test" project menu',
+ trigger: ".o_kanban_record:contains(Burndown Chart Test)",
+ run: `hover && click .o_kanban_record:contains(Burndown Chart Test) .o_dropdown_kanban .dropdown-toggle`,
+}, {
+ content: `Open "Burndown Chart Test" project's "Burndown Chart" view`,
+ trigger: '.o_kanban_manage_reporting div[role="menuitem"] a:contains("Burndown Chart")',
+ run: "click",
+},
+{
+ trigger: ".o_graph_renderer",
+},
+{
+ content: 'The sort buttons are not rendered',
+ trigger: '.o_graph_renderer:not(:has(.btn-group[role=toolbar][aria-label="Sort graph"]))',
+ run: "click",
+}, {
+ content: 'Remove the project search "Burndown Chart Test"',
+ trigger: ".o_searchview_facet:contains(Burndown Chart Test)",
+ run: "hover && click .o_facet_remove",
+}, {
+ content: 'Search Burndown Chart',
+ trigger: 'input.o_searchview_input',
+ run: `edit Burndown`,
+}, {
+ content: 'Validate search',
+ trigger: '.o_searchview_autocomplete .o-dropdown-item:contains("Project")',
+ run: "click",
+}, {
+ content: 'Remove the group by "Date: Month > Stage"',
+ trigger: '.o_searchview_facet:contains("Stage") .o_facet_remove',
+ run: "click",
+}, {
+ content: 'A "The Burndown Chart must be grouped by Date and Stage" notification is shown when trying to remove the group by "Date: Month > Stage"',
+ trigger: '.o_notification_manager .o_notification:contains("The report should be grouped either by ") button.o_notification_close',
+ run: "click",
+}, {
+ content: 'Open the search panel menu',
+ trigger: '.o_control_panel .o_searchview_dropdown_toggler',
+ run: "click",
+}, {
+ content: 'The Stage group menu item is visible',
+ trigger: '.o_group_by_menu .o_menu_item:contains("Stage")',
+ run: "click",
+}, {
+ content: 'Open the Date group by sub menu',
+ trigger: '.o_group_by_menu button.o_menu_item:contains("Date")',
+ run: "click",
+}, {
+ content: 'Click on the selected Date sub menu',
+ trigger: '.o_group_by_menu button.o_menu_item:contains("Date") + * .dropdown-item.selected',
+ run: "click",
+}, {
+ content: 'A "The Burndown Chart must be grouped by Date" notification is shown when trying to remove the group by "Date: Month > Stage"',
+ trigger: '.o_notification_manager .o_notification:contains("The Burndown Chart must be grouped by Date") button.o_notification_close',
+ run: "click",
+}, {
+ content: 'Open the search panel menu',
+ trigger: '.o_control_panel .o_searchview_dropdown_toggler',
+ run: "click",
+}, {
+ content: 'Open the Date filter sub menu',
+ trigger: '.o_filter_menu button.o_menu_item:contains("Date")',
+ run: "click",
+}, {
+ content: 'Click on the first Date filter sub menu',
+ trigger: '.o_filter_menu .o_menu_item:contains("Date") + * .dropdown-item:first-child',
+ run: "click",
+}, {
+ content: 'Close the Date filter menu',
+ trigger: '.o_graph_renderer',
+ run: "click",
+}, {
+ content: 'Open the search panel menu',
+ trigger: '.o_control_panel .o_searchview_dropdown_toggler',
+ run: "click",
+}]});
diff --git a/frontend/project/static/tests/tours/project_sharing_tour.js b/frontend/project/static/tests/tours/project_sharing_tour.js
new file mode 100644
index 0000000..0a86e0d
--- /dev/null
+++ b/frontend/project/static/tests/tours/project_sharing_tour.js
@@ -0,0 +1,256 @@
+import { delay } from "@web/core/utils/concurrency";
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+const projectSharingSteps = [...stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project App.'), {
+ trigger: ".o_kanban_record:contains(Project Sharing)",
+ content: 'Open the project dropdown.',
+ run: "hover && click .o_kanban_record:contains(Project Sharing) .o_dropdown_kanban .dropdown-toggle",
+}, {
+ trigger: '.dropdown-menu a:contains("Share")',
+ content: 'Start editing the project.',
+ run: "click",
+}, {
+ trigger: '.modal div[name="collaborator_ids"] .o_field_x2many_list_row_add > a',
+ content: 'Add a collaborator to the project.',
+ run: "click",
+}, {
+ trigger: '.modal div[name="collaborator_ids"] div[name="partner_id"] input',
+ content: 'Select the user portal as collaborator to the "Project Sharing" project.',
+ run: "edit Georges",
+}, {
+ trigger: '.ui-autocomplete a.dropdown-item:contains("Georges")',
+ run: "click",
+}, {
+ trigger: '.modal div[name="collaborator_ids"] div[name="access_mode"] input',
+ content: 'Open Access mode selection dropdown.',
+ run: 'click',
+},{
+ trigger: '.o_select_menu_item:contains(Edit)',
+ run: 'click',
+}, {
+ trigger: '.modal footer > button[name="action_share_record"]',
+ content: 'Confirm the project sharing with this portal user.',
+ run: "click",
+},
+{
+ trigger: "body:not(:has(.modal))",
+},
+{
+ trigger: '.o_web_client',
+ content: 'Go to project portal view to select the "Project Sharing" project',
+ run: function () {
+ window.location.href = window.location.origin + '/my/projects';
+ },
+ expectUnloadPage: true,
+}, {
+ id: 'project_sharing_feature',
+ trigger: 'table > tbody > tr a:has(span:contains(Project Sharing))',
+ content: 'Select "Project Sharing" project to go to project sharing feature for this project.',
+ run: "click",
+ expectUnloadPage: true,
+}, {
+ trigger: '.o_project_sharing .o_kanban_renderer',
+ content: 'Wait the project sharing feature be loaded',
+}, {
+ trigger: 'button.o-kanban-button-new',
+ content: 'Click "Create" button',
+ run: 'click',
+}, {
+ trigger: '.o_kanban_quick_create .o_field_widget[name=name] input',
+ content: 'Create Task',
+ run: "edit Test Create Task",
+}, {
+ content: "Check that task stages cannot be drag and dropped",
+ trigger: '.o_kanban_group:not(.o_group_draggable)',
+}, {
+ trigger: '.o_kanban_quick_create .o_kanban_edit',
+ content: 'Go to the form view of this new task',
+ run: "click",
+}, {
+ trigger: 'div[name="stage_id"] div.o_statusbar_status button[aria-checked="false"]:contains(Done)',
+ content: 'Change the stage of the task.',
+ run: "click",
+}, {
+ trigger: '.o-mail-Composer-input',
+ content: 'Write a message in the chatter of the task',
+ run: "edit I create a new task for testing purpose.",
+}, {
+ trigger: '.o-mail-Composer-send:enabled',
+ content: 'Send the message',
+ run: "click",
+}, {
+ trigger: 'ol.breadcrumb > li.o_back_button > a:contains(Project Sharing)',
+ content: 'Go back to the kanban view',
+ run: "click",
+}, {
+ trigger: '.o_searchview_dropdown_toggler',
+ content: 'open the search panel menu',
+ run: "click",
+}, {
+ trigger: '.o_filter_menu .dropdown-item:first-child',
+ content: 'click on the first item in the filter menu',
+ run: "click",
+}, {
+ trigger: '.o_group_by_menu .dropdown-item:first-child',
+ content: 'click on the first item in the group by menu',
+ run: "click",
+}, {
+ trigger: '.o_favorite_menu .o_add_favorite',
+ content: 'open accordion "save current search" in favorite menu',
+ run: "click",
+}, {
+ trigger: '.o_favorite_menu .o_accordion_values .o_save_favorite',
+ content: 'click to "save" button in favorite menu',
+ run: "click",
+}, {
+ trigger: '.o_filter_menu .dropdown-item:first-child',
+ content: 'click on the first item in the filter menu',
+ run: "click",
+}, {
+ trigger: '.o_group_by_menu .dropdown-item:first-child',
+ content: 'click on the first item in the group by menu',
+ run: "click",
+}, {
+ trigger: '.o_favorite_menu .o_accordion_values .o_save_favorite',
+ content: 'click to "save" button in favorite menu',
+ run: "click",
+}, {
+ trigger: 'button.o_switch_view.o_list',
+ content: 'Go to the list view',
+ run: "click",
+}, {
+ trigger: '.o_list_view',
+}, {
+ trigger: '.o_optional_columns_dropdown_toggle',
+ run: "click",
+}, {
+ trigger: '.dropdown-item:contains("Milestone")',
+}, {
+ trigger: '.o_list_view',
+ content: 'Check the list view',
+}];
+
+registry.category("web_tour.tours").add('project_sharing_tour', {
+ url: '/odoo',
+ steps: () => {
+ return projectSharingSteps;
+ }
+});
+
+registry.category("web_tour.tours").add("portal_project_sharing_tour", {
+ url: "/my/projects",
+ steps: () => {
+ // The begining of the project sharing feature
+ const projectSharingStepIndex = projectSharingSteps.findIndex(s => s?.id === 'project_sharing_feature');
+ return projectSharingSteps.slice(projectSharingStepIndex, projectSharingSteps.length);
+ }
+});
+
+registry.category("web_tour.tours").add("project_sharing_with_blocked_task_tour", {
+ url: "/my/projects",
+ steps: () => [{
+ trigger: 'table > tbody > tr a:has(span:contains("Project Sharing"))',
+ content: 'Click on the portal project.',
+ run: "click",
+ expectUnloadPage: true,
+ }, {
+ trigger: 'article.o_kanban_record',
+ content: 'Click on the task',
+ run: "click",
+ }, {
+ trigger: 'a:contains("Blocked By")',
+ content: 'Go to the Block by task tab',
+ run: "click",
+ }, {
+ trigger: 'i:contains("This task is currently blocked by")',
+ content: 'Check that the blocked task is not visible',
+ },
+]});
+
+registry.category("web_tour.tours").add("portal_project_sharing_tour_with_disallowed_milestones", {
+ url: "/my/projects",
+ steps: () => [
+ {
+ id: "project_sharing_feature",
+ trigger: "table > tbody > tr a:has(span:contains(Project Sharing))",
+ content:
+ 'Select "Project Sharing" project to go to project sharing feature for this project.',
+ run: "click",
+ expectUnloadPage: true,
+ },
+ {
+ trigger: ".o_project_sharing",
+ content: "Wait the project sharing feature be loaded",
+ },
+ {
+ trigger: "button.o_switch_view.o_list",
+ content: "Go to the list view",
+ run: "click",
+ },
+ {
+ trigger: ".o_list_view",
+ },
+ {
+ trigger: ".o_optional_columns_dropdown_toggle",
+ run: "click",
+ },
+ {
+ trigger: ".dropdown-item",
+ },
+ {
+ trigger: ".dropdown-menu",
+ run: function () {
+ const optionalFields = Array.from(
+ this.anchor.ownerDocument.querySelectorAll(".dropdown-item")
+ ).map((e) => e.textContent);
+
+ if (optionalFields.includes("Milestone")) {
+ throw new Error(
+ "the Milestone field should be absent as allow_milestones is set to False"
+ );
+ }
+ },
+ },
+ ],
+});
+
+registry.category("web_tour.tours").add("test_04_project_sharing_chatter_message_reactions", {
+ url: "/my/projects",
+ steps: () => [
+ {
+ trigger: "table > tbody > tr a:has(span:contains(Project Sharing))",
+ run: "click",
+ expectUnloadPage: true,
+ },
+ { trigger: ".o_project_sharing" },
+ { trigger: ".o_kanban_record:contains('Test Task with messages')", run: "click" },
+ { trigger: ".o-mail-Message" },
+ { trigger: ".o-mail-Message .o-mail-MessageReaction:contains('👀')" },
+ ],
+});
+
+registry.category("web_tour.tours").add("portal_project_sharing_chatter_mention_users", {
+ url: "/my/projects",
+ steps: () => [
+ {
+ trigger: "table > tbody > tr a:has(span:contains(Project Sharing))",
+ run: "click",
+ expectUnloadPage: true,
+ },
+ { trigger: ".o_project_sharing" },
+ { trigger: ".o_kanban_record:contains('Test Task')", run: "click" },
+ { trigger: ".o-mail-Composer-input", run: "edit @xxx" },
+ {
+ trigger: "body:not(:has(.o-mail-Composer-suggestion))",
+ run: async () => {
+ const delay_fetch = odoo.loader.modules.get(
+ "@mail/core/common/suggestion_hook"
+ ).DELAY_FETCH;
+ await delay(delay_fetch);
+ },
+ },
+ { trigger: ".o-mail-Composer-input", run: "edit @Georges" },
+ { trigger: ".o-mail-Composer-suggestion:contains('Georges')" },
+ ],
+});
diff --git a/frontend/project/static/tests/tours/project_tags_filter_tour_tests.js b/frontend/project/static/tests/tours/project_tags_filter_tour_tests.js
new file mode 100644
index 0000000..f14494b
--- /dev/null
+++ b/frontend/project/static/tests/tours/project_tags_filter_tour_tests.js
@@ -0,0 +1,65 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+function changeFilter(filterName) {
+ return [
+ {
+ trigger: ".o_control_panel_actions .o_searchview_dropdown_toggler",
+ content: "open searchview menu",
+ run: "click",
+ },
+ {
+ trigger: `.o_favorite_menu .dropdown-item span:contains("${filterName}")`,
+ run: "click",
+ },
+ {
+ trigger: ".o_control_panel_actions .o_searchview_dropdown_toggler",
+ content: "close searchview menu",
+ run: "click",
+ },
+ ];
+}
+
+registry.category("web_tour.tours").add("project_tags_filter_tour", {
+ url: "/odoo",
+ steps: () => [
+ stepUtils.showAppsMenuItem(),
+ {
+ trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
+ run: "click",
+ },
+ ...changeFilter("Corkscrew tail tag filter"),
+ {
+ trigger:
+ '.o_kanban_group:has(.o_kanban_header:has(span:contains("goat"))):not(:has(.o_kanban_record))',
+ content: "check that the corkscrew tail filter has taken effect",
+ },
+ {
+ trigger:
+ '.o_kanban_group:has(.o_kanban_header:has(span:contains("pig"))) .o_kanban_record:has(span:contains("Pigs"))',
+ content: "check that the corkscrew tail filter has taken effect",
+ },
+ ...changeFilter("horned tag filter"),
+ {
+ trigger:
+ '.o_kanban_group:has(.o_kanban_header:has(span:contains("pig"))):not(:has(.o_kanban_record))',
+ content: "check that the horned filter has taken effect",
+ },
+ {
+ trigger:
+ '.o_kanban_group:has(.o_kanban_header:has(span:contains("goat"))) .o_kanban_record:has(span:contains("Goats"))',
+ content: "check that the horned filter has taken effect",
+ },
+ ...changeFilter("4 Legged tag filter"),
+ {
+ trigger:
+ '.o_kanban_group:has(.o_kanban_header:has(span:contains("pig"))) .o_kanban_record:has(span:contains("Pigs"))',
+ content: "check that the 4 legged filter has taken effect",
+ },
+ {
+ trigger:
+ '.o_kanban_group:has(.o_kanban_header:has(span:contains("goat"))) .o_kanban_record:has(span:contains("Goats"))',
+ content: "check that the 4 legged filter has taken effect",
+ },
+ ],
+});
diff --git a/frontend/project/static/tests/tours/project_task_history.js b/frontend/project/static/tests/tours/project_task_history.js
new file mode 100644
index 0000000..ab94a59
--- /dev/null
+++ b/frontend/project/static/tests/tours/project_task_history.js
@@ -0,0 +1,278 @@
+/**
+ * Project Task history tour.
+ * Features tested:
+ * - Create / edit a task description and ensure revisions are created on write
+ * - Open the history dialog and check that the revisions are correctly shown
+ * - Select a revision and check that the content / comparison are correct
+ * - Click the restore button and check that the content is correctly restored
+ */
+
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+const baseDescriptionContent = "Test project task history version";
+function changeDescriptionContentAndSave(newContent) {
+ const newText = `${baseDescriptionContent} ${newContent}`;
+ return [
+ {
+ // force focus on editable so editor will create initial p (if not yet done)
+ trigger: "div.note-editable.odoo-editor-editable",
+ run: "click",
+ },
+ {
+ trigger: `div.note-editable[spellcheck='true'].odoo-editor-editable`,
+ run: `editor ${newText}`,
+ },
+ ...stepUtils.saveForm(),
+ ];
+}
+
+function insertEditorContent(newContent) {
+ return [
+ {
+ // force focus on editable so editor will create initial p (if not yet done)
+ trigger: "div.note-editable.odoo-editor-editable",
+ run: "click",
+ },
+ {
+ trigger: `div.note-editable[spellcheck='true'].odoo-editor-editable`,
+ run: async function () {
+ // Insert content as html and make the field dirty
+ const div = document.createElement("div");
+ div.appendChild(document.createTextNode(newContent));
+ this.anchor.removeChild(this.anchor.firstChild);
+ this.anchor.appendChild(div);
+ this.anchor.dispatchEvent(new Event("input", { bubbles: true }));
+ },
+ },
+ ];
+}
+
+
+registry.category("web_tour.tours").add("project_task_history_tour", {
+ url: "/odoo?debug=1,tests",
+ steps: () => [stepUtils.showAppsMenuItem(), {
+ content: "Open the project app",
+ trigger: ".o_app[data-menu-xmlid='project.menu_main_pm']",
+ run: "click",
+ },
+ {
+ content: "Open Test History Project",
+ trigger: ".o_kanban_view .o_kanban_record:contains(Test History Project)",
+ run: "click",
+ },
+ {
+ content: "Open Test History Task",
+ trigger: ".o_kanban_view .o_kanban_record:contains(Test History Task)",
+ run: "click",
+ },
+ // edit the description content 3 times and save after each edit
+ ...changeDescriptionContentAndSave("0"),
+ ...changeDescriptionContentAndSave("1"),
+ ...changeDescriptionContentAndSave("2"),
+ ...changeDescriptionContentAndSave("3"),
+ {
+ content: "Go back to kanban view of tasks. this step is added because it takes some time to save the changes, so it's a sort of timeout to wait a bit for the save",
+ trigger: ".o_back_button a",
+ run: "click",
+ },
+ {
+ content: "Open Test History Task",
+ trigger: ".o_kanban_view .o_kanban_record:contains(Test History Task)",
+ run: "click",
+ },
+ {
+ content: "Open History Dialog",
+ trigger: ".o_form_view .o_cp_action_menus i.fa-cog",
+ run: "click",
+ },
+ {
+ trigger: ".dropdown-menu",
+ },
+ {
+ content: "Open History Dialog",
+ trigger: ".o_menu_item i.fa-history",
+ run: "click",
+ }, {
+ trigger: ".modal .html-history-dialog.html-history-loaded",
+ }, {
+ content: "Verify that 5 revisions are displayed (default empty description after the creation of the task + 3 edits + current version)",
+ trigger: ".modal .html-history-dialog .revision-list .btn",
+ run: function () {
+ const items = document.querySelectorAll(".revision-list .btn");
+ if (items.length !== 5) {
+ console.error("Expect 5 Revisions in the history dialog, got " + items.length);
+ }
+ },
+ }, {
+ content: "Verify that the active revision (revision 4) is related to the current version",
+ trigger: `.modal .history-container .history-content-view .history-view-inner:contains(${baseDescriptionContent} 3)`,
+ }, {
+ content: "Go to the third revision related to the second edit",
+ trigger: ".modal .html-history-dialog .revision-list .btn:nth-child(3)",
+ run: "click",
+ }, {
+ trigger: ".modal .html-history-dialog.html-history-loaded",
+ }, {
+ content: "Verify that the active revision is the one clicked in the previous step",
+ trigger: `.modal .history-container .history-content-view .history-view-inner:contains(${baseDescriptionContent} 1)`,
+ }, {
+ // click on the comparison tab
+ trigger: '.history-container .history-view-top-bar a:contains(Comparison)',
+ run: "click",
+ }, {
+ content: "Verify comparison text",
+ trigger: ".modal .history-container .history-comparison-view",
+ run: function () {
+ const comparaisonHtml = this.anchor.innerHTML;
+ const correctHtml = `${baseDescriptionContent} 3${baseDescriptionContent} 1`;
+ if (!comparaisonHtml.includes(correctHtml)) {
+ console.error(`Expect comparison to be ${correctHtml}, got ${comparaisonHtml}`);
+ }
+ },
+ }, {
+ trigger: ".modal .html-history-dialog.html-history-loaded",
+ }, {
+ content: "Click on Restore History btn to get back to the selected revision in the previous step",
+ trigger: ".modal button.btn-primary:enabled",
+ run: "click",
+ }, {
+ content: "Verify the confirmation dialog is opened",
+ trigger: ".modal button.btn-primary:text(Restore)",
+ run: "click",
+ }, {
+ content: "Verify that the description contains the right text after the restore",
+ trigger: `div.note-editable.odoo-editor-editable`,
+ run: function () {
+ const p = this.anchor?.innerText;
+ const expected = `${baseDescriptionContent} 1`;
+ if (p !== expected) {
+ console.error(`Expect description to be ${expected}, got ${p}`);
+ }
+ }
+ }, {
+ content: "Go back to projects view.",
+ trigger: 'a[data-menu-xmlid="project.menu_projects"]',
+ run: "click",
+ }, {
+ trigger: ".o_kanban_view",
+ }, {
+ content: "Open Test History Project Without Tasks",
+ trigger: ".o_kanban_view .o_kanban_record:contains(Without tasks project)",
+ run: "click",
+ }, {
+ trigger: ".o_kanban_project_tasks",
+ }, {
+ content: "Switch to list view",
+ trigger: ".o_switch_view.o_list",
+ run: "click",
+ }, {
+ content: "Create a new task.",
+ trigger: '.o_list_button_add',
+ run: "click",
+ }, {
+ trigger: ".o_form_view",
+ }, {
+ trigger: 'div[name="name"] .o_input',
+ content: 'Set task name',
+ run: 'edit New task',
+ },
+ ...stepUtils.saveForm(),
+ ...changeDescriptionContentAndSave("0"),
+ ...changeDescriptionContentAndSave("1"),
+ ...changeDescriptionContentAndSave("2"),
+ ...changeDescriptionContentAndSave("3"),
+ {
+ trigger: ".o_form_view",
+ }, {
+ content: "Open History Dialog",
+ trigger: ".o_cp_action_menus i.fa-cog",
+ run: "click",
+ }, {
+ trigger: ".dropdown-menu",
+ }, {
+ content: "Open History Dialog",
+ trigger: ".o_menu_item i.fa-history",
+ run: "click",
+ }, {
+ content: "Close History Dialog",
+ trigger: ".modal-header .btn-close",
+ run: "click",
+ }, {
+ content: "Go back to projects view. this step is added because Tour can't be finished with an open form view in edition mode.",
+ trigger: 'a[data-menu-xmlid="project.menu_projects"]',
+ run: "click",
+ }, {
+ content: "Verify that we are on kanban view",
+ trigger: 'button.o_switch_view.o_kanban.active',
+ }
+]});
+
+registry.category("web_tour.tours").add("project_task_last_history_steps_tour", {
+ url: "/odoo?debug=1,tests",
+ steps: () => [stepUtils.showAppsMenuItem(), {
+ content: "Open the project app",
+ trigger: ".o_app[data-menu-xmlid='project.menu_main_pm']",
+ run: "click",
+ },
+ {
+ content: "Open Test History Project",
+ trigger: ".o_kanban_view .o_kanban_record:contains(Test History Project)",
+ run: "click",
+ },
+ {
+ content: "Open Test History Task",
+ trigger: ".o_kanban_view .o_kanban_record:contains(Test History Task)",
+ run: "click",
+ },
+ ...insertEditorContent("0"),
+ ...stepUtils.saveForm(),
+ {
+ content: "Open History Dialog",
+ trigger: ".o_cp_action_menus i.fa-cog",
+ run: "click",
+ }, {
+ trigger: ".dropdown-menu",
+ }, {
+ content: "Open History Dialog",
+ trigger: ".o_menu_item i.fa-history",
+ run: "click",
+ }, {
+ trigger: ".modal .html-history-dialog.html-history-loaded",
+ }, {
+ content: "Verify that 2 revisions are displayed",
+ trigger: ".modal .html-history-dialog .revision-list .btn",
+ run: function () {
+ const items = document.querySelectorAll(".revision-list .btn");
+ if (items.length !== 2) {
+ console.error("Expect 2 Revisions in the history dialog, got " + items.length);
+ }
+ },
+ }, {
+ content: "Go to the second revision related to the initial blank document ",
+ trigger: ".modal .html-history-dialog .revision-list .btn:nth-child(2)",
+ run: "click",
+ }, {
+ trigger: ".modal .html-history-dialog.html-history-loaded",
+ }, {
+ trigger: '.modal button.btn-primary:enabled',
+ run: "click",
+ }, {
+ trigger: '.modal button.btn-primary:text(Restore)',
+ run: "click",
+ },
+ ...insertEditorContent("2"),
+ ...stepUtils.saveForm(),
+ ...insertEditorContent("4"),
+ {
+ trigger: ".o_notebook_headers li:nth-of-type(2) a",
+ run: "click",
+ },
+ {
+ trigger: ".o_notebook_headers li:nth-of-type(1) a",
+ run: "click",
+ },
+ ...insertEditorContent("5"),
+ ...stepUtils.saveForm(),
+ ],
+});
diff --git a/frontend/project/static/tests/tours/project_task_templates_tour.js b/frontend/project/static/tests/tours/project_task_templates_tour.js
new file mode 100644
index 0000000..f7294b8
--- /dev/null
+++ b/frontend/project/static/tests/tours/project_task_templates_tour.js
@@ -0,0 +1,47 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+registry.category("web_tour.tours").add("project_task_templates_tour", {
+ url: "/odoo",
+ steps: () => [
+ stepUtils.showAppsMenuItem(),
+ {
+ trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
+ run: "click",
+ },
+ {
+ trigger: '.o_kanban_record span:contains("Project with Task Template")',
+ run: "click",
+ content: "Navigate to the project with a task template",
+ },
+ {
+ trigger: 'div.o_last_breadcrumb_item span:contains("Project with Task Template")',
+ content: "Wait for the kanban view to load",
+ },
+ {
+ trigger: ".o-kanban-button-new",
+ run: "click",
+ },
+ {
+ trigger: '.dropdown-menu button.dropdown-item:contains("Template")',
+ run: "click",
+ content: "Create a task with the template",
+ },
+ {
+ trigger: 'div[name="name"] .o_input',
+ run: "edit Task",
+ },
+ {
+ trigger: "button.o_form_button_save",
+ run: "click",
+ },
+ {
+ content: "Wait for save completion",
+ trigger: ".o_form_readonly, .o_form_saved",
+ },
+ {
+ trigger: 'div.note-editable.odoo-editor-editable:contains("Template description")',
+ content: "Check that the created task has copied the description of the template",
+ },
+ ],
+});
diff --git a/frontend/project/static/tests/tours/project_templates_tour.js b/frontend/project/static/tests/tours/project_templates_tour.js
new file mode 100644
index 0000000..c7faec7
--- /dev/null
+++ b/frontend/project/static/tests/tours/project_templates_tour.js
@@ -0,0 +1,72 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+registry.category("web_tour.tours").add("project_templates_tour", {
+ url: "/odoo",
+ steps: () => [
+ stepUtils.showAppsMenuItem(),
+ {
+ trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
+ run: "click",
+ },
+ {
+ content: "Click on New Button of Kanban view",
+ trigger: ".o-kanban-button-new",
+ run: "click",
+ },
+ {
+ trigger: '.dropdown-menu button.dropdown-item:contains("Project Template")',
+ run: "click",
+ content: "Create a project from the template",
+ },
+ {
+ trigger: '.modal div[name="name"] .o_input',
+ run: "edit New Project",
+ },
+ {
+ trigger: 'button[name="create_project_from_template"]',
+ run: "click",
+ },
+ {
+ content: "Go back to kanban view",
+ trigger: ".breadcrumb-item a:contains('Projects')",
+ run: "click",
+ },
+ {
+ content: "Check for created project",
+ trigger: ".o_kanban_record:contains('New Project')",
+ },
+ {
+ content: "Go to list view",
+ trigger: "button.o_switch_view.o_list",
+ run: "click",
+ },
+ {
+ content: "Click on New Button of List view",
+ trigger: ".o_list_button_add",
+ run: "click",
+ },
+ {
+ content: "Lets Create a second project from the template",
+ trigger: '.dropdown-menu button.dropdown-item:contains("Project Template")',
+ run: "click",
+ },
+ {
+ trigger: '.modal div[name="name"] .o_input',
+ run: "edit New Project 2",
+ },
+ {
+ trigger: 'button[name="create_project_from_template"]',
+ run: "click",
+ },
+ {
+ content: "Go back to list view",
+ trigger: ".breadcrumb-item a:contains('Projects')",
+ run: "click",
+ },
+ {
+ content: "Check for created project",
+ trigger: ".o_data_row td[name='name']:contains('New Project 2')",
+ },
+ ],
+});
diff --git a/frontend/project/static/tests/tours/project_tour.js b/frontend/project/static/tests/tours/project_tour.js
new file mode 100644
index 0000000..b9c2ae0
--- /dev/null
+++ b/frontend/project/static/tests/tours/project_tour.js
@@ -0,0 +1,158 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+registry.category("web_tour.tours").add('project_test_tour', {
+ url: '/odoo',
+ steps: () => [
+ stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
+ run: "click",
+ },
+ {
+ trigger: '.o_project_kanban',
+ },
+ {
+ trigger: '.o-kanban-button-new',
+ run: "click",
+ }, {
+ isActive: ['.o-kanban-button-new.dropdown'], // if the project template dropdown is active
+ trigger: 'button.o-dropdown-item:contains("New Project")',
+ run: "click",
+ }, {
+ trigger: '.o_project_name input',
+ run: 'edit New Project',
+ id: 'project_creation',
+ }, {
+ trigger: '.o_open_tasks',
+ run: "click .modal:visible .btn.btn-primary",
+ }, {
+ trigger: ".o_kanban_project_tasks .o_column_quick_create .input-group input",
+ run: "edit New",
+ }, {
+ isActive: ["auto"],
+ trigger: ".o_kanban_project_tasks .o_column_quick_create .o_kanban_add",
+ run: "click",
+ },
+ {
+ trigger: ".o_kanban_group",
+ },
+ {
+ trigger: ".o_kanban_project_tasks .o_column_quick_create .input-group input",
+ run: "edit Done",
+ }, {
+ isActive: ["auto"],
+ trigger: ".o_kanban_project_tasks .o_column_quick_create .o_kanban_add",
+ run: "click",
+ },
+ {
+ trigger: ".o_kanban_group:eq(0)",
+ },
+ {
+ trigger: '.o-kanban-button-new',
+ run: "click",
+ },
+ {
+ trigger: ".o_kanban_project_tasks",
+ },
+ {
+ trigger: '.o_kanban_quick_create div.o_field_char[name=display_name] input',
+ run: "edit New task",
+ }, {
+ trigger: '.o_kanban_quick_create .o_kanban_add',
+ run: "click",
+ }, {
+ trigger: '.o_kanban_record span:contains("New task")',
+ run: "click",
+ }, {
+ trigger: 'a[name="sub_tasks_page"]',
+ content: 'Open sub-tasks notebook section',
+ run: 'click',
+ }, {
+ trigger: '.o_field_subtasks_one2many .o_list_renderer a[role="button"]',
+ content: 'Add a subtask',
+ run: 'click',
+ }, {
+ trigger: '.o_field_subtasks_one2many div[name="name"] input',
+ content: 'Set subtask name',
+ run: "edit new subtask",
+ }, {
+ trigger: ".o_breadcrumb .o_back_button",
+ content: 'Go back to kanban view',
+ tooltipPosition: "right",
+ run: "click",
+ }, {
+ trigger: ".o_kanban_record .o_widget_subtask_counter .subtask_list_button",
+ content: 'open sub-tasks from kanban card',
+ run: "click",
+ },
+ {
+ trigger: ".o_widget_subtask_kanban_list .subtask_list",
+ },
+ {
+ trigger: ".o_kanban_record .o_widget_subtask_kanban_list .subtask_create",
+ content: 'Create a new sub-task',
+ run: "click",
+ },
+ {
+ trigger: ".subtask_create_input",
+ },
+ {
+ trigger: ".o_kanban_record .o_widget_subtask_kanban_list .subtask_create_input input",
+ content: 'Give the sub-task a name',
+ run: "edit newer subtask && press Tab",
+ },
+ {
+ content: "wait the new record is created",
+ trigger: ".o_kanban_record .o_widget_subtask_kanban_list a:contains(newer subtask)",
+ },
+ {
+ trigger: ".o_kanban_record .o_widget_subtask_kanban_list .subtask_list_row:first-child .o_field_project_task_state_selection button",
+ content: 'Change the subtask state',
+ run: "click",
+ },
+ {
+ trigger: ".dropdown-menu",
+ },
+ {
+ trigger: ".dropdown-menu span.text-danger",
+ content: 'Mark the task as Canceled',
+ run: "click",
+ }, {
+ trigger: ".o_kanban_record .o_widget_subtask_counter .subtask_list_button:contains('1/2')",
+ content: 'Close the sub-tasks list',
+ id: "quick_create_tasks",
+ run: "click",
+ }, {
+ trigger: '.o_field_text[name="name"] textarea',
+ content: 'Set task name',
+ run: "edit New task",
+ }, {
+ trigger: 'div[name="user_ids"].o_field_many2many_tags_avatar input',
+ content: 'Assign the task to you',
+ run: 'click',
+ }, {
+ trigger: 'ul.ui-autocomplete a .o_avatar_many2x_autocomplete',
+ content: 'Assign the task to you',
+ run: "click",
+ }, {
+ trigger: 'a[name="sub_tasks_page"]',
+ content: 'Open sub-tasks notebook section',
+ run: 'click',
+ }, {
+ trigger: '.o_field_subtasks_one2many .o_list_renderer a[role="button"]',
+ content: 'Add a subtask',
+ run: 'click',
+ }, {
+ trigger: '.o_field_subtasks_one2many div[name="name"] input',
+ content: 'Set subtask name',
+ run: "edit new subtask",
+ },
+ {
+ trigger: '.o_field_many2many_tags_avatar .o_m2m_avatar',
+ },
+ {
+ trigger: 'button[special="save"]',
+ content: 'Save task',
+ run: "click",
+ },
+]});
diff --git a/frontend/project/static/tests/tours/project_update_tour_tests.js b/frontend/project/static/tests/tours/project_update_tour_tests.js
new file mode 100644
index 0000000..efaffe2
--- /dev/null
+++ b/frontend/project/static/tests/tours/project_update_tour_tests.js
@@ -0,0 +1,222 @@
+import { registry } from "@web/core/registry";
+import { stepUtils } from "@web_tour/tour_utils";
+
+registry.category("web_tour.tours").add('project_update_tour', {
+ url: '/odoo',
+ steps: () => [stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
+ run: "click",
+},
+{
+ trigger: ".o_project_kanban",
+},
+{
+ trigger: '.o-kanban-button-new',
+ run: "click",
+}, {
+ isActive: ['.o-kanban-button-new.dropdown'], // if the project template dropdown is active
+ trigger: 'button.o-dropdown-item:contains("New Project")',
+ run: "click",
+}, {
+ trigger: '.o_project_name input',
+ run: "edit New Project",
+}, {
+ trigger: '.o_open_tasks',
+ run: "click .modal:visible .btn.btn-primary",
+}, {
+ trigger: ".o_kanban_project_tasks .o_column_quick_create .input-group input",
+ run: "edit New",
+}, {
+ isActive: ["auto"],
+ trigger: ".o_kanban_project_tasks .o_column_quick_create .o_kanban_add",
+ run: "click",
+},
+{
+ trigger: ".o_kanban_group",
+},
+{
+ trigger: ".o_kanban_project_tasks .o_column_quick_create .input-group input",
+ run: "edit Done",
+}, {
+ isActive: ["auto"],
+ trigger: ".o_kanban_project_tasks .o_column_quick_create .o_kanban_add",
+ run: "click",
+},
+{
+ trigger: ".o_kanban_group:eq(0)",
+},
+{
+ trigger: '.o-kanban-button-new',
+ run: "click",
+},
+{
+ trigger: ".o_kanban_project_tasks",
+},
+{
+ trigger: '.o_kanban_quick_create div.o_field_char[name=display_name] input',
+ run: "edit New task",
+},
+{
+ trigger: ".o_kanban_project_tasks",
+},
+{
+ trigger: '.o_kanban_quick_create .o_kanban_add',
+ run: "click",
+},
+{
+ trigger: ".o_kanban_group:eq(0)",
+},
+{
+ trigger: '.o-kanban-button-new',
+ run: "click",
+},
+{
+ trigger: ".o_kanban_project_tasks",
+},
+{
+ trigger: '.o_kanban_quick_create div.o_field_char[name=display_name] input',
+ run: "edit Second task",
+},
+{
+ trigger: ".o_kanban_project_tasks",
+},
+{
+ trigger: '.o_kanban_quick_create .o_kanban_add',
+ run: "click",
+}, {
+ trigger: ".o_kanban_group:nth-child(2) .o_kanban_header",
+ run: "hover && click .o_kanban_group:nth-child(2) .o_kanban_header .dropdown-toggle",
+}, {
+ trigger: ".dropdown-item.o_group_edit",
+ run: "click",
+}, {
+ trigger: ".modal .o_field_widget[name=fold] input",
+ run: "click",
+}, {
+ trigger: ".modal .modal-footer button",
+ run: "click",
+},
+{
+ trigger: "body:not(:has(.modal))",
+},
+{
+ trigger: '.o_kanban_project_tasks',
+},
+{
+ trigger: ".o_kanban_record",
+ run: "drag_and_drop(.o_kanban_group:eq(1))",
+}, {
+ trigger: ".breadcrumb-item.o_back_button",
+ run: "click",
+}, {
+ trigger: ".o_kanban_record:contains('New Project')",
+}, {
+ trigger: ".o_switch_view.o_list",
+ run: "click",
+}, {
+ trigger: "tr.o_data_row td[name='name']:contains('New Project')",
+ run: "click",
+}, {
+ trigger: ".nav-link:contains('Settings')",
+ run: "click",
+}, {
+ trigger: "div[name='allow_milestones'] input",
+ run: "click",
+}, {
+ trigger: ".o_form_button_save",
+ run: "click",
+}, {
+ trigger: "button[name='action_view_tasks']",
+ run: "click",
+}, {
+ trigger: ".o_control_panel_navigation button i.fa-sliders",
+ content: 'Open embedded actions',
+ run: "click",
+}, {
+ trigger: "span.o-dropdown-item:contains('Top Menu')",
+ run: "click",
+}, {
+ trigger: ".o-dropdown-item div span:contains('Dashboard')",
+ content: "Put Dashboard in the embedded actions",
+ run: "click",
+}, {
+ trigger: ".o_embedded_actions button span:contains('Dashboard')",
+ content: "Open Dashboard",
+ run: "click",
+}, {
+ trigger: ".o_add_milestone a",
+ content: "Add a first milestone",
+ run: "click",
+}, {
+ trigger: ".o_list_button_add",
+ content: "Create new milestone",
+ run: "click",
+}, {
+ trigger: "div.o_field_widget[name=name] input",
+ run: "edit New milestone",
+}, {
+ trigger: "input[data-field=deadline]",
+ run: "edit 12/12/2099",
+}, {
+ trigger: ".o_list_button_save",
+ run: "click",
+}, {
+ trigger: ".o_list_button_add",
+ content: "Make sure the milestone is saved before continuing",
+}, {
+ trigger: "td[data-tooltip='New milestone'] + td",
+ run: "click",
+}, {
+ trigger: "input[data-field=deadline]",
+ run: "edit 12/12/2100 && click body"
+}, {
+ trigger: ".o_list_button_add",
+ content: "Create new milestone",
+ run: "click",
+}, {
+ trigger: "div.o_field_widget[name=name] input",
+ run: "edit Second milestone",
+}, {
+ trigger: "input[data-field=deadline]",
+ run: "edit 12/12/2022 && click body",
+}, {
+ trigger: ".breadcrumb-item.o_back_button",
+ run: "click",
+}, {
+ trigger: ".o-kanban-button-new",
+ content: "Create a new update",
+ run: "click",
+}, {
+ trigger: "div.o_field_widget[name=name] input",
+ run: "edit New update",
+}, {
+ trigger: ".o_form_button_save",
+ run: "click",
+}, {
+ trigger: ".o_field_widget[name='description'] h1:contains('Activities')",
+}, {
+ trigger: ".o_field_widget[name='description'] h3:contains('Milestones')",
+}, {
+ trigger: ".o_field_widget[name='description'] div[name='milestone'] ul li:contains('(12/12/2099 => 12/12/2100)')",
+}, {
+ trigger: ".o_field_widget[name='description'] div[name='milestone'] ul li:contains('(due 12/12/2022)')",
+}, {
+ trigger: ".o_field_widget[name='description'] div[name='milestone'] ul li:contains('(due 12/12/2100)')",
+}, {
+ trigger: '.o_back_button',
+ content: 'Go back to the kanban view the project',
+ run: "click",
+}, {
+ trigger: '.o_switch_view.o_list',
+ content: 'Open List View of Dashboard',
+ run: "click",
+},
+{
+ trigger: '.o_list_view',
+},
+{
+ trigger: '.o_back_button',
+ content: 'Go back to the kanban view the project',
+ run: "click",
+},
+]});
diff --git a/frontend/project/static/xls/tasks_import_template.xlsx b/frontend/project/static/xls/tasks_import_template.xlsx
new file mode 100644
index 0000000..c37b4b3
Binary files /dev/null and b/frontend/project/static/xls/tasks_import_template.xlsx differ
diff --git a/frontend/purchase/static/description/icon.png b/frontend/purchase/static/description/icon.png
new file mode 100644
index 0000000..3be331c
Binary files /dev/null and b/frontend/purchase/static/description/icon.png differ
diff --git a/frontend/purchase/static/description/icon.svg b/frontend/purchase/static/description/icon.svg
new file mode 100644
index 0000000..6cd77d4
--- /dev/null
+++ b/frontend/purchase/static/description/icon.svg
@@ -0,0 +1 @@
+
diff --git a/frontend/purchase/static/description/icon_hi.png b/frontend/purchase/static/description/icon_hi.png
new file mode 100644
index 0000000..344656f
Binary files /dev/null and b/frontend/purchase/static/description/icon_hi.png differ
diff --git a/frontend/purchase/static/src/components/monetary_field_no_zero/monetary_field_no_zero.js b/frontend/purchase/static/src/components/monetary_field_no_zero/monetary_field_no_zero.js
new file mode 100644
index 0000000..bf1cc9d
--- /dev/null
+++ b/frontend/purchase/static/src/components/monetary_field_no_zero/monetary_field_no_zero.js
@@ -0,0 +1,23 @@
+import { monetaryField, MonetaryField } from "@web/views/fields/monetary/monetary_field";
+import { registry } from "@web/core/registry";
+import { floatIsZero } from "@web/core/utils/numbers";
+
+export class MonetaryFieldNoZero extends MonetaryField {
+ static props = {
+ ...MonetaryField.props,
+ };
+
+ /** Override **/
+ get value() {
+ const originalValue = super.value;
+ const decimals = this.currencyDigits ? this.currencyDigits[1] : 2;
+ return floatIsZero(originalValue, decimals) ? false : originalValue;
+ }
+}
+
+export const monetaryFieldNoZero = {
+ ...monetaryField,
+ component: MonetaryFieldNoZero,
+};
+
+registry.category("fields").add("monetary_no_zero", monetaryFieldNoZero);
diff --git a/frontend/purchase/static/src/components/open_match_line_widget/open_match_line_widget.js b/frontend/purchase/static/src/components/open_match_line_widget/open_match_line_widget.js
new file mode 100644
index 0000000..bb64064
--- /dev/null
+++ b/frontend/purchase/static/src/components/open_match_line_widget/open_match_line_widget.js
@@ -0,0 +1,27 @@
+import { registry } from "@web/core/registry";
+import { useService } from "@web/core/utils/hooks";
+import { standardFieldProps } from "@web/views/fields/standard_field_props";
+import { Component } from "@odoo/owl";
+
+class OpenMatchLineWidget extends Component {
+ static template = "purchase.OpenMatchLineWidget";
+ static props = { ...standardFieldProps };
+
+ setup() {
+ super.setup();
+ this.action = useService("action");
+ }
+
+ async openMatchLine() {
+ this.action.doActionButton({
+ type: "object",
+ resId: this.props.record.resId,
+ name: "action_open_line",
+ resModel: "purchase.bill.line.match",
+ });
+ }
+}
+
+registry.category("fields").add("open_match_line_widget", {
+ component: OpenMatchLineWidget,
+});
diff --git a/frontend/purchase/static/src/components/open_match_line_widget/open_match_line_widget.xml b/frontend/purchase/static/src/components/open_match_line_widget/open_match_line_widget.xml
new file mode 100644
index 0000000..0e32b10
--- /dev/null
+++ b/frontend/purchase/static/src/components/open_match_line_widget/open_match_line_widget.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/purchase/static/src/components/purchase_file_uploader/purchase_file_uploader.js b/frontend/purchase/static/src/components/purchase_file_uploader/purchase_file_uploader.js
new file mode 100644
index 0000000..2b8a134
--- /dev/null
+++ b/frontend/purchase/static/src/components/purchase_file_uploader/purchase_file_uploader.js
@@ -0,0 +1,90 @@
+import { useService } from "@web/core/utils/hooks";
+import { registry } from "@web/core/registry";
+import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
+import { FileUploader } from "@web/views/fields/file_handler";
+import { WarningDialog } from "@web/core/errors/error_dialogs";
+import { _t } from "@web/core/l10n/translation";
+
+import { Component } from "@odoo/owl";
+
+export class PurchaseFileUploader extends Component {
+ static template = "purchase.DocumentFileUploader";
+ static props = {
+ ...standardWidgetProps,
+ record: { type: Object, optional: true },
+ list: { type: Object, optional: true },
+ };
+ static components = { FileUploader };
+
+ setup() {
+ this.orm = useService("orm");
+ this.action = useService("action");
+ this.dialog = useService("dialog");
+ this.attachmentIdsToProcess = [];
+ }
+
+ get resModel() {
+ return "purchase.order";
+ }
+
+ get records() {
+ return this.props.record ? [this.props.record] : this.props.list.records;
+ }
+
+ async getIds() {
+ if (this.props.record) {
+ return this.props.record.data.id;
+ }
+ return this.props.list.getResIds(true);
+ }
+
+ onClick(ev) {
+ if (this.env.config.viewType !== "list") {
+ return;
+ }
+ const vendorSet = new Set(this.props.list.selection.map((record) => record.data.partner_id.id));
+ if (vendorSet.size > 1) {
+ this.dialog.add(WarningDialog, {
+ title: _t("Validation Error"),
+ message: _t("You can only upload a bill for a single vendor at a time."),
+ });
+ return false;
+ }
+ }
+
+ async onFileUploaded(file) {
+ const att_data = {
+ name: file.name,
+ mimetype: file.type,
+ datas: file.data,
+ };
+ const [att_id] = await this.orm.create("ir.attachment", [att_data], {
+ context: { ...this.env.searchModel.context },
+ });
+ this.attachmentIdsToProcess.push(att_id);
+ }
+
+ async onUploadComplete() {
+ const resModel = this.resModel;
+ const ids = await this.getIds();
+ let action;
+ try {
+ action = await this.orm.call(
+ resModel,
+ "action_create_invoice",
+ [ids, this.attachmentIdsToProcess],
+ { context: { ...this.env.searchModel.context } }
+ );
+ } finally {
+ // ensures attachments are cleared on success as well as on error
+ this.attachmentIdsToProcess = [];
+ }
+ this.action.doAction(action);
+ }
+}
+
+export const purchaseFileUploader = {
+ component: PurchaseFileUploader,
+};
+
+registry.category("view_widgets").add("purchase_file_uploader", purchaseFileUploader);
diff --git a/frontend/purchase/static/src/components/purchase_file_uploader/purchase_file_uploader.xml b/frontend/purchase/static/src/components/purchase_file_uploader/purchase_file_uploader.xml
new file mode 100644
index 0000000..72834d5
--- /dev/null
+++ b/frontend/purchase/static/src/components/purchase_file_uploader/purchase_file_uploader.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+ Upload Bill
+
+
+
+
+
+
diff --git a/frontend/purchase/static/src/components/tax_totals/tax_totals.xml b/frontend/purchase/static/src/components/tax_totals/tax_totals.xml
new file mode 100644
index 0000000..5624ed5
--- /dev/null
+++ b/frontend/purchase/static/src/components/tax_totals/tax_totals.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: var(--#{$prefix}heading-color);\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `
`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 2. Add explicit cursor to indicate changed behavior.\n// 3. Prevent the text-decoration to be skipped.\n\nabbr[title] {\n text-decoration: underline dotted; // 1\n cursor: help; // 2\n text-decoration-skip-ink: none; // 3\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n color: var(--#{$prefix}highlight-color);\n background-color: var(--#{$prefix}highlight-bg);\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: rgba(var(--#{$prefix}link-color-rgb), var(--#{$prefix}link-opacity, 1));\n text-decoration: $link-decoration;\n\n &:hover {\n --#{$prefix}link-color-rgb: var(--#{$prefix}link-hover-color-rgb);\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: var(--#{$prefix}code-color);\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `
` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-`` buttons\n//\n// Details at https://github.com/twbs/bootstrap/pull/30562\n[role=\"button\"] {\n cursor: pointer;\n}\n\nselect {\n // Remove the inheritance of word-wrap in Safari.\n // See https://github.com/twbs/bootstrap/issues/24990\n word-wrap: normal;\n\n // Undo the opacity change from Chrome\n &:disabled {\n opacity: 1;\n }\n}\n\n// Remove the dropdown arrow only from text type inputs built with datalists in Chrome.\n// See https://stackoverflow.com/a/54997118\n\n[list]:not([type=\"date\"]):not([type=\"datetime-local\"]):not([type=\"month\"]):not([type=\"week\"]):not([type=\"time\"])::-webkit-calendar-picker-indicator {\n display: none !important;\n}\n\n// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`\n// controls in Android 4.\n// 2. Correct the inability to style clickable types in iOS and Safari.\n// 3. Opinionated: add \"hand\" cursor to non-disabled button elements.\n\nbutton,\n[type=\"button\"], // 1\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button; // 2\n\n @if $enable-button-pointers {\n &:not(:disabled) {\n cursor: pointer; // 3\n }\n }\n}\n\n// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\n// 1. Textareas should really only resize vertically so they don't break their (horizontal) containers.\n\ntextarea {\n resize: vertical; // 1\n}\n\n// 1. Browsers set a default `min-width: min-content;` on fieldsets,\n// unlike e.g. `
`s, which have `min-width: 0;` by default.\n// So we reset that to ensure fieldsets behave more like a standard block element.\n// See https://github.com/twbs/bootstrap/issues/12359\n// and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements\n// 2. Reset the default outline behavior of fieldsets so they don't affect page layout.\n\nfieldset {\n min-width: 0; // 1\n padding: 0; // 2\n margin: 0; // 2\n border: 0; // 2\n}\n\n// 1. By using `float: left`, the legend will behave like a block element.\n// This way the border of a fieldset wraps around the legend if present.\n// 2. Fix wrapping bug.\n// See https://github.com/twbs/bootstrap/issues/29712\n\nlegend {\n float: left; // 1\n width: 100%;\n padding: 0;\n margin-bottom: $legend-margin-bottom;\n @include font-size($legend-font-size);\n font-weight: $legend-font-weight;\n line-height: inherit;\n\n + * {\n clear: left; // 2\n }\n}\n\n// Fix height of inputs with a type of datetime-local, date, month, week, or time\n// See https://github.com/twbs/bootstrap/issues/18842\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n// 1. This overrides the extra rounded corners on search inputs in iOS so that our\n// `.form-control` class can properly style them. Note that this cannot simply\n// be added to `.form-control` as it's not specific enough. For details, see\n// https://github.com/twbs/bootstrap/issues/11586.\n// 2. Correct the outline style in Safari.\n\n[type=\"search\"] {\n -webkit-appearance: textfield; // 1\n outline-offset: -2px; // 2\n}\n\n// 1. A few input types should stay LTR\n// See https://rtlstyling.com/posts/rtl-styling#form-inputs\n// 2. RTL only output\n// See https://rtlcss.com/learn/usage-guide/control-directives/#raw\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n\n// Remove the inner padding in Chrome and Safari on macOS.\n\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n// Remove padding around color pickers in webkit browsers\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n\n// 1. Inherit font family and line height for file input buttons\n// 2. Correct the inability to style clickable types in iOS and Safari.\n\n::file-selector-button {\n font: inherit; // 1\n -webkit-appearance: button; // 2\n}\n\n// Correct element displays\n\noutput {\n display: inline-block;\n}\n\n// Remove border from iframe\n\niframe {\n border: 0;\n}\n\n// Summary\n//\n// 1. Add the correct display in all browsers\n\nsummary {\n display: list-item; // 1\n cursor: pointer;\n}\n\n\n// Progress\n//\n// Add the correct vertical alignment in Chrome, Firefox, and Opera.\n\nprogress {\n vertical-align: baseline;\n}\n\n\n// Hidden attribute\n//\n// Always hide an element with the `hidden` HTML attribute.\n\n[hidden] {\n display: none !important;\n}\n","// Variables\n//\n// Variables should follow the `$component-state-property-size` formula for\n// consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs.\n\n// Color system\n\n// scss-docs-start gray-color-variables\n$white: #fff !default;\n$gray-100: #f8f9fa !default;\n$gray-200: #e9ecef !default;\n$gray-300: #dee2e6 !default;\n$gray-400: #ced4da !default;\n$gray-500: #adb5bd !default;\n$gray-600: #6c757d !default;\n$gray-700: #495057 !default;\n$gray-800: #343a40 !default;\n$gray-900: #212529 !default;\n$black: #000 !default;\n// scss-docs-end gray-color-variables\n\n// fusv-disable\n// scss-docs-start gray-colors-map\n$grays: (\n \"100\": $gray-100,\n \"200\": $gray-200,\n \"300\": $gray-300,\n \"400\": $gray-400,\n \"500\": $gray-500,\n \"600\": $gray-600,\n \"700\": $gray-700,\n \"800\": $gray-800,\n \"900\": $gray-900\n) !default;\n// scss-docs-end gray-colors-map\n// fusv-enable\n\n// scss-docs-start color-variables\n$blue: #0d6efd !default;\n$indigo: #6610f2 !default;\n$purple: #6f42c1 !default;\n$pink: #d63384 !default;\n$red: #dc3545 !default;\n$orange: #fd7e14 !default;\n$yellow: #ffc107 !default;\n$green: #198754 !default;\n$teal: #20c997 !default;\n$cyan: #0dcaf0 !default;\n// scss-docs-end color-variables\n\n// scss-docs-start colors-map\n$colors: (\n \"blue\": $blue,\n \"indigo\": $indigo,\n \"purple\": $purple,\n \"pink\": $pink,\n \"red\": $red,\n \"orange\": $orange,\n \"yellow\": $yellow,\n \"green\": $green,\n \"teal\": $teal,\n \"cyan\": $cyan,\n \"black\": $black,\n \"white\": $white,\n \"gray\": $gray-600,\n \"gray-dark\": $gray-800\n) !default;\n// scss-docs-end colors-map\n\n// The contrast ratio to reach against white, to determine if color changes from \"light\" to \"dark\". Acceptable values for WCAG 2.0 are 3, 4.5 and 7.\n// See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast\n$min-contrast-ratio: 4.5 !default;\n\n// Customize the light and dark text colors for use in our color contrast function.\n$color-contrast-dark: $black !default;\n$color-contrast-light: $white !default;\n\n// fusv-disable\n$blue-100: tint-color($blue, 80%) !default;\n$blue-200: tint-color($blue, 60%) !default;\n$blue-300: tint-color($blue, 40%) !default;\n$blue-400: tint-color($blue, 20%) !default;\n$blue-500: $blue !default;\n$blue-600: shade-color($blue, 20%) !default;\n$blue-700: shade-color($blue, 40%) !default;\n$blue-800: shade-color($blue, 60%) !default;\n$blue-900: shade-color($blue, 80%) !default;\n\n$indigo-100: tint-color($indigo, 80%) !default;\n$indigo-200: tint-color($indigo, 60%) !default;\n$indigo-300: tint-color($indigo, 40%) !default;\n$indigo-400: tint-color($indigo, 20%) !default;\n$indigo-500: $indigo !default;\n$indigo-600: shade-color($indigo, 20%) !default;\n$indigo-700: shade-color($indigo, 40%) !default;\n$indigo-800: shade-color($indigo, 60%) !default;\n$indigo-900: shade-color($indigo, 80%) !default;\n\n$purple-100: tint-color($purple, 80%) !default;\n$purple-200: tint-color($purple, 60%) !default;\n$purple-300: tint-color($purple, 40%) !default;\n$purple-400: tint-color($purple, 20%) !default;\n$purple-500: $purple !default;\n$purple-600: shade-color($purple, 20%) !default;\n$purple-700: shade-color($purple, 40%) !default;\n$purple-800: shade-color($purple, 60%) !default;\n$purple-900: shade-color($purple, 80%) !default;\n\n$pink-100: tint-color($pink, 80%) !default;\n$pink-200: tint-color($pink, 60%) !default;\n$pink-300: tint-color($pink, 40%) !default;\n$pink-400: tint-color($pink, 20%) !default;\n$pink-500: $pink !default;\n$pink-600: shade-color($pink, 20%) !default;\n$pink-700: shade-color($pink, 40%) !default;\n$pink-800: shade-color($pink, 60%) !default;\n$pink-900: shade-color($pink, 80%) !default;\n\n$red-100: tint-color($red, 80%) !default;\n$red-200: tint-color($red, 60%) !default;\n$red-300: tint-color($red, 40%) !default;\n$red-400: tint-color($red, 20%) !default;\n$red-500: $red !default;\n$red-600: shade-color($red, 20%) !default;\n$red-700: shade-color($red, 40%) !default;\n$red-800: shade-color($red, 60%) !default;\n$red-900: shade-color($red, 80%) !default;\n\n$orange-100: tint-color($orange, 80%) !default;\n$orange-200: tint-color($orange, 60%) !default;\n$orange-300: tint-color($orange, 40%) !default;\n$orange-400: tint-color($orange, 20%) !default;\n$orange-500: $orange !default;\n$orange-600: shade-color($orange, 20%) !default;\n$orange-700: shade-color($orange, 40%) !default;\n$orange-800: shade-color($orange, 60%) !default;\n$orange-900: shade-color($orange, 80%) !default;\n\n$yellow-100: tint-color($yellow, 80%) !default;\n$yellow-200: tint-color($yellow, 60%) !default;\n$yellow-300: tint-color($yellow, 40%) !default;\n$yellow-400: tint-color($yellow, 20%) !default;\n$yellow-500: $yellow !default;\n$yellow-600: shade-color($yellow, 20%) !default;\n$yellow-700: shade-color($yellow, 40%) !default;\n$yellow-800: shade-color($yellow, 60%) !default;\n$yellow-900: shade-color($yellow, 80%) !default;\n\n$green-100: tint-color($green, 80%) !default;\n$green-200: tint-color($green, 60%) !default;\n$green-300: tint-color($green, 40%) !default;\n$green-400: tint-color($green, 20%) !default;\n$green-500: $green !default;\n$green-600: shade-color($green, 20%) !default;\n$green-700: shade-color($green, 40%) !default;\n$green-800: shade-color($green, 60%) !default;\n$green-900: shade-color($green, 80%) !default;\n\n$teal-100: tint-color($teal, 80%) !default;\n$teal-200: tint-color($teal, 60%) !default;\n$teal-300: tint-color($teal, 40%) !default;\n$teal-400: tint-color($teal, 20%) !default;\n$teal-500: $teal !default;\n$teal-600: shade-color($teal, 20%) !default;\n$teal-700: shade-color($teal, 40%) !default;\n$teal-800: shade-color($teal, 60%) !default;\n$teal-900: shade-color($teal, 80%) !default;\n\n$cyan-100: tint-color($cyan, 80%) !default;\n$cyan-200: tint-color($cyan, 60%) !default;\n$cyan-300: tint-color($cyan, 40%) !default;\n$cyan-400: tint-color($cyan, 20%) !default;\n$cyan-500: $cyan !default;\n$cyan-600: shade-color($cyan, 20%) !default;\n$cyan-700: shade-color($cyan, 40%) !default;\n$cyan-800: shade-color($cyan, 60%) !default;\n$cyan-900: shade-color($cyan, 80%) !default;\n\n$blues: (\n \"blue-100\": $blue-100,\n \"blue-200\": $blue-200,\n \"blue-300\": $blue-300,\n \"blue-400\": $blue-400,\n \"blue-500\": $blue-500,\n \"blue-600\": $blue-600,\n \"blue-700\": $blue-700,\n \"blue-800\": $blue-800,\n \"blue-900\": $blue-900\n) !default;\n\n$indigos: (\n \"indigo-100\": $indigo-100,\n \"indigo-200\": $indigo-200,\n \"indigo-300\": $indigo-300,\n \"indigo-400\": $indigo-400,\n \"indigo-500\": $indigo-500,\n \"indigo-600\": $indigo-600,\n \"indigo-700\": $indigo-700,\n \"indigo-800\": $indigo-800,\n \"indigo-900\": $indigo-900\n) !default;\n\n$purples: (\n \"purple-100\": $purple-100,\n \"purple-200\": $purple-200,\n \"purple-300\": $purple-300,\n \"purple-400\": $purple-400,\n \"purple-500\": $purple-500,\n \"purple-600\": $purple-600,\n \"purple-700\": $purple-700,\n \"purple-800\": $purple-800,\n \"purple-900\": $purple-900\n) !default;\n\n$pinks: (\n \"pink-100\": $pink-100,\n \"pink-200\": $pink-200,\n \"pink-300\": $pink-300,\n \"pink-400\": $pink-400,\n \"pink-500\": $pink-500,\n \"pink-600\": $pink-600,\n \"pink-700\": $pink-700,\n \"pink-800\": $pink-800,\n \"pink-900\": $pink-900\n) !default;\n\n$reds: (\n \"red-100\": $red-100,\n \"red-200\": $red-200,\n \"red-300\": $red-300,\n \"red-400\": $red-400,\n \"red-500\": $red-500,\n \"red-600\": $red-600,\n \"red-700\": $red-700,\n \"red-800\": $red-800,\n \"red-900\": $red-900\n) !default;\n\n$oranges: (\n \"orange-100\": $orange-100,\n \"orange-200\": $orange-200,\n \"orange-300\": $orange-300,\n \"orange-400\": $orange-400,\n \"orange-500\": $orange-500,\n \"orange-600\": $orange-600,\n \"orange-700\": $orange-700,\n \"orange-800\": $orange-800,\n \"orange-900\": $orange-900\n) !default;\n\n$yellows: (\n \"yellow-100\": $yellow-100,\n \"yellow-200\": $yellow-200,\n \"yellow-300\": $yellow-300,\n \"yellow-400\": $yellow-400,\n \"yellow-500\": $yellow-500,\n \"yellow-600\": $yellow-600,\n \"yellow-700\": $yellow-700,\n \"yellow-800\": $yellow-800,\n \"yellow-900\": $yellow-900\n) !default;\n\n$greens: (\n \"green-100\": $green-100,\n \"green-200\": $green-200,\n \"green-300\": $green-300,\n \"green-400\": $green-400,\n \"green-500\": $green-500,\n \"green-600\": $green-600,\n \"green-700\": $green-700,\n \"green-800\": $green-800,\n \"green-900\": $green-900\n) !default;\n\n$teals: (\n \"teal-100\": $teal-100,\n \"teal-200\": $teal-200,\n \"teal-300\": $teal-300,\n \"teal-400\": $teal-400,\n \"teal-500\": $teal-500,\n \"teal-600\": $teal-600,\n \"teal-700\": $teal-700,\n \"teal-800\": $teal-800,\n \"teal-900\": $teal-900\n) !default;\n\n$cyans: (\n \"cyan-100\": $cyan-100,\n \"cyan-200\": $cyan-200,\n \"cyan-300\": $cyan-300,\n \"cyan-400\": $cyan-400,\n \"cyan-500\": $cyan-500,\n \"cyan-600\": $cyan-600,\n \"cyan-700\": $cyan-700,\n \"cyan-800\": $cyan-800,\n \"cyan-900\": $cyan-900\n) !default;\n// fusv-enable\n\n// scss-docs-start theme-color-variables\n$primary: $blue !default;\n$secondary: $gray-600 !default;\n$success: $green !default;\n$info: $cyan !default;\n$warning: $yellow !default;\n$danger: $red !default;\n$light: $gray-100 !default;\n$dark: $gray-900 !default;\n// scss-docs-end theme-color-variables\n\n// scss-docs-start theme-colors-map\n$theme-colors: (\n \"primary\": $primary,\n \"secondary\": $secondary,\n \"success\": $success,\n \"info\": $info,\n \"warning\": $warning,\n \"danger\": $danger,\n \"light\": $light,\n \"dark\": $dark\n) !default;\n// scss-docs-end theme-colors-map\n\n// scss-docs-start theme-text-variables\n$primary-text-emphasis: shade-color($primary, 60%) !default;\n$secondary-text-emphasis: shade-color($secondary, 60%) !default;\n$success-text-emphasis: shade-color($success, 60%) !default;\n$info-text-emphasis: shade-color($info, 60%) !default;\n$warning-text-emphasis: shade-color($warning, 60%) !default;\n$danger-text-emphasis: shade-color($danger, 60%) !default;\n$light-text-emphasis: $gray-700 !default;\n$dark-text-emphasis: $gray-700 !default;\n// scss-docs-end theme-text-variables\n\n// scss-docs-start theme-bg-subtle-variables\n$primary-bg-subtle: tint-color($primary, 80%) !default;\n$secondary-bg-subtle: tint-color($secondary, 80%) !default;\n$success-bg-subtle: tint-color($success, 80%) !default;\n$info-bg-subtle: tint-color($info, 80%) !default;\n$warning-bg-subtle: tint-color($warning, 80%) !default;\n$danger-bg-subtle: tint-color($danger, 80%) !default;\n$light-bg-subtle: mix($gray-100, $white) !default;\n$dark-bg-subtle: $gray-400 !default;\n// scss-docs-end theme-bg-subtle-variables\n\n// scss-docs-start theme-border-subtle-variables\n$primary-border-subtle: tint-color($primary, 60%) !default;\n$secondary-border-subtle: tint-color($secondary, 60%) !default;\n$success-border-subtle: tint-color($success, 60%) !default;\n$info-border-subtle: tint-color($info, 60%) !default;\n$warning-border-subtle: tint-color($warning, 60%) !default;\n$danger-border-subtle: tint-color($danger, 60%) !default;\n$light-border-subtle: $gray-200 !default;\n$dark-border-subtle: $gray-500 !default;\n// scss-docs-end theme-border-subtle-variables\n\n// Characters which are escaped by the escape-svg function\n$escaped-characters: (\n (\"<\", \"%3c\"),\n (\">\", \"%3e\"),\n (\"#\", \"%23\"),\n (\"(\", \"%28\"),\n (\")\", \"%29\"),\n) !default;\n\n// Options\n//\n// Quickly modify global styling by enabling or disabling optional features.\n\n$enable-caret: true !default;\n$enable-rounded: true !default;\n$enable-shadows: false !default;\n$enable-gradients: false !default;\n$enable-transitions: true !default;\n$enable-reduced-motion: true !default;\n$enable-smooth-scroll: true !default;\n$enable-grid-classes: true !default;\n$enable-container-classes: true !default;\n$enable-cssgrid: false !default;\n$enable-button-pointers: true !default;\n$enable-rfs: true !default;\n$enable-validation-icons: true !default;\n$enable-negative-margins: false !default;\n$enable-deprecation-messages: true !default;\n$enable-important-utilities: true !default;\n\n$enable-dark-mode: true !default;\n$color-mode-type: data !default; // `data` or `media-query`\n\n// Prefix for :root CSS variables\n\n$variable-prefix: bs- !default; // Deprecated in v5.2.0 for the shorter `$prefix`\n$prefix: $variable-prefix !default;\n\n// Gradient\n//\n// The gradient which is added to components if `$enable-gradients` is `true`\n// This gradient is also added to elements with `.bg-gradient`\n// scss-docs-start variable-gradient\n$gradient: linear-gradient(180deg, rgba($white, .15), rgba($white, 0)) !default;\n// scss-docs-end variable-gradient\n\n// Spacing\n//\n// Control the default styling of most Bootstrap elements by modifying these\n// variables. Mostly focused on spacing.\n// You can add more entries to the $spacers map, should you need more variation.\n\n// scss-docs-start spacer-variables-maps\n$spacer: 1rem !default;\n$spacers: (\n 0: 0,\n 1: $spacer * .25,\n 2: $spacer * .5,\n 3: $spacer,\n 4: $spacer * 1.5,\n 5: $spacer * 3,\n) !default;\n// scss-docs-end spacer-variables-maps\n\n// Position\n//\n// Define the edge positioning anchors of the position utilities.\n\n// scss-docs-start position-map\n$position-values: (\n 0: 0,\n 50: 50%,\n 100: 100%\n) !default;\n// scss-docs-end position-map\n\n// Body\n//\n// Settings for the `` element.\n\n$body-text-align: null !default;\n$body-color: $gray-900 !default;\n$body-bg: $white !default;\n\n$body-secondary-color: rgba($body-color, .75) !default;\n$body-secondary-bg: $gray-200 !default;\n\n$body-tertiary-color: rgba($body-color, .5) !default;\n$body-tertiary-bg: $gray-100 !default;\n\n$body-emphasis-color: $black !default;\n\n// Links\n//\n// Style anchor elements.\n\n$link-color: $primary !default;\n$link-decoration: underline !default;\n$link-shade-percentage: 20% !default;\n$link-hover-color: shift-color($link-color, $link-shade-percentage) !default;\n$link-hover-decoration: null !default;\n\n$stretched-link-pseudo-element: after !default;\n$stretched-link-z-index: 1 !default;\n\n// Icon links\n// scss-docs-start icon-link-variables\n$icon-link-gap: .375rem !default;\n$icon-link-underline-offset: .25em !default;\n$icon-link-icon-size: 1em !default;\n$icon-link-icon-transition: .2s ease-in-out transform !default;\n$icon-link-icon-transform: translate3d(.25em, 0, 0) !default;\n// scss-docs-end icon-link-variables\n\n// Paragraphs\n//\n// Style p element.\n\n$paragraph-margin-bottom: 1rem !default;\n\n\n// Grid breakpoints\n//\n// Define the minimum dimensions at which your layout will change,\n// adapting to different screen sizes, for use in media queries.\n\n// scss-docs-start grid-breakpoints\n$grid-breakpoints: (\n xs: 0,\n sm: 576px,\n md: 768px,\n lg: 992px,\n xl: 1200px,\n xxl: 1400px\n) !default;\n// scss-docs-end grid-breakpoints\n\n@include _assert-ascending($grid-breakpoints, \"$grid-breakpoints\");\n@include _assert-starts-at-zero($grid-breakpoints, \"$grid-breakpoints\");\n\n\n// Grid containers\n//\n// Define the maximum width of `.container` for different screen sizes.\n\n// scss-docs-start container-max-widths\n$container-max-widths: (\n sm: 540px,\n md: 720px,\n lg: 960px,\n xl: 1140px,\n xxl: 1320px\n) !default;\n// scss-docs-end container-max-widths\n\n@include _assert-ascending($container-max-widths, \"$container-max-widths\");\n\n\n// Grid columns\n//\n// Set the number of columns and specify the width of the gutters.\n\n$grid-columns: 12 !default;\n$grid-gutter-width: 1.5rem !default;\n$grid-row-columns: 6 !default;\n\n// Container padding\n\n$container-padding-x: $grid-gutter-width !default;\n\n\n// Components\n//\n// Define common padding and border radius sizes and more.\n\n// scss-docs-start border-variables\n$border-width: 1px !default;\n$border-widths: (\n 1: 1px,\n 2: 2px,\n 3: 3px,\n 4: 4px,\n 5: 5px\n) !default;\n$border-style: solid !default;\n$border-color: $gray-300 !default;\n$border-color-translucent: rgba($black, .175) !default;\n// scss-docs-end border-variables\n\n// scss-docs-start border-radius-variables\n$border-radius: .375rem !default;\n$border-radius-sm: .25rem !default;\n$border-radius-lg: .5rem !default;\n$border-radius-xl: 1rem !default;\n$border-radius-xxl: 2rem !default;\n$border-radius-pill: 50rem !default;\n// scss-docs-end border-radius-variables\n// fusv-disable\n$border-radius-2xl: $border-radius-xxl !default; // Deprecated in v5.3.0\n// fusv-enable\n\n// scss-docs-start box-shadow-variables\n$box-shadow: 0 .5rem 1rem rgba($black, .15) !default;\n$box-shadow-sm: 0 .125rem .25rem rgba($black, .075) !default;\n$box-shadow-lg: 0 1rem 3rem rgba($black, .175) !default;\n$box-shadow-inset: inset 0 1px 2px rgba($black, .075) !default;\n// scss-docs-end box-shadow-variables\n\n$component-active-color: $white !default;\n$component-active-bg: $primary !default;\n\n// scss-docs-start focus-ring-variables\n$focus-ring-width: .25rem !default;\n$focus-ring-opacity: .25 !default;\n$focus-ring-color: rgba($primary, $focus-ring-opacity) !default;\n$focus-ring-blur: 0 !default;\n$focus-ring-box-shadow: 0 0 $focus-ring-blur $focus-ring-width $focus-ring-color !default;\n// scss-docs-end focus-ring-variables\n\n// scss-docs-start caret-variables\n$caret-width: .3em !default;\n$caret-vertical-align: $caret-width * .85 !default;\n$caret-spacing: $caret-width * .85 !default;\n// scss-docs-end caret-variables\n\n$transition-base: all .2s ease-in-out !default;\n$transition-fade: opacity .15s linear !default;\n// scss-docs-start collapse-transition\n$transition-collapse: height .35s ease !default;\n$transition-collapse-width: width .35s ease !default;\n// scss-docs-end collapse-transition\n\n// stylelint-disable function-disallowed-list\n// scss-docs-start aspect-ratios\n$aspect-ratios: (\n \"1x1\": 100%,\n \"4x3\": calc(3 / 4 * 100%),\n \"16x9\": calc(9 / 16 * 100%),\n \"21x9\": calc(9 / 21 * 100%)\n) !default;\n// scss-docs-end aspect-ratios\n// stylelint-enable function-disallowed-list\n\n// Typography\n//\n// Font, line-height, and color for body text, headings, and more.\n\n// scss-docs-start font-variables\n// stylelint-disable value-keyword-case\n$font-family-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\" !default;\n$font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace !default;\n// stylelint-enable value-keyword-case\n$font-family-base: var(--#{$prefix}font-sans-serif) !default;\n$font-family-code: var(--#{$prefix}font-monospace) !default;\n\n// $font-size-root affects the value of `rem`, which is used for as well font sizes, paddings, and margins\n// $font-size-base affects the font size of the body text\n$font-size-root: null !default;\n$font-size-base: 1rem !default; // Assumes the browser default, typically `16px`\n$font-size-sm: $font-size-base * .875 !default;\n$font-size-lg: $font-size-base * 1.25 !default;\n\n$font-weight-lighter: lighter !default;\n$font-weight-light: 300 !default;\n$font-weight-normal: 400 !default;\n$font-weight-medium: 500 !default;\n$font-weight-semibold: 600 !default;\n$font-weight-bold: 700 !default;\n$font-weight-bolder: bolder !default;\n\n$font-weight-base: $font-weight-normal !default;\n\n$line-height-base: 1.5 !default;\n$line-height-sm: 1.25 !default;\n$line-height-lg: 2 !default;\n\n$h1-font-size: $font-size-base * 2.5 !default;\n$h2-font-size: $font-size-base * 2 !default;\n$h3-font-size: $font-size-base * 1.75 !default;\n$h4-font-size: $font-size-base * 1.5 !default;\n$h5-font-size: $font-size-base * 1.25 !default;\n$h6-font-size: $font-size-base !default;\n// scss-docs-end font-variables\n\n// scss-docs-start font-sizes\n$font-sizes: (\n 1: $h1-font-size,\n 2: $h2-font-size,\n 3: $h3-font-size,\n 4: $h4-font-size,\n 5: $h5-font-size,\n 6: $h6-font-size\n) !default;\n// scss-docs-end font-sizes\n\n// scss-docs-start headings-variables\n$headings-margin-bottom: $spacer * .5 !default;\n$headings-font-family: null !default;\n$headings-font-style: null !default;\n$headings-font-weight: 500 !default;\n$headings-line-height: 1.2 !default;\n$headings-color: inherit !default;\n// scss-docs-end headings-variables\n\n// scss-docs-start display-headings\n$display-font-sizes: (\n 1: 5rem,\n 2: 4.5rem,\n 3: 4rem,\n 4: 3.5rem,\n 5: 3rem,\n 6: 2.5rem\n) !default;\n\n$display-font-family: null !default;\n$display-font-style: null !default;\n$display-font-weight: 300 !default;\n$display-line-height: $headings-line-height !default;\n// scss-docs-end display-headings\n\n// scss-docs-start type-variables\n$lead-font-size: $font-size-base * 1.25 !default;\n$lead-font-weight: 300 !default;\n\n$small-font-size: .875em !default;\n\n$sub-sup-font-size: .75em !default;\n\n// fusv-disable\n$text-muted: var(--#{$prefix}secondary-color) !default; // Deprecated in 5.3.0\n// fusv-enable\n\n$initialism-font-size: $small-font-size !default;\n\n$blockquote-margin-y: $spacer !default;\n$blockquote-font-size: $font-size-base * 1.25 !default;\n$blockquote-footer-color: $gray-600 !default;\n$blockquote-footer-font-size: $small-font-size !default;\n\n$hr-margin-y: $spacer !default;\n$hr-color: inherit !default;\n\n// fusv-disable\n$hr-bg-color: null !default; // Deprecated in v5.2.0\n$hr-height: null !default; // Deprecated in v5.2.0\n// fusv-enable\n\n$hr-border-color: null !default; // Allows for inherited colors\n$hr-border-width: var(--#{$prefix}border-width) !default;\n$hr-opacity: .25 !default;\n\n// scss-docs-start vr-variables\n$vr-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end vr-variables\n\n$legend-margin-bottom: .5rem !default;\n$legend-font-size: 1.5rem !default;\n$legend-font-weight: null !default;\n\n$dt-font-weight: $font-weight-bold !default;\n\n$list-inline-padding: .5rem !default;\n\n$mark-padding: .1875em !default;\n$mark-color: $body-color !default;\n$mark-bg: $yellow-100 !default;\n// scss-docs-end type-variables\n\n\n// Tables\n//\n// Customizes the `.table` component with basic values, each used across all table variations.\n\n// scss-docs-start table-variables\n$table-cell-padding-y: .5rem !default;\n$table-cell-padding-x: .5rem !default;\n$table-cell-padding-y-sm: .25rem !default;\n$table-cell-padding-x-sm: .25rem !default;\n\n$table-cell-vertical-align: top !default;\n\n$table-color: var(--#{$prefix}emphasis-color) !default;\n$table-bg: var(--#{$prefix}body-bg) !default;\n$table-accent-bg: transparent !default;\n\n$table-th-font-weight: null !default;\n\n$table-striped-color: $table-color !default;\n$table-striped-bg-factor: .05 !default;\n$table-striped-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-striped-bg-factor) !default;\n\n$table-active-color: $table-color !default;\n$table-active-bg-factor: .1 !default;\n$table-active-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-active-bg-factor) !default;\n\n$table-hover-color: $table-color !default;\n$table-hover-bg-factor: .075 !default;\n$table-hover-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-hover-bg-factor) !default;\n\n$table-border-factor: .2 !default;\n$table-border-width: var(--#{$prefix}border-width) !default;\n$table-border-color: var(--#{$prefix}border-color) !default;\n\n$table-striped-order: odd !default;\n$table-striped-columns-order: even !default;\n\n$table-group-separator-color: currentcolor !default;\n\n$table-caption-color: var(--#{$prefix}secondary-color) !default;\n\n$table-bg-scale: -80% !default;\n// scss-docs-end table-variables\n\n// scss-docs-start table-loop\n$table-variants: (\n \"primary\": shift-color($primary, $table-bg-scale),\n \"secondary\": shift-color($secondary, $table-bg-scale),\n \"success\": shift-color($success, $table-bg-scale),\n \"info\": shift-color($info, $table-bg-scale),\n \"warning\": shift-color($warning, $table-bg-scale),\n \"danger\": shift-color($danger, $table-bg-scale),\n \"light\": $light,\n \"dark\": $dark,\n) !default;\n// scss-docs-end table-loop\n\n\n// Buttons + Forms\n//\n// Shared variables that are reassigned to `$input-` and `$btn-` specific variables.\n\n// scss-docs-start input-btn-variables\n$input-btn-padding-y: .375rem !default;\n$input-btn-padding-x: .75rem !default;\n$input-btn-font-family: null !default;\n$input-btn-font-size: $font-size-base !default;\n$input-btn-line-height: $line-height-base !default;\n\n$input-btn-focus-width: $focus-ring-width !default;\n$input-btn-focus-color-opacity: $focus-ring-opacity !default;\n$input-btn-focus-color: $focus-ring-color !default;\n$input-btn-focus-blur: $focus-ring-blur !default;\n$input-btn-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$input-btn-padding-y-sm: .25rem !default;\n$input-btn-padding-x-sm: .5rem !default;\n$input-btn-font-size-sm: $font-size-sm !default;\n\n$input-btn-padding-y-lg: .5rem !default;\n$input-btn-padding-x-lg: 1rem !default;\n$input-btn-font-size-lg: $font-size-lg !default;\n\n$input-btn-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end input-btn-variables\n\n\n// Buttons\n//\n// For each of Bootstrap's buttons, define text, background, and border color.\n\n// scss-docs-start btn-variables\n$btn-color: var(--#{$prefix}body-color) !default;\n$btn-padding-y: $input-btn-padding-y !default;\n$btn-padding-x: $input-btn-padding-x !default;\n$btn-font-family: $input-btn-font-family !default;\n$btn-font-size: $input-btn-font-size !default;\n$btn-line-height: $input-btn-line-height !default;\n$btn-white-space: null !default; // Set to `nowrap` to prevent text wrapping\n\n$btn-padding-y-sm: $input-btn-padding-y-sm !default;\n$btn-padding-x-sm: $input-btn-padding-x-sm !default;\n$btn-font-size-sm: $input-btn-font-size-sm !default;\n\n$btn-padding-y-lg: $input-btn-padding-y-lg !default;\n$btn-padding-x-lg: $input-btn-padding-x-lg !default;\n$btn-font-size-lg: $input-btn-font-size-lg !default;\n\n$btn-border-width: $input-btn-border-width !default;\n\n$btn-font-weight: $font-weight-normal !default;\n$btn-box-shadow: inset 0 1px 0 rgba($white, .15), 0 1px 1px rgba($black, .075) !default;\n$btn-focus-width: $input-btn-focus-width !default;\n$btn-focus-box-shadow: $input-btn-focus-box-shadow !default;\n$btn-disabled-opacity: .65 !default;\n$btn-active-box-shadow: inset 0 3px 5px rgba($black, .125) !default;\n\n$btn-link-color: var(--#{$prefix}link-color) !default;\n$btn-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$btn-link-disabled-color: $gray-600 !default;\n$btn-link-focus-shadow-rgb: to-rgb(mix(color-contrast($link-color), $link-color, 15%)) !default;\n\n// Allows for customizing button radius independently from global border radius\n$btn-border-radius: var(--#{$prefix}border-radius) !default;\n$btn-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$btn-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$btn-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$btn-hover-bg-shade-amount: 15% !default;\n$btn-hover-bg-tint-amount: 15% !default;\n$btn-hover-border-shade-amount: 20% !default;\n$btn-hover-border-tint-amount: 10% !default;\n$btn-active-bg-shade-amount: 20% !default;\n$btn-active-bg-tint-amount: 20% !default;\n$btn-active-border-shade-amount: 25% !default;\n$btn-active-border-tint-amount: 10% !default;\n// scss-docs-end btn-variables\n\n\n// Forms\n\n// scss-docs-start form-text-variables\n$form-text-margin-top: .25rem !default;\n$form-text-font-size: $small-font-size !default;\n$form-text-font-style: null !default;\n$form-text-font-weight: null !default;\n$form-text-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end form-text-variables\n\n// scss-docs-start form-label-variables\n$form-label-margin-bottom: .5rem !default;\n$form-label-font-size: null !default;\n$form-label-font-style: null !default;\n$form-label-font-weight: null !default;\n$form-label-color: null !default;\n// scss-docs-end form-label-variables\n\n// scss-docs-start form-input-variables\n$input-padding-y: $input-btn-padding-y !default;\n$input-padding-x: $input-btn-padding-x !default;\n$input-font-family: $input-btn-font-family !default;\n$input-font-size: $input-btn-font-size !default;\n$input-font-weight: $font-weight-base !default;\n$input-line-height: $input-btn-line-height !default;\n\n$input-padding-y-sm: $input-btn-padding-y-sm !default;\n$input-padding-x-sm: $input-btn-padding-x-sm !default;\n$input-font-size-sm: $input-btn-font-size-sm !default;\n\n$input-padding-y-lg: $input-btn-padding-y-lg !default;\n$input-padding-x-lg: $input-btn-padding-x-lg !default;\n$input-font-size-lg: $input-btn-font-size-lg !default;\n\n$input-bg: var(--#{$prefix}body-bg) !default;\n$input-disabled-color: null !default;\n$input-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$input-disabled-border-color: null !default;\n\n$input-color: var(--#{$prefix}body-color) !default;\n$input-border-color: var(--#{$prefix}border-color) !default;\n$input-border-width: $input-btn-border-width !default;\n$input-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$input-border-radius: var(--#{$prefix}border-radius) !default;\n$input-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$input-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$input-focus-bg: $input-bg !default;\n$input-focus-border-color: tint-color($component-active-bg, 50%) !default;\n$input-focus-color: $input-color !default;\n$input-focus-width: $input-btn-focus-width !default;\n$input-focus-box-shadow: $input-btn-focus-box-shadow !default;\n\n$input-placeholder-color: var(--#{$prefix}secondary-color) !default;\n$input-plaintext-color: var(--#{$prefix}body-color) !default;\n\n$input-height-border: calc(#{$input-border-width} * 2) !default; // stylelint-disable-line function-disallowed-list\n\n$input-height-inner: add($input-line-height * 1em, $input-padding-y * 2) !default;\n$input-height-inner-half: add($input-line-height * .5em, $input-padding-y) !default;\n$input-height-inner-quarter: add($input-line-height * .25em, $input-padding-y * .5) !default;\n\n$input-height: add($input-line-height * 1em, add($input-padding-y * 2, $input-height-border, false)) !default;\n$input-height-sm: add($input-line-height * 1em, add($input-padding-y-sm * 2, $input-height-border, false)) !default;\n$input-height-lg: add($input-line-height * 1em, add($input-padding-y-lg * 2, $input-height-border, false)) !default;\n\n$input-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$form-color-width: 3rem !default;\n// scss-docs-end form-input-variables\n\n// scss-docs-start form-check-variables\n$form-check-input-width: 1em !default;\n$form-check-min-height: $font-size-base * $line-height-base !default;\n$form-check-padding-start: $form-check-input-width + .5em !default;\n$form-check-margin-bottom: .125rem !default;\n$form-check-label-color: null !default;\n$form-check-label-cursor: null !default;\n$form-check-transition: null !default;\n\n$form-check-input-active-filter: brightness(90%) !default;\n\n$form-check-input-bg: $input-bg !default;\n$form-check-input-border: var(--#{$prefix}border-width) solid var(--#{$prefix}border-color) !default;\n$form-check-input-border-radius: .25em !default;\n$form-check-radio-border-radius: 50% !default;\n$form-check-input-focus-border: $input-focus-border-color !default;\n$form-check-input-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$form-check-input-checked-color: $component-active-color !default;\n$form-check-input-checked-bg-color: $component-active-bg !default;\n$form-check-input-checked-border-color: $form-check-input-checked-bg-color !default;\n$form-check-input-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-check-radio-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-indeterminate-color: $component-active-color !default;\n$form-check-input-indeterminate-bg-color: $component-active-bg !default;\n$form-check-input-indeterminate-border-color: $form-check-input-indeterminate-bg-color !default;\n$form-check-input-indeterminate-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-disabled-opacity: .5 !default;\n$form-check-label-disabled-opacity: $form-check-input-disabled-opacity !default;\n$form-check-btn-check-disabled-opacity: $btn-disabled-opacity !default;\n\n$form-check-inline-margin-end: 1rem !default;\n// scss-docs-end form-check-variables\n\n// scss-docs-start form-switch-variables\n$form-switch-color: rgba($black, .25) !default;\n$form-switch-width: 2em !default;\n$form-switch-padding-start: $form-switch-width + .5em !default;\n$form-switch-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-border-radius: $form-switch-width !default;\n$form-switch-transition: background-position .15s ease-in-out !default;\n\n$form-switch-focus-color: $input-focus-border-color !default;\n$form-switch-focus-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-switch-checked-color: $component-active-color !default;\n$form-switch-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-checked-bg-position: right center !default;\n// scss-docs-end form-switch-variables\n\n// scss-docs-start input-group-variables\n$input-group-addon-padding-y: $input-padding-y !default;\n$input-group-addon-padding-x: $input-padding-x !default;\n$input-group-addon-font-weight: $input-font-weight !default;\n$input-group-addon-color: $input-color !default;\n$input-group-addon-bg: var(--#{$prefix}tertiary-bg) !default;\n$input-group-addon-border-color: $input-border-color !default;\n// scss-docs-end input-group-variables\n\n// scss-docs-start form-select-variables\n$form-select-padding-y: $input-padding-y !default;\n$form-select-padding-x: $input-padding-x !default;\n$form-select-font-family: $input-font-family !default;\n$form-select-font-size: $input-font-size !default;\n$form-select-indicator-padding: $form-select-padding-x * 3 !default; // Extra padding for background-image\n$form-select-font-weight: $input-font-weight !default;\n$form-select-line-height: $input-line-height !default;\n$form-select-color: $input-color !default;\n$form-select-bg: $input-bg !default;\n$form-select-disabled-color: null !default;\n$form-select-disabled-bg: $input-disabled-bg !default;\n$form-select-disabled-border-color: $input-disabled-border-color !default;\n$form-select-bg-position: right $form-select-padding-x center !default;\n$form-select-bg-size: 16px 12px !default; // In pixels because image dimensions\n$form-select-indicator-color: $gray-800 !default;\n$form-select-indicator: url(\"data:image/svg+xml,\") !default;\n\n$form-select-feedback-icon-padding-end: $form-select-padding-x * 2.5 + $form-select-indicator-padding !default;\n$form-select-feedback-icon-position: center right $form-select-indicator-padding !default;\n$form-select-feedback-icon-size: $input-height-inner-half $input-height-inner-half !default;\n\n$form-select-border-width: $input-border-width !default;\n$form-select-border-color: $input-border-color !default;\n$form-select-border-radius: $input-border-radius !default;\n$form-select-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-select-focus-border-color: $input-focus-border-color !default;\n$form-select-focus-width: $input-focus-width !default;\n$form-select-focus-box-shadow: 0 0 0 $form-select-focus-width $input-btn-focus-color !default;\n\n$form-select-padding-y-sm: $input-padding-y-sm !default;\n$form-select-padding-x-sm: $input-padding-x-sm !default;\n$form-select-font-size-sm: $input-font-size-sm !default;\n$form-select-border-radius-sm: $input-border-radius-sm !default;\n\n$form-select-padding-y-lg: $input-padding-y-lg !default;\n$form-select-padding-x-lg: $input-padding-x-lg !default;\n$form-select-font-size-lg: $input-font-size-lg !default;\n$form-select-border-radius-lg: $input-border-radius-lg !default;\n\n$form-select-transition: $input-transition !default;\n// scss-docs-end form-select-variables\n\n// scss-docs-start form-range-variables\n$form-range-track-width: 100% !default;\n$form-range-track-height: .5rem !default;\n$form-range-track-cursor: pointer !default;\n$form-range-track-bg: var(--#{$prefix}secondary-bg) !default;\n$form-range-track-border-radius: 1rem !default;\n$form-range-track-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-range-thumb-width: 1rem !default;\n$form-range-thumb-height: $form-range-thumb-width !default;\n$form-range-thumb-bg: $component-active-bg !default;\n$form-range-thumb-border: 0 !default;\n$form-range-thumb-border-radius: 1rem !default;\n$form-range-thumb-box-shadow: 0 .1rem .25rem rgba($black, .1) !default;\n$form-range-thumb-focus-box-shadow: 0 0 0 1px $body-bg, $input-focus-box-shadow !default;\n$form-range-thumb-focus-box-shadow-width: $input-focus-width !default; // For focus box shadow issue in Edge\n$form-range-thumb-active-bg: tint-color($component-active-bg, 70%) !default;\n$form-range-thumb-disabled-bg: var(--#{$prefix}secondary-color) !default;\n$form-range-thumb-transition: background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n// scss-docs-end form-range-variables\n\n// scss-docs-start form-file-variables\n$form-file-button-color: $input-color !default;\n$form-file-button-bg: var(--#{$prefix}tertiary-bg) !default;\n$form-file-button-hover-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end form-file-variables\n\n// scss-docs-start form-floating-variables\n$form-floating-height: add(3.5rem, $input-height-border) !default;\n$form-floating-line-height: 1.25 !default;\n$form-floating-padding-x: $input-padding-x !default;\n$form-floating-padding-y: 1rem !default;\n$form-floating-input-padding-t: 1.625rem !default;\n$form-floating-input-padding-b: .625rem !default;\n$form-floating-label-height: 1.5em !default;\n$form-floating-label-opacity: .65 !default;\n$form-floating-label-transform: scale(.85) translateY(-.5rem) translateX(.15rem) !default;\n$form-floating-label-disabled-color: $gray-600 !default;\n$form-floating-transition: opacity .1s ease-in-out, transform .1s ease-in-out !default;\n// scss-docs-end form-floating-variables\n\n// Form validation\n\n// scss-docs-start form-feedback-variables\n$form-feedback-margin-top: $form-text-margin-top !default;\n$form-feedback-font-size: $form-text-font-size !default;\n$form-feedback-font-style: $form-text-font-style !default;\n$form-feedback-valid-color: $success !default;\n$form-feedback-invalid-color: $danger !default;\n\n$form-feedback-icon-valid-color: $form-feedback-valid-color !default;\n$form-feedback-icon-valid: url(\"data:image/svg+xml,\") !default;\n$form-feedback-icon-invalid-color: $form-feedback-invalid-color !default;\n$form-feedback-icon-invalid: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end form-feedback-variables\n\n// scss-docs-start form-validation-colors\n$form-valid-color: $form-feedback-valid-color !default;\n$form-valid-border-color: $form-feedback-valid-color !default;\n$form-invalid-color: $form-feedback-invalid-color !default;\n$form-invalid-border-color: $form-feedback-invalid-color !default;\n// scss-docs-end form-validation-colors\n\n// scss-docs-start form-validation-states\n$form-validation-states: (\n \"valid\": (\n \"color\": var(--#{$prefix}form-valid-color),\n \"icon\": $form-feedback-icon-valid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}success),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-valid-border-color),\n ),\n \"invalid\": (\n \"color\": var(--#{$prefix}form-invalid-color),\n \"icon\": $form-feedback-icon-invalid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}danger),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-invalid-border-color),\n )\n) !default;\n// scss-docs-end form-validation-states\n\n// Z-index master list\n//\n// Warning: Avoid customizing these values. They're used for a bird's eye view\n// of components dependent on the z-axis and are designed to all work together.\n\n// scss-docs-start zindex-stack\n$zindex-dropdown: 1000 !default;\n$zindex-sticky: 1020 !default;\n$zindex-fixed: 1030 !default;\n$zindex-offcanvas-backdrop: 1040 !default;\n$zindex-offcanvas: 1045 !default;\n$zindex-modal-backdrop: 1050 !default;\n$zindex-modal: 1055 !default;\n$zindex-popover: 1070 !default;\n$zindex-tooltip: 1080 !default;\n$zindex-toast: 1090 !default;\n// scss-docs-end zindex-stack\n\n// scss-docs-start zindex-levels-map\n$zindex-levels: (\n n1: -1,\n 0: 0,\n 1: 1,\n 2: 2,\n 3: 3\n) !default;\n// scss-docs-end zindex-levels-map\n\n\n// Navs\n\n// scss-docs-start nav-variables\n$nav-link-padding-y: .5rem !default;\n$nav-link-padding-x: 1rem !default;\n$nav-link-font-size: null !default;\n$nav-link-font-weight: null !default;\n$nav-link-color: var(--#{$prefix}link-color) !default;\n$nav-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$nav-link-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out !default;\n$nav-link-disabled-color: var(--#{$prefix}secondary-color) !default;\n$nav-link-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$nav-tabs-border-color: var(--#{$prefix}border-color) !default;\n$nav-tabs-border-width: var(--#{$prefix}border-width) !default;\n$nav-tabs-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-tabs-link-hover-border-color: var(--#{$prefix}secondary-bg) var(--#{$prefix}secondary-bg) $nav-tabs-border-color !default;\n$nav-tabs-link-active-color: var(--#{$prefix}emphasis-color) !default;\n$nav-tabs-link-active-bg: var(--#{$prefix}body-bg) !default;\n$nav-tabs-link-active-border-color: var(--#{$prefix}border-color) var(--#{$prefix}border-color) $nav-tabs-link-active-bg !default;\n\n$nav-pills-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-pills-link-active-color: $component-active-color !default;\n$nav-pills-link-active-bg: $component-active-bg !default;\n\n$nav-underline-gap: 1rem !default;\n$nav-underline-border-width: .125rem !default;\n$nav-underline-link-active-color: var(--#{$prefix}emphasis-color) !default;\n// scss-docs-end nav-variables\n\n\n// Navbar\n\n// scss-docs-start navbar-variables\n$navbar-padding-y: $spacer * .5 !default;\n$navbar-padding-x: null !default;\n\n$navbar-nav-link-padding-x: .5rem !default;\n\n$navbar-brand-font-size: $font-size-lg !default;\n// Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link\n$nav-link-height: $font-size-base * $line-height-base + $nav-link-padding-y * 2 !default;\n$navbar-brand-height: $navbar-brand-font-size * $line-height-base !default;\n$navbar-brand-padding-y: ($nav-link-height - $navbar-brand-height) * .5 !default;\n$navbar-brand-margin-end: 1rem !default;\n\n$navbar-toggler-padding-y: .25rem !default;\n$navbar-toggler-padding-x: .75rem !default;\n$navbar-toggler-font-size: $font-size-lg !default;\n$navbar-toggler-border-radius: $btn-border-radius !default;\n$navbar-toggler-focus-width: $btn-focus-width !default;\n$navbar-toggler-transition: box-shadow .15s ease-in-out !default;\n\n$navbar-light-color: rgba(var(--#{$prefix}emphasis-color-rgb), .65) !default;\n$navbar-light-hover-color: rgba(var(--#{$prefix}emphasis-color-rgb), .8) !default;\n$navbar-light-active-color: rgba(var(--#{$prefix}emphasis-color-rgb), 1) !default;\n$navbar-light-disabled-color: rgba(var(--#{$prefix}emphasis-color-rgb), .3) !default;\n$navbar-light-icon-color: rgba($body-color, .75) !default;\n$navbar-light-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-light-toggler-border-color: rgba(var(--#{$prefix}emphasis-color-rgb), .15) !default;\n$navbar-light-brand-color: $navbar-light-active-color !default;\n$navbar-light-brand-hover-color: $navbar-light-active-color !default;\n// scss-docs-end navbar-variables\n\n// scss-docs-start navbar-dark-variables\n$navbar-dark-color: rgba($white, .55) !default;\n$navbar-dark-hover-color: rgba($white, .75) !default;\n$navbar-dark-active-color: $white !default;\n$navbar-dark-disabled-color: rgba($white, .25) !default;\n$navbar-dark-icon-color: $navbar-dark-color !default;\n$navbar-dark-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-dark-toggler-border-color: rgba($white, .1) !default;\n$navbar-dark-brand-color: $navbar-dark-active-color !default;\n$navbar-dark-brand-hover-color: $navbar-dark-active-color !default;\n// scss-docs-end navbar-dark-variables\n\n\n// Dropdowns\n//\n// Dropdown menu container and contents.\n\n// scss-docs-start dropdown-variables\n$dropdown-min-width: 10rem !default;\n$dropdown-padding-x: 0 !default;\n$dropdown-padding-y: .5rem !default;\n$dropdown-spacer: .125rem !default;\n$dropdown-font-size: $font-size-base !default;\n$dropdown-color: var(--#{$prefix}body-color) !default;\n$dropdown-bg: var(--#{$prefix}body-bg) !default;\n$dropdown-border-color: var(--#{$prefix}border-color-translucent) !default;\n$dropdown-border-radius: var(--#{$prefix}border-radius) !default;\n$dropdown-border-width: var(--#{$prefix}border-width) !default;\n$dropdown-inner-border-radius: calc(#{$dropdown-border-radius} - #{$dropdown-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$dropdown-divider-bg: $dropdown-border-color !default;\n$dropdown-divider-margin-y: $spacer * .5 !default;\n$dropdown-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$dropdown-link-color: var(--#{$prefix}body-color) !default;\n$dropdown-link-hover-color: $dropdown-link-color !default;\n$dropdown-link-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n\n$dropdown-link-active-color: $component-active-color !default;\n$dropdown-link-active-bg: $component-active-bg !default;\n\n$dropdown-link-disabled-color: var(--#{$prefix}tertiary-color) !default;\n\n$dropdown-item-padding-y: $spacer * .25 !default;\n$dropdown-item-padding-x: $spacer !default;\n\n$dropdown-header-color: $gray-600 !default;\n$dropdown-header-padding-x: $dropdown-item-padding-x !default;\n$dropdown-header-padding-y: $dropdown-padding-y !default;\n// fusv-disable\n$dropdown-header-padding: $dropdown-header-padding-y $dropdown-header-padding-x !default; // Deprecated in v5.2.0\n// fusv-enable\n// scss-docs-end dropdown-variables\n\n// scss-docs-start dropdown-dark-variables\n$dropdown-dark-color: $gray-300 !default;\n$dropdown-dark-bg: $gray-800 !default;\n$dropdown-dark-border-color: $dropdown-border-color !default;\n$dropdown-dark-divider-bg: $dropdown-divider-bg !default;\n$dropdown-dark-box-shadow: null !default;\n$dropdown-dark-link-color: $dropdown-dark-color !default;\n$dropdown-dark-link-hover-color: $white !default;\n$dropdown-dark-link-hover-bg: rgba($white, .15) !default;\n$dropdown-dark-link-active-color: $dropdown-link-active-color !default;\n$dropdown-dark-link-active-bg: $dropdown-link-active-bg !default;\n$dropdown-dark-link-disabled-color: $gray-500 !default;\n$dropdown-dark-header-color: $gray-500 !default;\n// scss-docs-end dropdown-dark-variables\n\n\n// Pagination\n\n// scss-docs-start pagination-variables\n$pagination-padding-y: .375rem !default;\n$pagination-padding-x: .75rem !default;\n$pagination-padding-y-sm: .25rem !default;\n$pagination-padding-x-sm: .5rem !default;\n$pagination-padding-y-lg: .75rem !default;\n$pagination-padding-x-lg: 1.5rem !default;\n\n$pagination-font-size: $font-size-base !default;\n\n$pagination-color: var(--#{$prefix}link-color) !default;\n$pagination-bg: var(--#{$prefix}body-bg) !default;\n$pagination-border-radius: var(--#{$prefix}border-radius) !default;\n$pagination-border-width: var(--#{$prefix}border-width) !default;\n$pagination-margin-start: calc(#{$pagination-border-width} * -1) !default; // stylelint-disable-line function-disallowed-list\n$pagination-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-focus-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-focus-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-focus-box-shadow: $focus-ring-box-shadow !default;\n$pagination-focus-outline: 0 !default;\n\n$pagination-hover-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$pagination-hover-border-color: var(--#{$prefix}border-color) !default; // Todo in v6: remove this?\n\n$pagination-active-color: $component-active-color !default;\n$pagination-active-bg: $component-active-bg !default;\n$pagination-active-border-color: $component-active-bg !default;\n\n$pagination-disabled-color: var(--#{$prefix}secondary-color) !default;\n$pagination-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-disabled-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$pagination-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$pagination-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n// scss-docs-end pagination-variables\n\n\n// Placeholders\n\n// scss-docs-start placeholders\n$placeholder-opacity-max: .5 !default;\n$placeholder-opacity-min: .2 !default;\n// scss-docs-end placeholders\n\n// Cards\n\n// scss-docs-start card-variables\n$card-spacer-y: $spacer !default;\n$card-spacer-x: $spacer !default;\n$card-title-spacer-y: $spacer * .5 !default;\n$card-title-color: null !default;\n$card-subtitle-color: null !default;\n$card-border-width: var(--#{$prefix}border-width) !default;\n$card-border-color: var(--#{$prefix}border-color-translucent) !default;\n$card-border-radius: var(--#{$prefix}border-radius) !default;\n$card-box-shadow: null !default;\n$card-inner-border-radius: subtract($card-border-radius, $card-border-width) !default;\n$card-cap-padding-y: $card-spacer-y * .5 !default;\n$card-cap-padding-x: $card-spacer-x !default;\n$card-cap-bg: rgba(var(--#{$prefix}body-color-rgb), .03) !default;\n$card-cap-color: null !default;\n$card-height: null !default;\n$card-color: null !default;\n$card-bg: var(--#{$prefix}body-bg) !default;\n$card-img-overlay-padding: $spacer !default;\n$card-group-margin: $grid-gutter-width * .5 !default;\n// scss-docs-end card-variables\n\n// Accordion\n\n// scss-docs-start accordion-variables\n$accordion-padding-y: 1rem !default;\n$accordion-padding-x: 1.25rem !default;\n$accordion-color: var(--#{$prefix}body-color) !default;\n$accordion-bg: var(--#{$prefix}body-bg) !default;\n$accordion-border-width: var(--#{$prefix}border-width) !default;\n$accordion-border-color: var(--#{$prefix}border-color) !default;\n$accordion-border-radius: var(--#{$prefix}border-radius) !default;\n$accordion-inner-border-radius: subtract($accordion-border-radius, $accordion-border-width) !default;\n\n$accordion-body-padding-y: $accordion-padding-y !default;\n$accordion-body-padding-x: $accordion-padding-x !default;\n\n$accordion-button-padding-y: $accordion-padding-y !default;\n$accordion-button-padding-x: $accordion-padding-x !default;\n$accordion-button-color: var(--#{$prefix}body-color) !default;\n$accordion-button-bg: var(--#{$prefix}accordion-bg) !default;\n$accordion-transition: $btn-transition, border-radius .15s ease !default;\n$accordion-button-active-bg: var(--#{$prefix}primary-bg-subtle) !default;\n$accordion-button-active-color: var(--#{$prefix}primary-text-emphasis) !default;\n\n// fusv-disable\n$accordion-button-focus-border-color: $input-focus-border-color !default; // Deprecated in v5.3.3\n// fusv-enable\n$accordion-button-focus-box-shadow: $btn-focus-box-shadow !default;\n\n$accordion-icon-width: 1.25rem !default;\n$accordion-icon-color: $body-color !default;\n$accordion-icon-active-color: $primary-text-emphasis !default;\n$accordion-icon-transition: transform .2s ease-in-out !default;\n$accordion-icon-transform: rotate(-180deg) !default;\n\n$accordion-button-icon: url(\"data:image/svg+xml,\") !default;\n$accordion-button-active-icon: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end accordion-variables\n\n// Tooltips\n\n// scss-docs-start tooltip-variables\n$tooltip-font-size: $font-size-sm !default;\n$tooltip-max-width: 200px !default;\n$tooltip-color: var(--#{$prefix}body-bg) !default;\n$tooltip-bg: var(--#{$prefix}emphasis-color) !default;\n$tooltip-border-radius: var(--#{$prefix}border-radius) !default;\n$tooltip-opacity: .9 !default;\n$tooltip-padding-y: $spacer * .25 !default;\n$tooltip-padding-x: $spacer * .5 !default;\n$tooltip-margin: null !default; // TODO: remove this in v6\n\n$tooltip-arrow-width: .8rem !default;\n$tooltip-arrow-height: .4rem !default;\n// fusv-disable\n$tooltip-arrow-color: null !default; // Deprecated in Bootstrap 5.2.0 for CSS variables\n// fusv-enable\n// scss-docs-end tooltip-variables\n\n// Form tooltips must come after regular tooltips\n// scss-docs-start tooltip-feedback-variables\n$form-feedback-tooltip-padding-y: $tooltip-padding-y !default;\n$form-feedback-tooltip-padding-x: $tooltip-padding-x !default;\n$form-feedback-tooltip-font-size: $tooltip-font-size !default;\n$form-feedback-tooltip-line-height: null !default;\n$form-feedback-tooltip-opacity: $tooltip-opacity !default;\n$form-feedback-tooltip-border-radius: $tooltip-border-radius !default;\n// scss-docs-end tooltip-feedback-variables\n\n\n// Popovers\n\n// scss-docs-start popover-variables\n$popover-font-size: $font-size-sm !default;\n$popover-bg: var(--#{$prefix}body-bg) !default;\n$popover-max-width: 276px !default;\n$popover-border-width: var(--#{$prefix}border-width) !default;\n$popover-border-color: var(--#{$prefix}border-color-translucent) !default;\n$popover-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$popover-inner-border-radius: calc(#{$popover-border-radius} - #{$popover-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$popover-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$popover-header-font-size: $font-size-base !default;\n$popover-header-bg: var(--#{$prefix}secondary-bg) !default;\n$popover-header-color: $headings-color !default;\n$popover-header-padding-y: .5rem !default;\n$popover-header-padding-x: $spacer !default;\n\n$popover-body-color: var(--#{$prefix}body-color) !default;\n$popover-body-padding-y: $spacer !default;\n$popover-body-padding-x: $spacer !default;\n\n$popover-arrow-width: 1rem !default;\n$popover-arrow-height: .5rem !default;\n// scss-docs-end popover-variables\n\n// fusv-disable\n// Deprecated in Bootstrap 5.2.0 for CSS variables\n$popover-arrow-color: $popover-bg !default;\n$popover-arrow-outer-color: var(--#{$prefix}border-color-translucent) !default;\n// fusv-enable\n\n\n// Toasts\n\n// scss-docs-start toast-variables\n$toast-max-width: 350px !default;\n$toast-padding-x: .75rem !default;\n$toast-padding-y: .5rem !default;\n$toast-font-size: .875rem !default;\n$toast-color: null !default;\n$toast-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-border-width: var(--#{$prefix}border-width) !default;\n$toast-border-color: var(--#{$prefix}border-color-translucent) !default;\n$toast-border-radius: var(--#{$prefix}border-radius) !default;\n$toast-box-shadow: var(--#{$prefix}box-shadow) !default;\n$toast-spacing: $container-padding-x !default;\n\n$toast-header-color: var(--#{$prefix}secondary-color) !default;\n$toast-header-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-header-border-color: $toast-border-color !default;\n// scss-docs-end toast-variables\n\n\n// Badges\n\n// scss-docs-start badge-variables\n$badge-font-size: .75em !default;\n$badge-font-weight: $font-weight-bold !default;\n$badge-color: $white !default;\n$badge-padding-y: .35em !default;\n$badge-padding-x: .65em !default;\n$badge-border-radius: var(--#{$prefix}border-radius) !default;\n// scss-docs-end badge-variables\n\n\n// Modals\n\n// scss-docs-start modal-variables\n$modal-inner-padding: $spacer !default;\n\n$modal-footer-margin-between: .5rem !default;\n\n$modal-dialog-margin: .5rem !default;\n$modal-dialog-margin-y-sm-up: 1.75rem !default;\n\n$modal-title-line-height: $line-height-base !default;\n\n$modal-content-color: null !default;\n$modal-content-bg: var(--#{$prefix}body-bg) !default;\n$modal-content-border-color: var(--#{$prefix}border-color-translucent) !default;\n$modal-content-border-width: var(--#{$prefix}border-width) !default;\n$modal-content-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$modal-content-inner-border-radius: subtract($modal-content-border-radius, $modal-content-border-width) !default;\n$modal-content-box-shadow-xs: var(--#{$prefix}box-shadow-sm) !default;\n$modal-content-box-shadow-sm-up: var(--#{$prefix}box-shadow) !default;\n\n$modal-backdrop-bg: $black !default;\n$modal-backdrop-opacity: .5 !default;\n\n$modal-header-border-color: var(--#{$prefix}border-color) !default;\n$modal-header-border-width: $modal-content-border-width !default;\n$modal-header-padding-y: $modal-inner-padding !default;\n$modal-header-padding-x: $modal-inner-padding !default;\n$modal-header-padding: $modal-header-padding-y $modal-header-padding-x !default; // Keep this for backwards compatibility\n\n$modal-footer-bg: null !default;\n$modal-footer-border-color: $modal-header-border-color !default;\n$modal-footer-border-width: $modal-header-border-width !default;\n\n$modal-sm: 300px !default;\n$modal-md: 500px !default;\n$modal-lg: 800px !default;\n$modal-xl: 1140px !default;\n\n$modal-fade-transform: translate(0, -50px) !default;\n$modal-show-transform: none !default;\n$modal-transition: transform .3s ease-out !default;\n$modal-scale-transform: scale(1.02) !default;\n// scss-docs-end modal-variables\n\n\n// Alerts\n//\n// Define alert colors, border radius, and padding.\n\n// scss-docs-start alert-variables\n$alert-padding-y: $spacer !default;\n$alert-padding-x: $spacer !default;\n$alert-margin-bottom: 1rem !default;\n$alert-border-radius: var(--#{$prefix}border-radius) !default;\n$alert-link-font-weight: $font-weight-bold !default;\n$alert-border-width: var(--#{$prefix}border-width) !default;\n$alert-dismissible-padding-r: $alert-padding-x * 3 !default; // 3x covers width of x plus default padding on either side\n// scss-docs-end alert-variables\n\n// fusv-disable\n$alert-bg-scale: -80% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-border-scale: -70% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-color-scale: 40% !default; // Deprecated in v5.2.0, to be removed in v6\n// fusv-enable\n\n// Progress bars\n\n// scss-docs-start progress-variables\n$progress-height: 1rem !default;\n$progress-font-size: $font-size-base * .75 !default;\n$progress-bg: var(--#{$prefix}secondary-bg) !default;\n$progress-border-radius: var(--#{$prefix}border-radius) !default;\n$progress-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n$progress-bar-color: $white !default;\n$progress-bar-bg: $primary !default;\n$progress-bar-animation-timing: 1s linear infinite !default;\n$progress-bar-transition: width .6s ease !default;\n// scss-docs-end progress-variables\n\n\n// List group\n\n// scss-docs-start list-group-variables\n$list-group-color: var(--#{$prefix}body-color) !default;\n$list-group-bg: var(--#{$prefix}body-bg) !default;\n$list-group-border-color: var(--#{$prefix}border-color) !default;\n$list-group-border-width: var(--#{$prefix}border-width) !default;\n$list-group-border-radius: var(--#{$prefix}border-radius) !default;\n\n$list-group-item-padding-y: $spacer * .5 !default;\n$list-group-item-padding-x: $spacer !default;\n// fusv-disable\n$list-group-item-bg-scale: -80% !default; // Deprecated in v5.3.0\n$list-group-item-color-scale: 40% !default; // Deprecated in v5.3.0\n// fusv-enable\n\n$list-group-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$list-group-active-color: $component-active-color !default;\n$list-group-active-bg: $component-active-bg !default;\n$list-group-active-border-color: $list-group-active-bg !default;\n\n$list-group-disabled-color: var(--#{$prefix}secondary-color) !default;\n$list-group-disabled-bg: $list-group-bg !default;\n\n$list-group-action-color: var(--#{$prefix}secondary-color) !default;\n$list-group-action-hover-color: var(--#{$prefix}emphasis-color) !default;\n\n$list-group-action-active-color: var(--#{$prefix}body-color) !default;\n$list-group-action-active-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end list-group-variables\n\n\n// Image thumbnails\n\n// scss-docs-start thumbnail-variables\n$thumbnail-padding: .25rem !default;\n$thumbnail-bg: var(--#{$prefix}body-bg) !default;\n$thumbnail-border-width: var(--#{$prefix}border-width) !default;\n$thumbnail-border-color: var(--#{$prefix}border-color) !default;\n$thumbnail-border-radius: var(--#{$prefix}border-radius) !default;\n$thumbnail-box-shadow: var(--#{$prefix}box-shadow-sm) !default;\n// scss-docs-end thumbnail-variables\n\n\n// Figures\n\n// scss-docs-start figure-variables\n$figure-caption-font-size: $small-font-size !default;\n$figure-caption-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end figure-variables\n\n\n// Breadcrumbs\n\n// scss-docs-start breadcrumb-variables\n$breadcrumb-font-size: null !default;\n$breadcrumb-padding-y: 0 !default;\n$breadcrumb-padding-x: 0 !default;\n$breadcrumb-item-padding-x: .5rem !default;\n$breadcrumb-margin-bottom: 1rem !default;\n$breadcrumb-bg: null !default;\n$breadcrumb-divider-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-active-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-divider: quote(\"/\") !default;\n$breadcrumb-divider-flipped: $breadcrumb-divider !default;\n$breadcrumb-border-radius: null !default;\n// scss-docs-end breadcrumb-variables\n\n// Carousel\n\n// scss-docs-start carousel-variables\n$carousel-control-color: $white !default;\n$carousel-control-width: 15% !default;\n$carousel-control-opacity: .5 !default;\n$carousel-control-hover-opacity: .9 !default;\n$carousel-control-transition: opacity .15s ease !default;\n\n$carousel-indicator-width: 30px !default;\n$carousel-indicator-height: 3px !default;\n$carousel-indicator-hit-area-height: 10px !default;\n$carousel-indicator-spacer: 3px !default;\n$carousel-indicator-opacity: .5 !default;\n$carousel-indicator-active-bg: $white !default;\n$carousel-indicator-active-opacity: 1 !default;\n$carousel-indicator-transition: opacity .6s ease !default;\n\n$carousel-caption-width: 70% !default;\n$carousel-caption-color: $white !default;\n$carousel-caption-padding-y: 1.25rem !default;\n$carousel-caption-spacer: 1.25rem !default;\n\n$carousel-control-icon-width: 2rem !default;\n\n$carousel-control-prev-icon-bg: url(\"data:image/svg+xml,\") !default;\n$carousel-control-next-icon-bg: url(\"data:image/svg+xml,\") !default;\n\n$carousel-transition-duration: .6s !default;\n$carousel-transition: transform $carousel-transition-duration ease-in-out !default; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`)\n// scss-docs-end carousel-variables\n\n// scss-docs-start carousel-dark-variables\n$carousel-dark-indicator-active-bg: $black !default;\n$carousel-dark-caption-color: $black !default;\n$carousel-dark-control-icon-filter: invert(1) grayscale(100) !default;\n// scss-docs-end carousel-dark-variables\n\n\n// Spinners\n\n// scss-docs-start spinner-variables\n$spinner-width: 2rem !default;\n$spinner-height: $spinner-width !default;\n$spinner-vertical-align: -.125em !default;\n$spinner-border-width: .25em !default;\n$spinner-animation-speed: .75s !default;\n\n$spinner-width-sm: 1rem !default;\n$spinner-height-sm: $spinner-width-sm !default;\n$spinner-border-width-sm: .2em !default;\n// scss-docs-end spinner-variables\n\n\n// Close\n\n// scss-docs-start close-variables\n$btn-close-width: 1em !default;\n$btn-close-height: $btn-close-width !default;\n$btn-close-padding-x: .25em !default;\n$btn-close-padding-y: $btn-close-padding-x !default;\n$btn-close-color: $black !default;\n$btn-close-bg: url(\"data:image/svg+xml,\") !default;\n$btn-close-focus-shadow: $focus-ring-box-shadow !default;\n$btn-close-opacity: .5 !default;\n$btn-close-hover-opacity: .75 !default;\n$btn-close-focus-opacity: 1 !default;\n$btn-close-disabled-opacity: .25 !default;\n$btn-close-white-filter: invert(1) grayscale(100%) brightness(200%) !default;\n// scss-docs-end close-variables\n\n\n// Offcanvas\n\n// scss-docs-start offcanvas-variables\n$offcanvas-padding-y: $modal-inner-padding !default;\n$offcanvas-padding-x: $modal-inner-padding !default;\n$offcanvas-horizontal-width: 400px !default;\n$offcanvas-vertical-height: 30vh !default;\n$offcanvas-transition-duration: .3s !default;\n$offcanvas-border-color: $modal-content-border-color !default;\n$offcanvas-border-width: $modal-content-border-width !default;\n$offcanvas-title-line-height: $modal-title-line-height !default;\n$offcanvas-bg-color: var(--#{$prefix}body-bg) !default;\n$offcanvas-color: var(--#{$prefix}body-color) !default;\n$offcanvas-box-shadow: $modal-content-box-shadow-xs !default;\n$offcanvas-backdrop-bg: $modal-backdrop-bg !default;\n$offcanvas-backdrop-opacity: $modal-backdrop-opacity !default;\n// scss-docs-end offcanvas-variables\n\n// Code\n\n$code-font-size: $small-font-size !default;\n$code-color: $pink !default;\n\n$kbd-padding-y: .1875rem !default;\n$kbd-padding-x: .375rem !default;\n$kbd-font-size: $code-font-size !default;\n$kbd-color: var(--#{$prefix}body-bg) !default;\n$kbd-bg: var(--#{$prefix}body-color) !default;\n$nested-kbd-font-weight: null !default; // Deprecated in v5.2.0, removing in v6\n\n$pre-color: null !default;\n\n@import \"variables-dark\"; // TODO: can be removed safely in v6, only here to avoid breaking changes in v5.3\n","// stylelint-disable property-disallowed-list\n// Single side border-radius\n\n// Helper function to replace negative values with 0\n@function valid-radius($radius) {\n $return: ();\n @each $value in $radius {\n @if type-of($value) == number {\n $return: append($return, max($value, 0));\n } @else {\n $return: append($return, $value);\n }\n }\n @return $return;\n}\n\n// scss-docs-start border-radius-mixins\n@mixin border-radius($radius: $border-radius, $fallback-border-radius: false) {\n @if $enable-rounded {\n border-radius: valid-radius($radius);\n }\n @else if $fallback-border-radius != false {\n border-radius: $fallback-border-radius;\n }\n}\n\n@mixin border-top-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n border-top-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-right-radius: valid-radius($radius);\n border-bottom-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-right-radius: valid-radius($radius);\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-top-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-top-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n// scss-docs-end border-radius-mixins\n","//\n// Headings\n//\n.h1 {\n @extend h1;\n}\n\n.h2 {\n @extend h2;\n}\n\n.h3 {\n @extend h3;\n}\n\n.h4 {\n @extend h4;\n}\n\n.h5 {\n @extend h5;\n}\n\n.h6 {\n @extend h6;\n}\n\n\n.lead {\n @include font-size($lead-font-size);\n font-weight: $lead-font-weight;\n}\n\n// Type display classes\n@each $display, $font-size in $display-font-sizes {\n .display-#{$display} {\n @include font-size($font-size);\n font-family: $display-font-family;\n font-style: $display-font-style;\n font-weight: $display-font-weight;\n line-height: $display-line-height;\n }\n}\n\n//\n// Emphasis\n//\n.small {\n @extend small;\n}\n\n.mark {\n @extend mark;\n}\n\n//\n// Lists\n//\n\n.list-unstyled {\n @include list-unstyled();\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n @include list-unstyled();\n}\n.list-inline-item {\n display: inline-block;\n\n &:not(:last-child) {\n margin-right: $list-inline-padding;\n }\n}\n\n\n//\n// Misc\n//\n\n// Builds on `abbr`\n.initialism {\n @include font-size($initialism-font-size);\n text-transform: uppercase;\n}\n\n// Blockquotes\n.blockquote {\n margin-bottom: $blockquote-margin-y;\n @include font-size($blockquote-font-size);\n\n > :last-child {\n margin-bottom: 0;\n }\n}\n\n.blockquote-footer {\n margin-top: -$blockquote-margin-y;\n margin-bottom: $blockquote-margin-y;\n @include font-size($blockquote-footer-font-size);\n color: $blockquote-footer-color;\n\n &::before {\n content: \"\\2014\\00A0\"; // em dash, nbsp\n }\n}\n","// Lists\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n@mixin list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n","// Responsive images (ensure images don't scale beyond their parents)\n//\n// This is purposefully opt-in via an explicit class rather than being the default for all ``s.\n// We previously tried the \"images are responsive by default\" approach in Bootstrap v2,\n// and abandoned it in Bootstrap v3 because it breaks lots of third-party widgets (including Google Maps)\n// which weren't expecting the images within themselves to be involuntarily resized.\n// See also https://github.com/twbs/bootstrap/issues/18178\n.img-fluid {\n @include img-fluid();\n}\n\n\n// Image thumbnails\n.img-thumbnail {\n padding: $thumbnail-padding;\n background-color: $thumbnail-bg;\n border: $thumbnail-border-width solid $thumbnail-border-color;\n @include border-radius($thumbnail-border-radius);\n @include box-shadow($thumbnail-box-shadow);\n\n // Keep them at most 100% wide\n @include img-fluid();\n}\n\n//\n// Figures\n//\n\n.figure {\n // Ensures the caption's text aligns with the image.\n display: inline-block;\n}\n\n.figure-img {\n margin-bottom: $spacer * .5;\n line-height: 1;\n}\n\n.figure-caption {\n @include font-size($figure-caption-font-size);\n color: $figure-caption-color;\n}\n","// Image Mixins\n// - Responsive image\n// - Retina image\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n\n@mixin img-fluid {\n // Part 1: Set a maximum relative to the parent\n max-width: 100%;\n // Part 2: Override the height to auto, otherwise images will be stretched\n // when setting a width and height attribute on the img element.\n height: auto;\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-container-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-right: auto;\n margin-left: auto;\n}\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Row\n//\n// Rows contain your columns.\n\n:root {\n @each $name, $value in $grid-breakpoints {\n --#{$prefix}breakpoint-#{$name}: #{$value};\n }\n}\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed\n margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list\n margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready() {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// number of columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: percentage(divide(1, $count));\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is an invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","//\n// Basic Bootstrap table\n//\n\n.table {\n // Reset needed for nesting tables\n --#{$prefix}table-color-type: initial;\n --#{$prefix}table-bg-type: initial;\n --#{$prefix}table-color-state: initial;\n --#{$prefix}table-bg-state: initial;\n // End of reset\n --#{$prefix}table-color: #{$table-color};\n --#{$prefix}table-bg: #{$table-bg};\n --#{$prefix}table-border-color: #{$table-border-color};\n --#{$prefix}table-accent-bg: #{$table-accent-bg};\n --#{$prefix}table-striped-color: #{$table-striped-color};\n --#{$prefix}table-striped-bg: #{$table-striped-bg};\n --#{$prefix}table-active-color: #{$table-active-color};\n --#{$prefix}table-active-bg: #{$table-active-bg};\n --#{$prefix}table-hover-color: #{$table-hover-color};\n --#{$prefix}table-hover-bg: #{$table-hover-bg};\n\n width: 100%;\n margin-bottom: $spacer;\n vertical-align: $table-cell-vertical-align;\n border-color: var(--#{$prefix}table-border-color);\n\n // Target th & td\n // We need the child combinator to prevent styles leaking to nested tables which doesn't have a `.table` class.\n // We use the universal selectors here to simplify the selector (else we would need 6 different selectors).\n // Another advantage is that this generates less code and makes the selector less specific making it easier to override.\n // stylelint-disable-next-line selector-max-universal\n > :not(caption) > * > * {\n padding: $table-cell-padding-y $table-cell-padding-x;\n // Following the precept of cascades: https://codepen.io/miriamsuzanne/full/vYNgodb\n color: var(--#{$prefix}table-color-state, var(--#{$prefix}table-color-type, var(--#{$prefix}table-color)));\n background-color: var(--#{$prefix}table-bg);\n border-bottom-width: $table-border-width;\n box-shadow: inset 0 0 0 9999px var(--#{$prefix}table-bg-state, var(--#{$prefix}table-bg-type, var(--#{$prefix}table-accent-bg)));\n }\n\n > tbody {\n vertical-align: inherit;\n }\n\n > thead {\n vertical-align: bottom;\n }\n}\n\n.table-group-divider {\n border-top: calc(#{$table-border-width} * 2) solid $table-group-separator-color; // stylelint-disable-line function-disallowed-list\n}\n\n//\n// Change placement of captions with a class\n//\n\n.caption-top {\n caption-side: top;\n}\n\n\n//\n// Condensed table w/ half padding\n//\n\n.table-sm {\n // stylelint-disable-next-line selector-max-universal\n > :not(caption) > * > * {\n padding: $table-cell-padding-y-sm $table-cell-padding-x-sm;\n }\n}\n\n\n// Border versions\n//\n// Add or remove borders all around the table and between all the columns.\n//\n// When borders are added on all sides of the cells, the corners can render odd when\n// these borders do not have the same color or if they are semi-transparent.\n// Therefore we add top and border bottoms to the `tr`s and left and right borders\n// to the `td`s or `th`s\n\n.table-bordered {\n > :not(caption) > * {\n border-width: $table-border-width 0;\n\n // stylelint-disable-next-line selector-max-universal\n > * {\n border-width: 0 $table-border-width;\n }\n }\n}\n\n.table-borderless {\n // stylelint-disable-next-line selector-max-universal\n > :not(caption) > * > * {\n border-bottom-width: 0;\n }\n\n > :not(:first-child) {\n border-top-width: 0;\n }\n}\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n// For rows\n.table-striped {\n > tbody > tr:nth-of-type(#{$table-striped-order}) > * {\n --#{$prefix}table-color-type: var(--#{$prefix}table-striped-color);\n --#{$prefix}table-bg-type: var(--#{$prefix}table-striped-bg);\n }\n}\n\n// For columns\n.table-striped-columns {\n > :not(caption) > tr > :nth-child(#{$table-striped-columns-order}) {\n --#{$prefix}table-color-type: var(--#{$prefix}table-striped-color);\n --#{$prefix}table-bg-type: var(--#{$prefix}table-striped-bg);\n }\n}\n\n// Active table\n//\n// The `.table-active` class can be added to highlight rows or cells\n\n.table-active {\n --#{$prefix}table-color-state: var(--#{$prefix}table-active-color);\n --#{$prefix}table-bg-state: var(--#{$prefix}table-active-bg);\n}\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n > tbody > tr:hover > * {\n --#{$prefix}table-color-state: var(--#{$prefix}table-hover-color);\n --#{$prefix}table-bg-state: var(--#{$prefix}table-hover-bg);\n }\n}\n\n\n// Table variants\n//\n// Table variants set the table cell backgrounds, border colors\n// and the colors of the striped, hovered & active tables\n\n@each $color, $value in $table-variants {\n @include table-variant($color, $value);\n}\n\n// Responsive tables\n//\n// Generate series of `.table-responsive-*` classes for configuring the screen\n// size of where your table will overflow.\n\n@each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @include media-breakpoint-down($breakpoint) {\n .table-responsive#{$infix} {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n }\n}\n","// scss-docs-start table-variant\n@mixin table-variant($state, $background) {\n .table-#{$state} {\n $color: color-contrast(opaque($body-bg, $background));\n $hover-bg: mix($color, $background, percentage($table-hover-bg-factor));\n $striped-bg: mix($color, $background, percentage($table-striped-bg-factor));\n $active-bg: mix($color, $background, percentage($table-active-bg-factor));\n $table-border-color: mix($color, $background, percentage($table-border-factor));\n\n --#{$prefix}table-color: #{$color};\n --#{$prefix}table-bg: #{$background};\n --#{$prefix}table-border-color: #{$table-border-color};\n --#{$prefix}table-striped-bg: #{$striped-bg};\n --#{$prefix}table-striped-color: #{color-contrast($striped-bg)};\n --#{$prefix}table-active-bg: #{$active-bg};\n --#{$prefix}table-active-color: #{color-contrast($active-bg)};\n --#{$prefix}table-hover-bg: #{$hover-bg};\n --#{$prefix}table-hover-color: #{color-contrast($hover-bg)};\n\n color: var(--#{$prefix}table-color);\n border-color: var(--#{$prefix}table-border-color);\n }\n}\n// scss-docs-end table-variant\n","//\n// Labels\n//\n\n.form-label {\n margin-bottom: $form-label-margin-bottom;\n @include font-size($form-label-font-size);\n font-style: $form-label-font-style;\n font-weight: $form-label-font-weight;\n color: $form-label-color;\n}\n\n// For use with horizontal and inline forms, when you need the label (or legend)\n// text to align with the form controls.\n.col-form-label {\n padding-top: add($input-padding-y, $input-border-width);\n padding-bottom: add($input-padding-y, $input-border-width);\n margin-bottom: 0; // Override the `