[FW][MERGE] *: display sample data in empty views
This PR allows to display sample (fake) data in empty views, in the hope of easing user onboarding. This can be enabled (in list and kanban views) with attribute sample="1" on the arch root element. In this case, if there is no data to display, sample data will be generated based on heuristics (depending on field types and names). This can be used in addition to the no content helper. This PR also activates it in a bunch of list and kanban views. Task 2232801 closes odoo/odoo#53417 Forward-port-of: odoo/odoo#52867 Related: odoo/enterprise#11330 Signed-off-by: Christophe Monniez (moc) <moc@odoo.com> Signed-off-by: Aaron Bohy (aab) <aab@odoo.com> Co-authored-by: Aaron Bohy <aab@odoo.com> Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com> Co-authored-by: Jérémy Hennecart <jeh@odoo.com> Co-authored-by: Julien Mougenot <jum@odoo.com>
This commit is contained in:
co-authored by
Aaron Bohy
Mathieu Duckerts-Antoine
Jérémy Hennecart
Julien Mougenot
commit
5a99aacda5
@@ -136,7 +136,7 @@
|
||||
<field name="model">account.move.line</field>
|
||||
<field eval="100" name="priority"/>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Journal Items" create="false" edit="false">
|
||||
<tree string="Journal Items" create="false" edit="false" sample="1">
|
||||
<field name="date" optional="show"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="journal_id" options='{"no_open":True}'/>
|
||||
@@ -380,7 +380,7 @@
|
||||
<field name="name">account.move.tree</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Journal Entries">
|
||||
<tree string="Journal Entries" sample="1">
|
||||
<field name="date"/>
|
||||
<field name="name"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
@@ -400,7 +400,8 @@
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Invoices"
|
||||
banner_route="/account/account_invoice_onboarding"
|
||||
js_class="account_tree">
|
||||
js_class="account_tree"
|
||||
sample="1">
|
||||
<field name="name" decoration-bf="1"/>
|
||||
<field name="partner_id" invisible="1"/>
|
||||
<field name="invoice_source_email" invisible="1"/>
|
||||
@@ -432,7 +433,7 @@
|
||||
<field name="name">account.move.kanban</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="journal_id"/>
|
||||
<field name="ref"/>
|
||||
<field name="date"/>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<field name="name">account.payment.tree</field>
|
||||
<field name="model">account.payment</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree edit="false">
|
||||
<tree edit="false" sample="1">
|
||||
<field name="date"/>
|
||||
<field name="name"/>
|
||||
<field name="journal_id"/>
|
||||
@@ -38,7 +38,7 @@
|
||||
<field name="name">account.payment.kanban</field>
|
||||
<field name="model">account.payment</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile" create="0" group_create="0" >
|
||||
<kanban class="o_kanban_mobile" create="0" group_create="0" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="date"/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<field name="name">product.template.tree</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Products">
|
||||
<tree string="Products" sample="1">
|
||||
<field name="default_code"/>
|
||||
<field name="name"/>
|
||||
<field name="lst_price"/>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<field name="name">coupon.program.tree</field>
|
||||
<field name="model">coupon.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<tree sample="1">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="active"/>
|
||||
@@ -133,7 +133,7 @@
|
||||
<field name="name">coupon.program.kanban</field>
|
||||
<field name="model">coupon.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name" />
|
||||
<field name="active"/>
|
||||
<templates>
|
||||
|
||||
@@ -309,7 +309,7 @@
|
||||
<field name="model">crm.lead</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Leads">
|
||||
<tree string="Leads" sample="1">
|
||||
<field name="date_deadline" invisible="1"/>
|
||||
<field name="create_date" optional="hide"/>
|
||||
<field name="name" string="Lead"/>
|
||||
@@ -342,7 +342,7 @@
|
||||
<field name="model">crm.lead</field>
|
||||
<field name="priority" eval="100"/>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile" archivable="false">
|
||||
<kanban class="o_kanban_mobile" archivable="false" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="contact_name"/>
|
||||
<field name="priority"/>
|
||||
@@ -455,7 +455,7 @@
|
||||
<field name="priority" eval="1"/>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="stage_id" class="o_kanban_small_column o_opportunity_kanban" on_create="quick_create" quick_create_view="crm.quick_create_opportunity_form"
|
||||
archivable="false">
|
||||
archivable="false" sample="1">
|
||||
<field name="stage_id" options='{"group_by_tooltip": {"requirements": "Description"}}'/>
|
||||
<field name="color"/>
|
||||
<field name="priority"/>
|
||||
@@ -599,7 +599,7 @@
|
||||
<field name="model">crm.lead</field>
|
||||
<field name="priority">1</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Opportunities" >
|
||||
<tree string="Opportunities" sample="1">
|
||||
<field name="date_deadline" invisible="1"/>
|
||||
<field name="create_date" optional="hide"/>
|
||||
<field name="name" string="Opportunity"/>
|
||||
|
||||
@@ -285,7 +285,8 @@
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Events"
|
||||
decoration-danger="(seats_max and seats_max<seats_reserved)"
|
||||
multi_edit="1">
|
||||
multi_edit="1"
|
||||
sample="1">
|
||||
<field name="name"/>
|
||||
<field name="address_id" readonly="1"/>
|
||||
<field name="organizer_id" readonly="1" optional="hide"/>
|
||||
@@ -324,7 +325,7 @@
|
||||
<field name="name">event.event.kanban</field>
|
||||
<field name="model">event.event</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_event_kanban_view" default_group_by="stage_id" quick_create_view="event.event_event_view_form_quick_create">
|
||||
<kanban class="o_event_kanban_view" default_group_by="stage_id" quick_create_view="event.event_event_view_form_quick_create" sample="1">
|
||||
<field name="user_id"/>
|
||||
<field name="name"/>
|
||||
<field name="stage_id" options='{"group_by_tooltip": {"description": "Description"}}'/>
|
||||
@@ -498,7 +499,7 @@
|
||||
<field name="name">event.registration.tree</field>
|
||||
<field name="model">event.registration</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Registration" multi_edit="1">
|
||||
<tree string="Registration" multi_edit="1" sample="1">
|
||||
<field name="create_date" optional="hide"/>
|
||||
<field name="date_open" optional="hide"/>
|
||||
<field name="partner_id"/>
|
||||
@@ -575,7 +576,7 @@
|
||||
<field name="model">event.registration</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_event_attendee_kanban_view" default_order="name, create_date desc">
|
||||
<kanban class="o_event_attendee_kanban_view" default_order="name, create_date desc" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="state"/>
|
||||
|
||||
@@ -66,7 +66,11 @@
|
||||
<field name="name">fleet.vehicle.log.contract.tree</field>
|
||||
<field name="model">fleet.vehicle.log.contract</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Contract logs" decoration-danger="days_left==0" decoration-muted="state=='closed'" default_order="expiration_date">
|
||||
<tree string="Contract logs"
|
||||
decoration-danger="days_left==0"
|
||||
decoration-muted="state=='closed'"
|
||||
default_order="expiration_date"
|
||||
sample="1">
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="name" />
|
||||
<field name="start_date" />
|
||||
@@ -87,7 +91,7 @@
|
||||
<field name="name">fleet.vehicle.log.contract.kanban</field>
|
||||
<field name="model">fleet.vehicle.log.contract</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="activity_state"/>
|
||||
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}'/>
|
||||
<templates>
|
||||
|
||||
@@ -131,7 +131,10 @@
|
||||
<field name="name">fleet.vehicle.tree</field>
|
||||
<field name="model">fleet.vehicle</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Vehicle" decoration-warning="contract_renewal_due_soon and not contract_renewal_overdue" decoration-danger="contract_renewal_overdue">
|
||||
<tree string="Vehicle"
|
||||
decoration-warning="contract_renewal_due_soon and not contract_renewal_overdue"
|
||||
decoration-danger="contract_renewal_overdue"
|
||||
sample="1">
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="license_plate"/>
|
||||
<field name="model_id"/>
|
||||
@@ -192,7 +195,7 @@
|
||||
<field name="name">fleet.vehicle.kanban</field>
|
||||
<field name="model">fleet.vehicle</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state_id">
|
||||
<kanban default_group_by="state_id" sample="1">
|
||||
<field name="license_plate" />
|
||||
<field name="model_id" />
|
||||
<field name="driver_id" />
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<field name="name">hr.department.tree</field>
|
||||
<field name="model">hr.department</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Companies">
|
||||
<tree string="Companies" sample="1">
|
||||
<field name="display_name"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="manager_id"/>
|
||||
@@ -55,7 +55,7 @@
|
||||
<field name="name">hr.department.kanban</field>
|
||||
<field name="model">hr.department</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="oe_background_grey o_kanban_dashboard o_hr_kanban">
|
||||
<kanban class="oe_background_grey o_kanban_dashboard o_hr_kanban" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="company_id"/>
|
||||
<field name="manager_id"/>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<field name="name">hr.employee.tree</field>
|
||||
<field name="model">hr.employee.public</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Employees">
|
||||
<tree string="Employees" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="work_phone" class="o_force_ltr"/>
|
||||
<field name="work_email"/>
|
||||
@@ -110,7 +110,7 @@
|
||||
<field name="model">hr.employee.public</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_hr_employee_kanban" js_class="hr_employee_kanban">
|
||||
<kanban class="o_hr_employee_kanban" js_class="hr_employee_kanban" sample="1">
|
||||
<field name="id"/>
|
||||
<field name="hr_presence_state"/>
|
||||
<field name="user_id"/>
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
<field name="name">hr.employee.tree</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Employees" multi_edit="1">
|
||||
<tree string="Employees" multi_edit="1" sample="1">
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="work_phone" class="o_force_ltr" readonly="1"/>
|
||||
<field name="work_email"/>
|
||||
@@ -220,7 +220,7 @@
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_hr_employee_kanban" js_class="hr_employee_kanban">
|
||||
<kanban class="o_hr_employee_kanban" js_class="hr_employee_kanban" sample="1">
|
||||
<field name="id"/>
|
||||
<field name="hr_presence_state"/>
|
||||
<field name="user_id"/>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<field name="name">hr.job.tree</field>
|
||||
<field name="model">hr.job</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Job">
|
||||
<tree string="Job" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="department_id"/>
|
||||
<field name="no_of_employee"/>
|
||||
@@ -61,7 +61,7 @@
|
||||
<field name="name">hr.job.kanban</field>
|
||||
<field name="model">hr.job</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<field name="name">hr.attendance.tree</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Employee attendances" editable="bottom">
|
||||
<tree string="Employee attendances" editable="bottom" sample="1">
|
||||
<field name="employee_id"/>
|
||||
<field name="check_in"/>
|
||||
<field name="check_out"/>
|
||||
@@ -20,7 +20,7 @@
|
||||
<field name="name">hr.attendance.kanban</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="employee_id"/>
|
||||
<field name="check_in"/>
|
||||
<field name="check_out"/>
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
<field name="name">hr.contract.tree</field>
|
||||
<field name="model">hr.contract</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Contracts" multi_edit="1">
|
||||
<tree string="Contracts" multi_edit="1" sample="1">
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="employee_id" readonly="1" widget="many2one_avatar_employee"/>
|
||||
<field name="job_id"/>
|
||||
@@ -246,7 +246,7 @@
|
||||
<field name="name">hr.contract.kanban</field>
|
||||
<field name="model">hr.contract</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_small_column" default_order="date_end">
|
||||
<kanban class="o_kanban_small_column" default_order="date_end" sample="1">
|
||||
<field name="employee_id"/>
|
||||
<field name="activity_state"/>
|
||||
<field name="state"/>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<field name="name">hr.expense.tree</field>
|
||||
<field name="model">hr.expense</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Expenses" multi_edit="1" js_class="hr_expense_tree_dashboard_upload">
|
||||
<tree string="Expenses" multi_edit="1" js_class="hr_expense_tree_dashboard_upload" sample="1">
|
||||
<field name="attachment_number" invisible="True"/>
|
||||
<field name="date" optional="show"/>
|
||||
<field name="accounting_date" optional="hide" groups="account.group_account_invoice,account.group_account_readonly"/>
|
||||
@@ -160,7 +160,7 @@
|
||||
<field name="name">hr.expense.kanban</field>
|
||||
<field name="model">hr.expense</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile" js_class="hr_expense_kanban">
|
||||
<kanban class="o_kanban_mobile" js_class="hr_expense_kanban" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="total_amount"/>
|
||||
@@ -441,7 +441,7 @@
|
||||
<field name="name">hr.expense.sheet.tree</field>
|
||||
<field name="model">hr.expense.sheet</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Expense Reports">
|
||||
<tree string="Expense Reports" sample="1">
|
||||
<field name="accounting_date" optional="show" groups="account.group_account_manager"/>
|
||||
<field name="create_date" optional="hide"/>
|
||||
<field name="name" string="Expense Report"/>
|
||||
@@ -571,7 +571,7 @@
|
||||
<field name="name">hr.expense.sheet.kanban</field>
|
||||
<field name="model">hr.expense.sheet</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="total_amount"/>
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
<field name="model">hr.leave.allocation</field>
|
||||
<field name="priority">16</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Allocation Requests">
|
||||
<tree string="Allocation Requests" sample="1">
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id" optional="hide"/>
|
||||
<field name="holiday_status_id"/>
|
||||
@@ -242,7 +242,7 @@
|
||||
<field name="name">hr.leave.allocation.view.kanban</field>
|
||||
<field name="model">hr.leave.allocation</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile" create="0">
|
||||
<kanban class="o_kanban_mobile" create="0" sample="1">
|
||||
<field name="employee_id"/>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<field name="name">hr.leave.view.kanban</field>
|
||||
<field name="model">hr.leave</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile" create="0">
|
||||
<kanban class="o_kanban_mobile" create="0" sample="1">
|
||||
<field name="employee_id"/>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
@@ -484,7 +484,7 @@
|
||||
<field name="name">hr.holidays.view.tree</field>
|
||||
<field name="model">hr.leave</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Time Off Requests">
|
||||
<tree string="Time Off Requests" sample="1">
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id"/>
|
||||
<field name="holiday_type" string="Mode" groups="base.group_no_one"/>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<field name="name">hr.job.kanban</field>
|
||||
<field name="model">hr.job</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="oe_background_grey o_kanban_dashboard o_hr_recruitment_kanban" on_create="hr_recruitment.create_job_simple">
|
||||
<kanban class="oe_background_grey o_kanban_dashboard o_hr_recruitment_kanban" on_create="hr_recruitment.create_job_simple" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="alias_name"/>
|
||||
<field name="alias_domain"/>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<field name="name">Applicants</field>
|
||||
<field name="model">hr.applicant</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Applicants" multi_edit="1">
|
||||
<tree string="Applicants" multi_edit="1" sample="1">
|
||||
<field name="message_needaction" invisible="1"/>
|
||||
<field name="last_stage_id" invisible="1"/>
|
||||
<field name="create_date" readonly="1" widget="date" optional="show"/>
|
||||
@@ -266,7 +266,7 @@
|
||||
<field name="name">Hr Applicants kanban</field>
|
||||
<field name="model">hr.applicant</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="stage_id" class="o_kanban_applicant" quick_create_view="hr_recruitment.quick_create_applicant_form">
|
||||
<kanban default_group_by="stage_id" class="o_kanban_applicant" quick_create_view="hr_recruitment.quick_create_applicant_form" sample="1">
|
||||
<field name="stage_id" options='{"group_by_tooltip": {"requirements": "Requirements"}}'/>
|
||||
<field name="color"/>
|
||||
<field name="priority"/>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<field name="name">hr.work.entry.tree</field>
|
||||
<field name="model">hr.work.entry</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree multi_edit="1">
|
||||
<tree multi_edit="1" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="work_entry_type_id" options="{'no_create': True, 'no_open': True}"/>
|
||||
<field name="duration" readonly="1"/>
|
||||
|
||||
@@ -22,7 +22,7 @@ const ImLivechatChannelFormController = FormController.extend({
|
||||
this.$(`[name="${name}"] .o_field_color`).css('background-color', colorValues[name]);
|
||||
}
|
||||
const result = await this.model.notifyChanges(this.handle, colorValues);
|
||||
this.renderer.updateState(this.model.get(this.handle), { fieldNames: result });
|
||||
this._updateRendererState(this.model.get(this.handle), { fieldNames: result });
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<field name="name">link.tracker.view.tree</field>
|
||||
<field name="model">link.tracker</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Links">
|
||||
<tree string="Links" sample="1">
|
||||
<field name="create_date"/>
|
||||
<field name="title"/>
|
||||
<field name="label"/>
|
||||
|
||||
@@ -31,7 +31,7 @@ var LunchModel = BasicModel.extend({
|
||||
return self._buildLocationDomainLeaf() ? [self._buildLocationDomainLeaf()]: [];
|
||||
});
|
||||
},
|
||||
load: function () {
|
||||
__load: function () {
|
||||
var self = this;
|
||||
var args = arguments;
|
||||
var _super = this._super;
|
||||
@@ -43,7 +43,7 @@ var LunchModel = BasicModel.extend({
|
||||
return _super.apply(self, args);
|
||||
});
|
||||
},
|
||||
reload: function (id, options) {
|
||||
__reload: function (id, options) {
|
||||
var domain = options && options.domain || this.localData[id].domain;
|
||||
|
||||
this._addOrUpdate(domain, this._buildLocationDomainLeaf());
|
||||
|
||||
@@ -18,7 +18,7 @@ const ActivityModel = BasicModel.extend({
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
get: function () {
|
||||
__get: function () {
|
||||
var result = this._super.apply(this, arguments);
|
||||
if (result && result.model === this.modelName && result.type === 'list') {
|
||||
_.extend(result, this.additionalData, {getKanbanActivityData: this.getKanbanActivityData});
|
||||
@@ -63,7 +63,7 @@ const ActivityModel = BasicModel.extend({
|
||||
* @override
|
||||
* @param {Array[]} params.domain
|
||||
*/
|
||||
load: function (params) {
|
||||
__load: function (params) {
|
||||
this.originalDomain = _.extend([], params.domain);
|
||||
params.domain.push(['activity_ids', '!=', false]);
|
||||
this.domain = params.domain;
|
||||
@@ -78,7 +78,7 @@ const ActivityModel = BasicModel.extend({
|
||||
* @override
|
||||
* @param {Array[]} [params.domain]
|
||||
*/
|
||||
reload: function (handle, params) {
|
||||
__reload: function (handle, params) {
|
||||
if (params && 'domain' in params) {
|
||||
this.originalDomain = _.extend([], params.domain);
|
||||
params.domain.push(['activity_ids', '!=', false]);
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<field name="name">mail.activity.type.view.tree</field>
|
||||
<field name="model">mail.activity.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Activities">
|
||||
<tree string="Activities" sample="1">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="summary"/>
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<field name="name">equipment.request.kanban</field>
|
||||
<field name="model">maintenance.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="stage_id">
|
||||
<kanban default_group_by="stage_id" sample="1">
|
||||
<field name="stage_id"/>
|
||||
<field name="color"/>
|
||||
<field name="priority"/>
|
||||
@@ -180,7 +180,7 @@
|
||||
<field name="name">equipment.request.tree</field>
|
||||
<field name="model">maintenance.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="maintenance Request" multi_edit="1">
|
||||
<tree string="maintenance Request" multi_edit="1" sample="1">
|
||||
<field name="message_needaction" invisible="1"/>
|
||||
<field name="name"/>
|
||||
<field name="request_date" groups="base.group_no_one"/>
|
||||
@@ -411,7 +411,7 @@
|
||||
<field name="name">equipment.kanban</field>
|
||||
<field name="model">maintenance.equipment</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban>
|
||||
<kanban sample="1">
|
||||
<field name="name"/>
|
||||
<field name="color"/>
|
||||
<field name="technician_user_id"/>
|
||||
@@ -484,7 +484,7 @@
|
||||
<field name="name">equipment.tree</field>
|
||||
<field name="model">maintenance.equipment</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Assign To User">
|
||||
<tree string="Assign To User" sample="1">
|
||||
<field name="message_needaction" invisible="1"/>
|
||||
<field name="name"/>
|
||||
<!-- <field name="active" invisible="1"/> -->
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<field name="model">mailing.contact</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Mailing List Contacts">
|
||||
<tree string="Mailing List Contacts" sample="1">
|
||||
<field name="create_date"/>
|
||||
<field name="name"/>
|
||||
<field name="company_name"/>
|
||||
@@ -105,7 +105,7 @@
|
||||
<field name="name">mailing.contact.view.kanban</field>
|
||||
<field name="model">mailing.contact</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban>
|
||||
<kanban sample="1">
|
||||
<field name="name"/>
|
||||
<field name="company_name"/>
|
||||
<field name="email"/>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<field name="model">mailing.list</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Mailing Lists">
|
||||
<tree string="Mailing Lists" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="create_date"/>
|
||||
<field name="is_public"/>
|
||||
@@ -97,7 +97,7 @@
|
||||
<field name="name">mailing.list.view.kanban</field>
|
||||
<field name="model">mailing.list</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban js_class="mass_mailing_list_kanban" class="o_kanban_mobile" on_create="mass_mailing.open_create_mass_mailing_list">
|
||||
<kanban js_class="mass_mailing_list_kanban" class="o_kanban_mobile" on_create="mass_mailing.open_create_mass_mailing_list" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="contact_nbr"/>
|
||||
<field name="active"/>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<field name="model">mailing.mailing</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Mailings">
|
||||
<tree string="Mailings" sample="1">
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="subject" attrs="{'readonly': [('state', 'in', ('sending', 'done'))]}"/>
|
||||
<field name="mailing_type" invisible="1"/>
|
||||
@@ -291,7 +291,7 @@
|
||||
<field name="name">mailing.mailing.kanban</field>
|
||||
<field name="model">mailing.mailing</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state" quick_create="false">
|
||||
<kanban default_group_by="state" quick_create="false" sample="1">
|
||||
<field name='state' readonly="1"/>
|
||||
<field name='email_from' readonly="1"/>
|
||||
<field name='color'/>
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
<field name="model">mailing.mailing</field>
|
||||
<field name="priority">20</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="SMS Marketing">
|
||||
<tree string="SMS Marketing" sample="1">
|
||||
<field name="subject"/>
|
||||
<field name="mailing_type" invisible="1"/>
|
||||
<field name="mailing_model_id" string="Recipients"/>
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
<field name="name">mrp.bom.tree</field>
|
||||
<field name="model">mrp.bom</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Bill of Materials">
|
||||
<tree string="Bill of Materials" sample="1">
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="product_tmpl_id"/>
|
||||
@@ -163,7 +163,7 @@
|
||||
<field name="name">mrp.bom.kanban</field>
|
||||
<field name="model">mrp.bom</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="product_tmpl_id"/>
|
||||
<field name="product_qty"/>
|
||||
<field name="product_uom_id"/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<field name="name">mrp.production.tree</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Manufacturing Orders" default_order="date_planned_start desc" multi_edit="1">
|
||||
<tree string="Manufacturing Orders" default_order="date_planned_start desc" multi_edit="1" sample="1">
|
||||
<field name="message_needaction" invisible="1"/>
|
||||
<field name="name" decoration-bf="1"/>
|
||||
<field name="date_planned_start" readonly="1" optional="show"
|
||||
@@ -339,7 +339,7 @@
|
||||
<field name="name">mrp.production.kanban</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="product_id"/>
|
||||
<field name="product_qty"/>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<field name="name">mrp.unbuild.kanban</field>
|
||||
<field name="model">mrp.unbuild</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="product_id"/>
|
||||
<field name="product_qty"/>
|
||||
@@ -174,7 +174,7 @@
|
||||
<field name="name">mrp.unbuild.tree</field>
|
||||
<field name="model">mrp.unbuild</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<tree sample="1">
|
||||
<field name="name" decoration-bf="1"/>
|
||||
<field name="product_id"/>
|
||||
<field name="bom_id"/>
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
<field name="name">pos.order.kanban</field>
|
||||
<field name="model">pos.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile" create="0">
|
||||
<kanban class="o_kanban_mobile" create="0" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="amount_total"/>
|
||||
@@ -214,7 +214,7 @@
|
||||
<field name="name">pos.order.tree</field>
|
||||
<field name="model">pos.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="POS Orders" create="0">
|
||||
<tree string="POS Orders" create="0" sample="1">
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="name" decoration-bf="1"/>
|
||||
<field name="session_id" />
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
<field name="name">pos.session.tree.view</field>
|
||||
<field name="model">pos.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Point of Sale Session" create="0">
|
||||
<tree string="Point of Sale Session" create="0" sample="1">
|
||||
<field name="config_id" />
|
||||
<field name="name" />
|
||||
<field name="user_id" />
|
||||
@@ -150,7 +150,7 @@
|
||||
<field name="name">pos.session.kanban</field>
|
||||
<field name="model">pos.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile" create="0">
|
||||
<kanban class="o_kanban_mobile" create="0" sample="1">
|
||||
<field name="config_id" />
|
||||
<field name="name" />
|
||||
<field name="user_id" />
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
<field name="name">product.pricelist.tree</field>
|
||||
<field name="model">product.pricelist</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Products Price List">
|
||||
<tree string="Products Price List" sample="1">
|
||||
<field name="sequence" widget="handle" />
|
||||
<field name="name"/>
|
||||
<field name="discount_policy" groups="product.group_discount_per_so_line"/>
|
||||
@@ -200,7 +200,7 @@
|
||||
<field name="name">product.pricelist.kanban</field>
|
||||
<field name="model">product.pricelist</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div t-attf-class="oe_kanban_global_click">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<field name="name">product.template.product.tree</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Product" multi_edit="1">
|
||||
<tree string="Product" multi_edit="1" sample="1">
|
||||
<field name="product_variant_count" invisible="1"/>
|
||||
|
||||
<field name="sequence" widget="handle" readonly="1"/>
|
||||
@@ -70,7 +70,7 @@
|
||||
<field name="name">Product.template.product.kanban</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban>
|
||||
<kanban sample="1">
|
||||
<field name="id"/>
|
||||
<field name="product_variant_count"/>
|
||||
<field name="currency_id"/>
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
<field name="model">product.product</field>
|
||||
<field eval="7" name="priority"/>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Product Variants" multi_edit="1" duplicate="false">
|
||||
<tree string="Product Variants" multi_edit="1" duplicate="false" sample="1">
|
||||
<field name="default_code" optional="show" readonly="1"/>
|
||||
<field name="barcode" optional="hide" readonly="1"/>
|
||||
<field name="name" readonly="1"/>
|
||||
@@ -354,7 +354,7 @@
|
||||
<field name="name">Product Kanban</field>
|
||||
<field name="model">product.product</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban>
|
||||
<kanban sample="1">
|
||||
<field name="id"/>
|
||||
<field name="lst_price"/>
|
||||
<field name="activity_state"/>
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
<field name="name">project.task.type.tree</field>
|
||||
<field name="model">project.task.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Task Stage" delete="0">
|
||||
<tree string="Task Stage" delete="0" sample="1">
|
||||
<field name="sequence" widget="handle" optional="show"/>
|
||||
<field name="name"/>
|
||||
<field name="fold" optional="show"/>
|
||||
@@ -175,7 +175,7 @@
|
||||
<field name="name">project.task.type.kanban</field>
|
||||
<field name="model">project.task.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="fold"/>
|
||||
<field name="description"/>
|
||||
@@ -390,7 +390,7 @@
|
||||
<field name="name">project.project.tree</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-muted="active == False" string="Projects" delete="0" multi_edit="1">
|
||||
<tree decoration-muted="active == False" string="Projects" delete="0" multi_edit="1" sample="1">
|
||||
<field name="sequence" optional="show" widget="handle"/>
|
||||
<field name="message_needaction" invisible="1"/>
|
||||
<field name="active" invisible="1"/>
|
||||
@@ -487,7 +487,7 @@
|
||||
<field name="name">project.project.kanban</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="oe_background_grey o_kanban_dashboard o_project_kanban o_emphasize_colors" on_create="project.open_create_project">
|
||||
<kanban class="oe_background_grey o_kanban_dashboard o_project_kanban o_emphasize_colors" on_create="project.open_create_project" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="color"/>
|
||||
@@ -833,7 +833,7 @@
|
||||
<field name="model">project.task</field>
|
||||
<field eval="2" name="priority"/>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Tasks" multi_edit="1">
|
||||
<tree string="Tasks" multi_edit="1" sample="1">
|
||||
<field name="message_needaction" invisible="1" readonly="1"/>
|
||||
<field name="is_closed" invisible="1" />
|
||||
<field name="sequence" invisible="not context.get('seq_visible', False)" readonly="1"/>
|
||||
@@ -1040,7 +1040,7 @@
|
||||
<field name="name">Tags</field>
|
||||
<field name="model">project.tags</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Tags" editable="bottom">
|
||||
<tree string="Tags" editable="bottom" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="color" widget="color_picker"/>
|
||||
</tree>
|
||||
|
||||
@@ -18,10 +18,21 @@ var KanbanController = require('web.KanbanController');
|
||||
var KanbanModel = require('web.KanbanModel');
|
||||
var KanbanRenderer = require('web.KanbanRenderer');
|
||||
var KanbanView = require('web.KanbanView');
|
||||
var SampleServer = require('web.SampleServer');
|
||||
var view_registry = require('web.view_registry');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
// Add mock of method 'retrieve_dashboard' in SampleServer, so that we can have
|
||||
// the sample data in empty purchase kanban and list view
|
||||
let dashboardValues;
|
||||
SampleServer.mockRegistry.add('purchase.order', {
|
||||
retrieve_dashboard: () => {
|
||||
return Object.assign({}, dashboardValues);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// List View
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -72,7 +83,7 @@ var PurchaseListDashboardModel = ListModel.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get: function (localID) {
|
||||
__get: function (localID) {
|
||||
var result = this._super.apply(this, arguments);
|
||||
if (_.isObject(result)) {
|
||||
result.dashboardValues = this.dashboardValues[localID];
|
||||
@@ -83,14 +94,14 @@ var PurchaseListDashboardModel = ListModel.extend({
|
||||
* @override
|
||||
* @returns {Promise}
|
||||
*/
|
||||
load: function () {
|
||||
__load: function () {
|
||||
return this._loadDashboard(this._super.apply(this, arguments));
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: function () {
|
||||
__reload: function () {
|
||||
return this._loadDashboard(this._super.apply(this, arguments));
|
||||
},
|
||||
|
||||
@@ -107,7 +118,7 @@ var PurchaseListDashboardModel = ListModel.extend({
|
||||
});
|
||||
return Promise.all([super_def, dashboard_def]).then(function(results) {
|
||||
var id = results[0];
|
||||
var dashboardValues = results[1];
|
||||
dashboardValues = results[1];
|
||||
self.dashboardValues[id] = dashboardValues;
|
||||
return id;
|
||||
});
|
||||
@@ -187,7 +198,7 @@ var PurchaseKanbanDashboardModel = KanbanModel.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get: function (localID) {
|
||||
__get: function (localID) {
|
||||
var result = this._super.apply(this, arguments);
|
||||
if (_.isObject(result)) {
|
||||
result.dashboardValues = this.dashboardValues[localID];
|
||||
@@ -198,14 +209,14 @@ var PurchaseKanbanDashboardModel = KanbanModel.extend({
|
||||
* @override
|
||||
* @returns {Promise}
|
||||
*/
|
||||
load: function () {
|
||||
__load: function () {
|
||||
return this._loadDashboard(this._super.apply(this, arguments));
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: function () {
|
||||
__reload: function () {
|
||||
return this._loadDashboard(this._super.apply(this, arguments));
|
||||
},
|
||||
|
||||
@@ -222,7 +233,7 @@ var PurchaseKanbanDashboardModel = KanbanModel.extend({
|
||||
});
|
||||
return Promise.all([super_def, dashboard_def]).then(function(results) {
|
||||
var id = results[0];
|
||||
var dashboardValues = results[1];
|
||||
dashboardValues = results[1];
|
||||
self.dashboardValues[id] = dashboardValues;
|
||||
return id;
|
||||
});
|
||||
|
||||
@@ -52,9 +52,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_purchase_dashboard ~ .o_view_nocontent {
|
||||
// To prevent overlapping from inherited values
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
<field name="name">purchase.order.kanban</field>
|
||||
<field name="model">purchase.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile" js_class="purchase_kanban_dashboard">
|
||||
<kanban class="o_kanban_mobile" js_class="purchase_kanban_dashboard" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="amount_total"/>
|
||||
@@ -505,7 +505,7 @@
|
||||
<field name="priority" eval="1"/>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Purchase Order" multi_edit="1" decoration-bf="message_unread==True"
|
||||
decoration-muted="state=='cancel'" decoration-info="state in ('wait','confirmed')">
|
||||
decoration-muted="state=='cancel'" decoration-info="state in ('wait','confirmed')" sample="1">
|
||||
<field name="message_unread" invisible="1"/>
|
||||
<field name="partner_ref" optional="hide"/>
|
||||
<field name="name" string="Reference" readonly="1"/>
|
||||
@@ -535,7 +535,7 @@
|
||||
<field name="priority" eval="10"/>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Purchase Order" multi_edit="1" decoration-bf="message_unread==True"
|
||||
class="o_purchase_order" js_class="purchase_list_dashboard">
|
||||
class="o_purchase_order" js_class="purchase_list_dashboard" sample="1">
|
||||
<field name="message_unread" invisible="1"/>
|
||||
<field name="partner_ref" optional="hide"/>
|
||||
<field name="name" string="Reference" readonly="1" decoration-bf="1"/>
|
||||
@@ -563,7 +563,12 @@
|
||||
<field name="name">purchase.order.view.tree</field>
|
||||
<field name="model">purchase.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-bf="message_unread==True" decoration-muted="state=='cancel'" decoration-info="state in ('wait','confirmed')" string="Purchase Order" class="o_purchase_order">
|
||||
<tree decoration-bf="message_unread==True"
|
||||
decoration-muted="state=='cancel'"
|
||||
decoration-info="state in ('wait','confirmed')"
|
||||
string="Purchase Order"
|
||||
class="o_purchase_order"
|
||||
sample="1">
|
||||
<field name="message_unread" invisible="1"/>
|
||||
<field name="partner_ref" optional="hide"/>
|
||||
<field name="name" string="Reference" readonly="1" decoration-bf="1"/>
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
<field name="name">purchase.requisition.tree</field>
|
||||
<field name="model">purchase.requisition</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Purchase Agreements">
|
||||
<tree string="Purchase Agreements" sample="1">
|
||||
<field name="message_needaction" invisible="1"/>
|
||||
<field name="name" decoration-bf="1"/>
|
||||
<field name="user_id" optional="show" widget='many2one_avatar_user'/>
|
||||
@@ -212,7 +212,7 @@
|
||||
<field name="name">purchase.requisition.kanban</field>
|
||||
<field name="model">purchase.requisition</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="state"/>
|
||||
<field name="user_id"/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<field name="name">rating.rating.tree</field>
|
||||
<field name="model">rating.rating</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Rating" create="false" edit="false">
|
||||
<tree string="Rating" create="false" edit="false" sample="1">
|
||||
<field name="res_name"/>
|
||||
<field name="parent_res_name"/>
|
||||
<field name="rated_partner_id"/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<field name="name">repair.tree</field>
|
||||
<field name="model">repair.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Repairs order" multi_edit="1">
|
||||
<tree string="Repairs order" multi_edit="1" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="product_id" readonly="1" optional="show"/>
|
||||
<field name="product_qty" optional="hide" string="Quantity"/>
|
||||
@@ -208,7 +208,7 @@
|
||||
<field name="name">repair.kanban</field>
|
||||
<field name="model">repair.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="name" />
|
||||
<field name="product_id" />
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
<field name="name">sale.order.kanban</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="amount_total"/>
|
||||
@@ -195,7 +195,7 @@
|
||||
<field name="model">sale.order</field>
|
||||
<field name="priority">2</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Sales Orders" multi_edit="1">
|
||||
<tree string="Sales Orders" multi_edit="1" sample="1">
|
||||
<field name="message_needaction" invisible="1"/>
|
||||
<field name="name" string="Number" readonly="1" decoration-bf="1"/>
|
||||
<field name="date_order" string="Order Date" widget="date" optional="show"/>
|
||||
@@ -222,7 +222,7 @@
|
||||
<field name="model">sale.order</field>
|
||||
<field name="priority">4</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Quotation" class="o_sale_order" multi_edit="1">
|
||||
<tree string="Quotation" class="o_sale_order" multi_edit="1" sample="1">
|
||||
<field name="name" string="Number" readonly="1" decoration-bf="1"/>
|
||||
<field name="create_date" string="Creation Date" widget="date" optional="show"/>
|
||||
<field name="commitment_date" widget="date" optional="hide"/>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<field name="model">crm.team</field>
|
||||
<field name="field_parent">child_ids</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Sales Team">
|
||||
<tree string="Sales Team" sample="1">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="active" invisible="1"/>
|
||||
@@ -91,7 +91,7 @@
|
||||
<field name="name">crm.team.kanban</field>
|
||||
<field name="model">crm.team</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div t-attf-class="oe_kanban_content oe_kanban_global_click">
|
||||
@@ -115,7 +115,7 @@
|
||||
<field name="name">crm.team.dashboard</field>
|
||||
<field name="model">crm.team</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="oe_background_grey o_kanban_dashboard o_salesteam_kanban" create="0">
|
||||
<kanban class="oe_background_grey o_kanban_dashboard o_salesteam_kanban" create="0" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="member_ids"/>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
<field name="name">stock.inventory.tree</field>
|
||||
<field name="model">stock.inventory</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Lot/Serial Number Inventory">
|
||||
<tree string="Lot/Serial Number Inventory" sample="1">
|
||||
<field name="date"/>
|
||||
<field name="name"/>
|
||||
<field name="location_ids" widget="many2many_tags" optional="hide" groups="stock.group_stock_multi_locations"/>
|
||||
@@ -131,7 +131,7 @@
|
||||
<field name="name">stock.inventory.kanban</field>
|
||||
<field name="model">stock.inventory</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="date"/>
|
||||
<field name="state"/>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<field name="name">stock.warehouse.orderpoint.tree.editable</field>
|
||||
<field name="model">stock.warehouse.orderpoint</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Reordering Rules" editable="bottom" js_class="stock_orderpoint_list">
|
||||
<tree string="Reordering Rules" editable="bottom" js_class="stock_orderpoint_list" sample="1">
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="allowed_route_ids" invisible="1"/>
|
||||
<field name="product_category_id" invisible="1"/>
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
<field name="name">stock.picking.kanban</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="location_dest_id"/>
|
||||
@@ -189,7 +189,7 @@
|
||||
<field name="name">stock.picking.tree</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Picking list" multi_edit="1">
|
||||
<tree string="Picking list" multi_edit="1" sample="1">
|
||||
<field name="name" decoration-bf="1"/>
|
||||
<field name="location_id" options="{'no_create': True}" string="From" groups="stock.group_stock_multi_locations" optional="show"/>
|
||||
<field name="location_dest_id" options="{'no_create': True}" string="To" groups="stock.group_stock_multi_locations" optional="show"/>
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
<field eval="10" name="priority"/>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="bottom"
|
||||
create="1" edit="1" import="0" js_class="singleton_list">
|
||||
create="1" edit="1" import="0" js_class="singleton_list"
|
||||
sample="1">
|
||||
<field name="id" invisible="1"/>
|
||||
<field name="tracking" invisible="1"/>
|
||||
<field name="product_id" attrs="{'readonly': [('id', '!=', False)]}"
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<field name="name">stock.scrap.kanban</field>
|
||||
<field name="model">stock.scrap</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="product_id"/>
|
||||
<field name="date_done"/>
|
||||
@@ -118,7 +118,7 @@
|
||||
<field name="name">stock.scrap.tree</field>
|
||||
<field name="model">stock.scrap</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree multi_edit="1">
|
||||
<tree multi_edit="1" sample="1">
|
||||
<field name="name" decoration-bf="1"/>
|
||||
<field name="date_done"/>
|
||||
<field name="product_id" readonly="1"/>
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
<field name="name">Tree view for survey</field>
|
||||
<field name="model">survey.survey</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Survey">
|
||||
<tree string="Survey" sample="1">
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="certification" invisible="1"/>
|
||||
<field name="title"/>
|
||||
@@ -179,7 +179,7 @@
|
||||
<field name="name">Kanban view for survey</field>
|
||||
<field name="model">survey.survey</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban>
|
||||
<kanban sample="1">
|
||||
<field name="state" />
|
||||
<field name="title" />
|
||||
<field name="answer_done_count" />
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<field name="name">utm.campaign.view.tree</field>
|
||||
<field name="model">utm.campaign</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="UTM Campaigns" multi_edit="1">
|
||||
<tree string="UTM Campaigns" multi_edit="1" sample="1">
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="user_id"/>
|
||||
<field name="stage_id"/>
|
||||
@@ -73,7 +73,7 @@
|
||||
<field name="name">utm.campaign.view.kanban</field>
|
||||
<field name="model">utm.campaign</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by='stage_id' class="o_utm_kanban" on_create="quick_create" quick_create_view="utm.utm_campaign_view_form_quick_create" examples="utm_campaign">
|
||||
<kanban default_group_by='stage_id' class="o_utm_kanban" on_create="quick_create" quick_create_view="utm.utm_campaign_view_form_quick_create" examples="utm_campaign" sample="1">
|
||||
<field name='color'/>
|
||||
<field name='user_id'/>
|
||||
<field name="stage_id"/>
|
||||
|
||||
@@ -154,11 +154,6 @@ odoo.define('web.ActionMixin', function (require) {
|
||||
return this._title;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gives the focus to the action
|
||||
*/
|
||||
giveFocus: function () { },
|
||||
|
||||
/**
|
||||
* Renders the buttons to append, in most cases, to the control panel (in
|
||||
* the bottom left corner). When the action is rendered in a dialog, those
|
||||
|
||||
@@ -109,9 +109,11 @@ var Controller = Widget.extend({
|
||||
* @param {Model} model
|
||||
* @param {Renderer} renderer
|
||||
* @param {Object} params
|
||||
* @param {any} [params.handle=null]
|
||||
*/
|
||||
init: function (parent, model, renderer, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.handle = params.handle || null;
|
||||
this.model = model;
|
||||
this.renderer = renderer;
|
||||
},
|
||||
@@ -178,11 +180,13 @@ var Factory = Class.extend({
|
||||
var self = this;
|
||||
var model = this.getModel(parent);
|
||||
return Promise.all([this._loadData(model), ajax.loadLibs(this)]).then(function (result) {
|
||||
var state = result[0];
|
||||
const { state, handle } = result[0];
|
||||
var renderer = self.getRenderer(parent, state);
|
||||
var Controller = self.Controller || self.config.Controller;
|
||||
const initialState = model.get(handle);
|
||||
var controllerParams = _.extend({
|
||||
initialState: state,
|
||||
initialState,
|
||||
handle,
|
||||
}, self.controllerParams);
|
||||
var controller = new Controller(parent, model, renderer, controllerParams);
|
||||
model.setParent(controller);
|
||||
@@ -221,13 +225,16 @@ var Factory = Class.extend({
|
||||
*
|
||||
* @private
|
||||
* @param {Model} model a Model instance
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.withSampleData=true]
|
||||
* @returns {Promise<*>} a promise that resolves to the value returned by
|
||||
* the get method from the model
|
||||
* @todo: get rid of loadParams (use modelParams instead)
|
||||
*/
|
||||
_loadData: function (model) {
|
||||
return model.load(this.loadParams).then(function () {
|
||||
return model.get.apply(model, arguments);
|
||||
_loadData: function (model, options = {}) {
|
||||
options.withSampleData = 'withSampleData' in options ? options.withSampleData : true;
|
||||
return model.load(this.loadParams).then(function (handle) {
|
||||
return { state: model.get(handle, options), handle };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -205,6 +205,28 @@ var diacriticsMap = {
|
||||
|
||||
const patchMap = new WeakMap();
|
||||
|
||||
/**
|
||||
* Helper function returning an extraction handler to use on array elements to
|
||||
* return a certain attribute or mutated form of the element.
|
||||
*
|
||||
* @private
|
||||
* @param {string | function} criterion
|
||||
* @returns {(element: any) => any}
|
||||
*/
|
||||
function _getExtractorFrom(criterion) {
|
||||
if (criterion) {
|
||||
switch (typeof criterion) {
|
||||
case 'string': return element => element[criterion];
|
||||
case 'function': return criterion;
|
||||
default: throw new Error(
|
||||
`Expected criterion of type 'string' or 'function' and got '${typeof criterion}'`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return element => element;
|
||||
}
|
||||
}
|
||||
|
||||
var utils = {
|
||||
|
||||
/**
|
||||
@@ -303,6 +325,31 @@ var utils = {
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Returns an object holding different groups defined by a given criterion
|
||||
* or a default one. Each group is a subset of the original given list.
|
||||
* The given criterion can either be:
|
||||
* - a string: a property name on the list elements which value will be the
|
||||
* group name,
|
||||
* - a function: a handler that will return the group name from a given
|
||||
* element.
|
||||
*
|
||||
* @param {any[]} list
|
||||
* @param {string | function} [criterion]
|
||||
* @returns {Object}
|
||||
*/
|
||||
groupBy: function (list, criterion) {
|
||||
const extract = _getExtractorFrom(criterion);
|
||||
const groups = {};
|
||||
for (const element of list) {
|
||||
const group = String(extract(element));
|
||||
if (!(group in groups)) {
|
||||
groups[group] = [];
|
||||
}
|
||||
groups[group].push(element);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
/**
|
||||
* Returns a human readable number (e.g. 34000 -> 34k).
|
||||
*
|
||||
@@ -656,22 +703,13 @@ var utils = {
|
||||
* - a function: a handler that will return the sortable primitive from a given element.
|
||||
*
|
||||
* @param {any[]} array
|
||||
* @param {(string|function)} [criterion]
|
||||
* @param {string | function} [criterion]
|
||||
*/
|
||||
sortBy: function (array, criterion) {
|
||||
let getter;
|
||||
if (criterion) {
|
||||
switch (typeof criterion) {
|
||||
case 'string': getter = element => element[criterion]; break;
|
||||
case 'function': getter = criterion; break;
|
||||
default: throw new Error(`Expected criterion of type 'string' or 'function' and got '${typeof criterion}'`);
|
||||
}
|
||||
} else {
|
||||
getter = element => element;
|
||||
}
|
||||
const extract = _getExtractorFrom(criterion);
|
||||
return array.slice().sort((elA, elB) => {
|
||||
const a = getter(elA);
|
||||
const b = getter(elB);
|
||||
const a = extract(elA);
|
||||
const b = extract(elB);
|
||||
if (isNaN(a) && isNaN(b)) {
|
||||
return a > b ? 1 : a < b ? -1 : 0;
|
||||
} else {
|
||||
|
||||
@@ -96,7 +96,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
if (this.withControlPanel) {
|
||||
this._updateControlPanelProps(this.initialState);
|
||||
this._controlPanelWrapper = new ComponentWrapper(this, ControlPanel, this.controlPanelProps);
|
||||
this._controlPanelWrapper.env.bus.on('focus-view', this, () => this.renderer.giveFocus());
|
||||
this._controlPanelWrapper.env.bus.on('focus-view', this, () => this._giveFocus());
|
||||
promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' }));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
@@ -192,12 +192,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
}
|
||||
return state;
|
||||
},
|
||||
/**
|
||||
* Gives the focus to the renderer
|
||||
*/
|
||||
giveFocus: function () {
|
||||
this.renderer.giveFocus();
|
||||
},
|
||||
/**
|
||||
* The use of this method is discouraged. It is still snakecased, because
|
||||
* it currently is used in many templates, but we will move to a simpler
|
||||
@@ -246,7 +240,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
}
|
||||
await Promise.all([this.update(params, {}), searchPanelUpdateProm]);
|
||||
if (postponeRendering) {
|
||||
return this.renderer._render();
|
||||
return this._updateRendererState(false);
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -259,40 +253,26 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
* updating the renderer and wait for the rendering to complete.
|
||||
*
|
||||
* @param {Object} params will be given to the model and to the renderer
|
||||
* @param {Object} [options]
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.reload=true] if true, the model will reload data
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
update: async function (params, options = {}) {
|
||||
async update(params, options = {}) {
|
||||
const shouldReload = 'reload' in options ? options.reload : true;
|
||||
if (shouldReload) {
|
||||
this.handle = await this.dp.add(this.model.reload(this.handle, params));
|
||||
}
|
||||
const localState = this.renderer.getLocalState();
|
||||
const state = this.model.get(this.handle);
|
||||
const state = this.model.get(this.handle, { withSampleData: true });
|
||||
const promises = [
|
||||
this.updateRendererState(state, params).then(() => {
|
||||
this._updateRendererState(state, params).then(() => {
|
||||
this.renderer.setLocalState(localState);
|
||||
}),
|
||||
this._update(state, params),
|
||||
this._update(this.model.get(this.handle), params)
|
||||
];
|
||||
await this.dp.add(Promise.all(promises));
|
||||
this.updateButtons();
|
||||
},
|
||||
/**
|
||||
* Update the state of the renderer (handle both Widget and Component
|
||||
* renderers).
|
||||
*
|
||||
* @param {Object} state the model state
|
||||
* @param {Object} params will be given to the model and to the renderer
|
||||
*/
|
||||
updateRendererState: function (state, params) {
|
||||
if (this.renderer instanceof owl.Component) {
|
||||
return this.renderer.update(state);
|
||||
}
|
||||
return this.renderer.updateState(state, params);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
@@ -326,6 +306,16 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
_getActionMenuItems: function (state) {
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* Gives the focus to the renderer if not in sample mode.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_giveFocus() {
|
||||
if (!this.model.isInSampleMode()) {
|
||||
this.renderer.giveFocus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This method is the way a view can notifies the outside world that
|
||||
* something has changed. The main use for this is to update the url, for
|
||||
@@ -339,6 +329,20 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
state: this.getState(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {function} callback function to execute before removing classname
|
||||
* 'o_view_sample_data' (may be async). This allows to reload and/or
|
||||
* rerender before removing the className, thus preventing the view from
|
||||
* flickering.
|
||||
*/
|
||||
async _removeSampleData(callback) {
|
||||
this.model.leaveSampleMode();
|
||||
if (callback) {
|
||||
await callback();
|
||||
}
|
||||
this.el.classList.remove('o_view_sample_data');
|
||||
},
|
||||
/**
|
||||
* Renders the html provided by the route specified by the
|
||||
* bannerRoute attribute on the controller (banner_route in the template).
|
||||
@@ -409,7 +413,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
* @param {Object} [params.shouldUpdateControlPanel]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_update: function (state, params) {
|
||||
async _update(state, params) {
|
||||
// AAB: update the control panel -> this will be moved elsewhere at some point
|
||||
if (!this.$buttons) {
|
||||
this.renderButtons();
|
||||
@@ -423,7 +427,8 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
promises.push(this.updateControlPanel());
|
||||
}
|
||||
this._pushState();
|
||||
return Promise.all(promises);
|
||||
await Promise.all(promises);
|
||||
this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode());
|
||||
},
|
||||
/**
|
||||
* Can be used to update the key 'cp_content'. This method is called in start and _update methods.
|
||||
@@ -457,6 +462,21 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
return this.updateControlPanel({ pager: pagingInfo });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Updates the state of the renderer (handle both Widget and Component
|
||||
* renderers).
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state the model state
|
||||
* @param {Object} [params={}] will be given to the model and to the renderer
|
||||
* @return {Promise}
|
||||
*/
|
||||
_updateRendererState(state, params = {}) {
|
||||
if (this.renderer instanceof owl.Component) {
|
||||
return this.renderer.update(state);
|
||||
}
|
||||
return this.renderer.updateState(state, params);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
@@ -536,7 +556,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
break;
|
||||
case 'down':
|
||||
ev.stopPropagation();
|
||||
this.giveFocus();
|
||||
this._giveFocus();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,25 +12,199 @@ odoo.define('web.AbstractModel', function (require) {
|
||||
* Note that the model is not a widget, it does not need to be rendered or
|
||||
* appended to the dom. However, it inherits from the EventDispatcherMixin,
|
||||
* in order to be able to notify its parent by bubbling events up.
|
||||
*
|
||||
* The model is able to generate sample (fake) data when there is no actual data
|
||||
* in database. This feature can be activated by instantiating the model with
|
||||
* param "useSampleModel" set to true. In this case, the model instantiates a
|
||||
* duplicated version of itself, parametrized to call a SampleServer (JS)
|
||||
* instead of doing RPCs. Here is how it works: the main model first load the
|
||||
* data normally (from database), and then checks whether the result is empty or
|
||||
* not. If it is, it asks the sample model to load with the exact same params,
|
||||
* and it thus enters in "sample" mode. The model keeps doing this at reload,
|
||||
* but only if the (re)load params haven't changed: as soon as a param changes,
|
||||
* the "sample" mode is left, and it never enters it again in the future (in the
|
||||
* lifetime of the model instance). To access those sample data from the outside,
|
||||
* 'get' must be called with the the option "withSampleData" set to true. In
|
||||
* this case, if the main model is in "sample" mode, it redirects the call to the
|
||||
* sample model.
|
||||
*/
|
||||
|
||||
var fieldUtils = require('web.field_utils');
|
||||
var mvc = require('web.mvc');
|
||||
const SampleServer = require('web.SampleServer');
|
||||
|
||||
|
||||
var AbstractModel = mvc.Model.extend({
|
||||
/**
|
||||
* @param {Widget} parent
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.fields]
|
||||
* @param {string} [params.modelName]
|
||||
* @param {boolean} [params.isSampleModel=false] if true, will fetch data
|
||||
* from a SampleServer instead of doing RPCs
|
||||
* @param {boolean} [params.useSampleModel=false] if true, will use a sample
|
||||
* model to generate sample data when there is no "real" data in database
|
||||
* @param {AbstractModel} [params.SampleModel] the AbstractModel class
|
||||
* to instantiate as sample model. This model won't do any rpc, but will
|
||||
* rather call a SampleServer that will generate sample data. This param
|
||||
* must be set when params.useSampleModel is true.
|
||||
*/
|
||||
init(parent, params = {}) {
|
||||
this._super(...arguments);
|
||||
this.useSampleModel = params.useSampleModel || false;
|
||||
if (params.isSampleModel) {
|
||||
this.isSampleModel = true;
|
||||
this.sampleServer = new SampleServer(params.modelName, params.fields);
|
||||
} else if (this.useSampleModel) {
|
||||
const sampleModelParams = Object.assign({}, params, {
|
||||
isSampleModel: true,
|
||||
SampleModel: null,
|
||||
useSampleModel: false,
|
||||
});
|
||||
this.sampleModel = new params.SampleModel(this, sampleModelParams);
|
||||
this._isInSampleMode = false;
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Override to call get on the sampleModel when we are in sample mode, and
|
||||
* option 'withSampleData' is set to true.
|
||||
*
|
||||
* @override
|
||||
* @param {any} _
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.withSampleData=false]
|
||||
*/
|
||||
get(_, options) {
|
||||
let state;
|
||||
if (options && options.withSampleData && this._isInSampleMode) {
|
||||
state = this.sampleModel.__get(...arguments);
|
||||
} else {
|
||||
state = this.__get(...arguments);
|
||||
}
|
||||
return state;
|
||||
},
|
||||
/**
|
||||
* Under some conditions, the model is designed to generate sample data if
|
||||
* there is no real data in database. This function returns a boolean which
|
||||
* indicates the mode of the model: if true, we are in "sample" mode.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isInSampleMode() {
|
||||
return !!this._isInSampleMode;
|
||||
},
|
||||
/**
|
||||
* Disables the sample data (forever) on this model instance.
|
||||
*/
|
||||
leaveSampleMode() {
|
||||
if (this.useSampleModel) {
|
||||
this.useSampleModel = false;
|
||||
this._isInSampleMode = false;
|
||||
this.sampleModel.destroy();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Override to check if we need to call the sample model (and if so, to do
|
||||
* it) after loading the data, in the case where there is no real data to
|
||||
* display.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async load(params) {
|
||||
this.loadParams = params;
|
||||
const handle = await this.__load(...arguments);
|
||||
await this._callSampleModel('__load', handle, ...arguments);
|
||||
return handle;
|
||||
},
|
||||
/**
|
||||
* When something changes, the data may need to be refetched. This is the
|
||||
* job for this method: reloading (only if necessary) all the data and
|
||||
* making sure that they are ready to be redisplayed.
|
||||
* Sometimes, we reload the data with the "same" params as the initial load
|
||||
* params (see '_haveParamsChanged'). When we do, if we were in "sample" mode,
|
||||
* we call again the sample server after the reload if there is still no data
|
||||
* to display. When the parameters change, we automatically leave "sample"
|
||||
* mode.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {any} _
|
||||
* @param {Object} [params]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: function (params) {
|
||||
async reload(_, params) {
|
||||
const handle = await this.__reload(...arguments);
|
||||
if (this._isInSampleMode) {
|
||||
if (!this._haveParamsChanged(params)) {
|
||||
await this._callSampleModel('__reload', handle, ...arguments);
|
||||
} else {
|
||||
this.leaveSampleMode();
|
||||
}
|
||||
}
|
||||
return handle;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} method
|
||||
* @param {any} handle
|
||||
* @param {...any} args
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _callSampleModel(method, handle, ...args) {
|
||||
if (this.useSampleModel && this._isEmpty(handle)) {
|
||||
try {
|
||||
if (method === '__load') {
|
||||
await this.sampleModel.__load(...args);
|
||||
} else if (method === '__reload') {
|
||||
await this.sampleModel.__reload(...args);
|
||||
}
|
||||
this._isInSampleMode = true;
|
||||
} catch (error) {
|
||||
if (error instanceof SampleServer.UnimplementedRouteError) {
|
||||
this.leaveSampleMode();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.leaveSampleMode();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
__get() {
|
||||
return {};
|
||||
},
|
||||
/**
|
||||
* This function can be overriden to determine if the result of a load or
|
||||
* a reload is empty. In the affirmative, we will try to generate sample
|
||||
* data to prevent from having an empty state to display.
|
||||
*
|
||||
* @private
|
||||
* @params {any} handle, the value returned by a load or a reload
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isEmpty(/* handle */) {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* To override to do the initial load of the data (this function is supposed
|
||||
* to be called only once).
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async __load() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
@@ -57,6 +231,54 @@ var AbstractModel = mvc.Model.extend({
|
||||
}
|
||||
return value;
|
||||
},
|
||||
/**
|
||||
* To override to reload data (this function may be called several times,
|
||||
* after the initial load has been done).
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async __reload() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* Determines whether or not the given params (reload params) differ from
|
||||
* the initial ones (this.loadParams). This is used to leave "sample" mode
|
||||
* as soon as a parameter (e.g. domain) changes.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.context]
|
||||
* @param {Array[]} [params.domain]
|
||||
* @param {Object} [params.timeRanges]
|
||||
* @param {string[]} [params.groupBy]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_haveParamsChanged(params = {}) {
|
||||
for (const key of ['context', 'domain', 'timeRanges']) {
|
||||
if (key in params) {
|
||||
const diff = JSON.stringify(params[key]) !== JSON.stringify(this.loadParams[key]);
|
||||
if (diff) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.useSampleModel && 'groupBy' in params) {
|
||||
return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Override to redirect all rpcs to the SampleServer if this.isSampleModel
|
||||
* is true.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async _rpc() {
|
||||
if (this.isSampleModel) {
|
||||
return this.sampleServer.mockRpc(...arguments);
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
return AbstractModel;
|
||||
|
||||
@@ -9,10 +9,28 @@ odoo.define('web.AbstractRenderer', function (require) {
|
||||
|
||||
var mvc = require('web.mvc');
|
||||
|
||||
// Renderers may display sample data when there is no real data to display. In
|
||||
// this case the data is displayed with opacity and can't be clicked. Moreover,
|
||||
// we also want to prevent the user from accessing DOM elements with TAB
|
||||
// navigation. This is the list of elements we won't allow to focus.
|
||||
const FOCUSABLE_ELEMENTS = [
|
||||
// focusable by default
|
||||
'a', 'button', 'input', 'select', 'textarea',
|
||||
// manually set
|
||||
'[tabindex="0"]'
|
||||
].map((sel) => `:scope ${sel}`).join(', ');
|
||||
|
||||
/**
|
||||
* @class AbstractRenderer
|
||||
*/
|
||||
return mvc.Renderer.extend({
|
||||
// Defines the elements suppressed when in demo data. This must be a list
|
||||
// of DOM selectors matching view elements that will:
|
||||
// 1. receive the 'o_sample_data_disabled' class (greyd out & no user events)
|
||||
// 2. have themselves and any of their focusable children removed from the
|
||||
// tab navigation
|
||||
sampleDataTargets: [],
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {string} [params.noContentHelp]
|
||||
@@ -29,12 +47,12 @@ return mvc.Renderer.extend({
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
start: function () {
|
||||
async start() {
|
||||
this.$el.addClass(this.arch.attrs.class);
|
||||
if (this.withSearchPanel) {
|
||||
this.$el.addClass('o_renderer_with_searchpanel');
|
||||
}
|
||||
return Promise.all([this._render(), this._super()]);
|
||||
await Promise.all([this._render(), this._super()]);
|
||||
},
|
||||
/**
|
||||
* Called each time the renderer is attached into the DOM.
|
||||
@@ -92,9 +110,11 @@ return mvc.Renderer.extend({
|
||||
* if true, the method only updates the state without rerendering
|
||||
* @returns {Promise}
|
||||
*/
|
||||
updateState: function (state, params) {
|
||||
this.state = state;
|
||||
return params.noRender ? Promise.resolve() : this._render();
|
||||
async updateState(state, params) {
|
||||
this._setState(state);
|
||||
if (!params.noRender) {
|
||||
await this._render();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -102,14 +122,66 @@ return mvc.Renderer.extend({
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render the view
|
||||
* Renders the widget. This method can be overriden to perform actions
|
||||
* before or after the view has been rendered.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _render() {
|
||||
await this._renderView();
|
||||
this._suppressFocusableElements();
|
||||
},
|
||||
/**
|
||||
* Actual rendering. This method is meant to be overridden by concrete
|
||||
* renderers.
|
||||
*
|
||||
* @abstract
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_render: function () {
|
||||
return Promise.resolve();
|
||||
async _renderView() { },
|
||||
/**
|
||||
* Assigns a new state to the renderer if not false.
|
||||
*
|
||||
* @private
|
||||
* @param {any} [state=false]
|
||||
*/
|
||||
_setState(state = false) {
|
||||
if (state !== false) {
|
||||
this.state = state;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Suppresses 'tabindex' property on any focusable element located inside
|
||||
* root elements defined in the `this.sampleDataTargets` object and assigns
|
||||
* the 'o_sample_data_disabled' class to these root elements.
|
||||
*
|
||||
* @private
|
||||
* @see sampleDataTargets
|
||||
*/
|
||||
_suppressFocusableElements() {
|
||||
if (!this.state.isSample) {
|
||||
return;
|
||||
}
|
||||
const rootEls = [];
|
||||
for (const selector of this.sampleDataTargets) {
|
||||
rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`));
|
||||
}
|
||||
const focusableEls = new Set(rootEls);
|
||||
for (const rootEl of rootEls) {
|
||||
rootEl.classList.add('o_sample_data_disabled');
|
||||
for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) {
|
||||
focusableEls.add(focusableEl);
|
||||
}
|
||||
}
|
||||
for (const focusableEl of focusableEls) {
|
||||
focusableEl.setAttribute('tabindex', -1);
|
||||
if (focusableEl.classList.contains('dropdown-item')) {
|
||||
// Tells Bootstrap to ignore the dropdown item in keynav
|
||||
focusableEl.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -162,6 +162,16 @@ var AbstractView = Factory.extend({
|
||||
res_id: currentId,
|
||||
res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined),
|
||||
};
|
||||
|
||||
this.modelParams = {
|
||||
fields: this.fields,
|
||||
modelName: params.modelName,
|
||||
useSampleModel: !!(this.arch.attrs.sample && JSON.parse(this.arch.attrs.sample))
|
||||
};
|
||||
if (this.modelParams.useSampleModel) {
|
||||
this.modelParams.SampleModel = this.config.Model;
|
||||
}
|
||||
|
||||
var defaultOrder = this.arch.attrs.default_order;
|
||||
if (defaultOrder) {
|
||||
this.loadParams.orderedBy = _.map(defaultOrder.split(','), function (order) {
|
||||
|
||||
@@ -43,7 +43,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
this.hasButtons = params.hasButtons;
|
||||
FieldManagerMixin.init.call(this, this.model);
|
||||
this.mode = params.mode || 'readonly';
|
||||
this.handle = this.initialState.id;
|
||||
// savingDef is used to ensure that we always wait for pending save
|
||||
// operations to complete before checking if there are changes to
|
||||
// discard when discardChanges is called
|
||||
@@ -134,12 +133,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
getSelectedIds: function () {
|
||||
return [];
|
||||
},
|
||||
/**
|
||||
* Gives the focus to the renderer
|
||||
*/
|
||||
giveFocus:function() {
|
||||
this.renderer.giveFocus();
|
||||
},
|
||||
/**
|
||||
* Returns true iff the given recordID (or the main recordID) is dirty.
|
||||
*
|
||||
@@ -762,7 +755,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
|
||||
await this.model.resequence(this.modelName, resIDs, this.handle, options);
|
||||
this._updateControlPanel();
|
||||
state = this.model.get(this.handle);
|
||||
return this.renderer.updateState(state, { noRender: true });
|
||||
return this._updateRendererState(state, { noRender: true });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -164,6 +164,13 @@ var BasicModel = AbstractModel.extend({
|
||||
this.batchedRPCsRequests = [];
|
||||
|
||||
this.localData = Object.create(null);
|
||||
// used to generate dataPoint ids. Note that the counter is set to 0 for
|
||||
// each instance, and this is mandatory for the sample data feature to
|
||||
// work: we need both the main model and the sample model to generate the
|
||||
// same datapoint ids for their common data (groups, when there are real
|
||||
// groups in database), so that we can easily do the mapping between
|
||||
// real and sample data.
|
||||
this.__id = 0;
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
@@ -549,7 +556,7 @@ var BasicModel = AbstractModel.extend({
|
||||
this.localData[listID].orderedResIDs = list.res_ids;
|
||||
},
|
||||
/**
|
||||
* The get method first argument is the handle returned by the load method.
|
||||
* The __get method first argument is the handle returned by the load method.
|
||||
* It is optional (the handle can be undefined). In some case, it makes
|
||||
* sense to use the handle as a key, for example the BasicModel holds the
|
||||
* data for various records, each with its local ID.
|
||||
@@ -563,7 +570,7 @@ var BasicModel = AbstractModel.extend({
|
||||
* @param {boolean} [options.raw=false] if true, will not follow relations
|
||||
* @returns {Object}
|
||||
*/
|
||||
get: function (id, options) {
|
||||
__get: function (id, options) {
|
||||
var self = this;
|
||||
options = options || {};
|
||||
|
||||
@@ -601,7 +608,7 @@ var BasicModel = AbstractModel.extend({
|
||||
relDataPoint = this.localData[data[fieldName]];
|
||||
data[fieldName] = relDataPoint ? relDataPoint.res_id : false;
|
||||
} else {
|
||||
data[fieldName] = this.get(data[fieldName]) || false;
|
||||
data[fieldName] = this.__get(data[fieldName]) || false;
|
||||
}
|
||||
} else if (field.type === 'reference') {
|
||||
if (options.raw) {
|
||||
@@ -610,7 +617,7 @@ var BasicModel = AbstractModel.extend({
|
||||
relDataPoint.model + ',' + relDataPoint.res_id :
|
||||
false;
|
||||
} else {
|
||||
data[fieldName] = this.get(data[fieldName]) || false;
|
||||
data[fieldName] = this.__get(data[fieldName]) || false;
|
||||
}
|
||||
} else if (field.type === 'one2many' || field.type === 'many2many') {
|
||||
if (options.raw) {
|
||||
@@ -625,7 +632,7 @@ var BasicModel = AbstractModel.extend({
|
||||
data[fieldName] = data[fieldName] || [];
|
||||
}
|
||||
} else {
|
||||
data[fieldName] = this.get(data[fieldName]) || [];
|
||||
data[fieldName] = this.__get(data[fieldName]) || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -687,7 +694,7 @@ var BasicModel = AbstractModel.extend({
|
||||
context: _.extend({}, element.context),
|
||||
count: element.count,
|
||||
data: _.map(element.data, function (elemID) {
|
||||
return self.get(elemID, options);
|
||||
return self.__get(elemID, options);
|
||||
}),
|
||||
domain: element.domain.slice(0),
|
||||
fields: element.fields,
|
||||
@@ -701,6 +708,7 @@ var BasicModel = AbstractModel.extend({
|
||||
id: element.id,
|
||||
isDirty: element.isDirty,
|
||||
isOpen: element.isOpen,
|
||||
isSample: this.isSampleModel,
|
||||
limit: element.limit,
|
||||
model: element.model,
|
||||
offset: element.offset,
|
||||
@@ -750,6 +758,27 @@ var BasicModel = AbstractModel.extend({
|
||||
});
|
||||
return isDirty;
|
||||
},
|
||||
/**
|
||||
* Returns true iff the datapoint is of type list and either:
|
||||
* - is not grouped, and contains no records
|
||||
* - is grouped, and contains columns, but all columns are empty
|
||||
* In these cases, we will generate sample data to display, instead of an
|
||||
* empty state.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_isEmpty(dataPointID) {
|
||||
const dataPoint = this.localData[dataPointID];
|
||||
if (dataPoint.type === 'list') {
|
||||
const hasRecords = dataPoint.count === 0;
|
||||
if (dataPoint.groupedBy.length) {
|
||||
return dataPoint.data.length > 0 && hasRecords;
|
||||
} else {
|
||||
return hasRecords;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* Check if a localData is new, meaning if it is in the process of being
|
||||
* created and no actual record exists in db. Note: if the localData is not
|
||||
@@ -782,6 +811,7 @@ var BasicModel = AbstractModel.extend({
|
||||
*
|
||||
* @todo document all params
|
||||
*
|
||||
* @private
|
||||
* @param {any} params
|
||||
* @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field
|
||||
* @param {Object} params.fields contains the description of each field
|
||||
@@ -789,7 +819,8 @@ var BasicModel = AbstractModel.extend({
|
||||
* @param {string} [params.recordID] an ID for an existing resource.
|
||||
* @returns {Promise<string>} resolves to a local id, or handle
|
||||
*/
|
||||
load: function (params) {
|
||||
__load: async function (params) {
|
||||
await this._super(...arguments);
|
||||
params.type = params.type || (params.res_id !== undefined ? 'record' : 'list');
|
||||
// FIXME: the following seems only to be used by the basic_model_tests
|
||||
// so it should probably be removed and the tests should be adapted
|
||||
@@ -954,13 +985,15 @@ var BasicModel = AbstractModel.extend({
|
||||
* Reload all data for a given resource. At any time there is at most one
|
||||
* reload operation active.
|
||||
*
|
||||
* @private
|
||||
* @param {string} id local id for a resource
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.keepChanges=false] if true, doesn't discard the
|
||||
* changes on the record before reloading it
|
||||
* @returns {Promise<string>} resolves to the id of the resource
|
||||
*/
|
||||
reload: function (id, options) {
|
||||
__reload: async function (id, options) {
|
||||
await this._super(...arguments);
|
||||
return this.mutex.exec(this._reload.bind(this, id, options));
|
||||
},
|
||||
/**
|
||||
@@ -2195,6 +2228,20 @@ var BasicModel = AbstractModel.extend({
|
||||
}
|
||||
return hasOnchange ? specs : false;
|
||||
},
|
||||
/**
|
||||
* Ensures that dataPoint ids are always synchronized between the main and
|
||||
* sample models when being in sample mode. Here, we now that __id in the
|
||||
* sample model is always greater than __id in the main model (as it
|
||||
* contains strictly more datapoints).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async _callSampleModel() {
|
||||
await this._super(...arguments);
|
||||
if (this._isInSampleMode) {
|
||||
this.__id = this.sampleModel.__id;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Compute the default value that the handle field should take.
|
||||
* We need to compute this in order for new lines to be added at the correct position.
|
||||
@@ -3968,7 +4015,7 @@ var BasicModel = AbstractModel.extend({
|
||||
groupsCount: 0,
|
||||
groupsLimit: type === 'list' && params.groupsLimit || null,
|
||||
groupsOffset: 0,
|
||||
id: _.uniqueId(params.modelName + '_'),
|
||||
id: `${params.modelName}_${++this.__id}`,
|
||||
isOpen: params.isOpen,
|
||||
limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER),
|
||||
loadMoreOffset: 0,
|
||||
@@ -4847,6 +4894,23 @@ var BasicModel = AbstractModel.extend({
|
||||
return result.id;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Override to handle the case where we want sample data, and we are in a
|
||||
* grouped kanban or list view with real groups, but all groups are empty.
|
||||
* In this case, we use the result of the web_read_group rpc to tweak the
|
||||
* data in the SampleServer instance of the sampleModel (so that calls to
|
||||
* that server will return the same groups).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async _rpc(params) {
|
||||
const result = await this._super(...arguments);
|
||||
if (this.sampleModel && params.method === 'web_read_group' && result.length) {
|
||||
const sampleServer = this.sampleModel.sampleServer;
|
||||
sampleServer.setExistingGroups(result.groups);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
/**
|
||||
* Allows to save a value in the specialData cache associated to a given
|
||||
* record and fieldName. If the value in the cache was already the given
|
||||
|
||||
@@ -142,7 +142,7 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, {
|
||||
*/
|
||||
confirmChange: function (state, id, fields, ev) {
|
||||
var self = this;
|
||||
this.state = state;
|
||||
this._setState(state);
|
||||
var record = this._getRecord(id);
|
||||
if (!record) {
|
||||
return this._render().then(_.constant([]));
|
||||
@@ -455,7 +455,7 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_hasContent: function () {
|
||||
return this.state.count !== 0;
|
||||
return this.state.count !== 0 && (!('isSample' in this.state) || !this.state.isSample);
|
||||
},
|
||||
/**
|
||||
* Force the resequencing of the records after moving one of them to a given
|
||||
@@ -646,25 +646,25 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, {
|
||||
return modifiersData.evaluatedModifiers[record.id];
|
||||
},
|
||||
/**
|
||||
* Render the view
|
||||
*
|
||||
* @override
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_render: function () {
|
||||
var oldAllFieldWidgets = this.allFieldWidgets;
|
||||
async _render() {
|
||||
const oldAllFieldWidgets = this.allFieldWidgets;
|
||||
this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great
|
||||
this.allModifiersData = [];
|
||||
var oldWidgets = this.widgets;
|
||||
const oldWidgets = this.widgets;
|
||||
this.widgets = [];
|
||||
return this._renderView().then(function () {
|
||||
_.each(oldAllFieldWidgets, function (recordWidgets) {
|
||||
_.each(recordWidgets, function (widget) {
|
||||
widget.destroy();
|
||||
});
|
||||
});
|
||||
_.invoke(oldWidgets, 'destroy');
|
||||
});
|
||||
|
||||
await this._super(...arguments);
|
||||
|
||||
for (const id in oldAllFieldWidgets) {
|
||||
for (const widget of oldAllFieldWidgets[id]) {
|
||||
widget.destroy();
|
||||
}
|
||||
}
|
||||
for (const widget of oldWidgets) {
|
||||
widget.destroy();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Instantiates the appropriate AbstractField specialization for the given
|
||||
@@ -775,22 +775,6 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, {
|
||||
.addClass('o_view_nocontent')
|
||||
.append($noContent);
|
||||
},
|
||||
/**
|
||||
* Actual rendering. Supposed to be overridden by concrete renderers.
|
||||
* The basic responsabilities of _renderView are:
|
||||
* - use the xml arch of the view to render a jQuery representation
|
||||
* - instantiate a widget from the registry for each field in the arch
|
||||
*
|
||||
* Note that the 'state' field should contains all necessary information
|
||||
* for the rendering. The field widgets should be as synchronous as
|
||||
* possible.
|
||||
*
|
||||
* @abstract
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_renderView: function () {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* Instantiate custom widgets
|
||||
*
|
||||
|
||||
@@ -197,7 +197,8 @@ var BasicView = AbstractView.extend({
|
||||
}
|
||||
}
|
||||
return Promise.resolve(def).then(function () {
|
||||
return model.get(record.id);
|
||||
const handle = record.id;
|
||||
return { state: model.get(handle), handle };
|
||||
});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
|
||||
@@ -173,7 +173,7 @@ return AbstractModel.extend({
|
||||
* @override
|
||||
* @returns {Object}
|
||||
*/
|
||||
get: function () {
|
||||
__get: function () {
|
||||
return _.extend({}, this.data, {
|
||||
fields: this.fields
|
||||
});
|
||||
@@ -183,7 +183,7 @@ return AbstractModel.extend({
|
||||
* @param {any} params
|
||||
* @returns {Promise}
|
||||
*/
|
||||
load: function (params) {
|
||||
__load: function (params) {
|
||||
var self = this;
|
||||
this.modelName = params.modelName;
|
||||
this.fields = params.fields;
|
||||
@@ -256,7 +256,7 @@ return AbstractModel.extend({
|
||||
* @param {Array} [params.domain]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: function (handle, params) {
|
||||
__reload: function (handle, params) {
|
||||
if (params.domain) {
|
||||
this.data.domain = params.domain;
|
||||
}
|
||||
|
||||
@@ -567,11 +567,9 @@ return AbstractRenderer.extend({
|
||||
/**
|
||||
* Render the calendar view, this is the main entry point.
|
||||
*
|
||||
* @override method from AbstractRenderer
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
* @override
|
||||
*/
|
||||
_render: function () {
|
||||
async _renderView() {
|
||||
this.$('.o_calendar_view')[0].prepend(this.calendarElement);
|
||||
if (this._isInDOM) {
|
||||
this._renderCalendar();
|
||||
@@ -590,9 +588,7 @@ return AbstractRenderer.extend({
|
||||
$a.not('.ui-state-active').addClass('o_color');
|
||||
});
|
||||
|
||||
var filterProm = this._renderFilters();
|
||||
|
||||
return Promise.all([filterProm, this._super.apply(this, arguments)]);
|
||||
await this._renderFilters();
|
||||
},
|
||||
/**
|
||||
* Render the specific code for the FullCalendar when it's in the DOM
|
||||
|
||||
@@ -286,6 +286,7 @@ var FormRenderer = BasicRenderer.extend(WidgetAdapterMixin, {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
updateState: function (state, params) {
|
||||
this._setState(state);
|
||||
this.mode = (params && 'mode' in params) ? params.mode : this.mode;
|
||||
|
||||
// if fieldNames are given, we update the corresponding field widget.
|
||||
@@ -293,7 +294,7 @@ var FormRenderer = BasicRenderer.extend(WidgetAdapterMixin, {
|
||||
// confirmChange method
|
||||
if (params.fieldNames) {
|
||||
// only update the given fields
|
||||
return this.confirmChange(state, state.id, params.fieldNames);
|
||||
return this.confirmChange(this.state, this.state.id, params.fieldNames);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ return AbstractModel.extend({
|
||||
* @override
|
||||
* @returns {Object}
|
||||
*/
|
||||
get: function () {
|
||||
__get: function () {
|
||||
return _.extend({}, this.chart);
|
||||
},
|
||||
/**
|
||||
@@ -57,7 +57,7 @@ return AbstractModel.extend({
|
||||
* @returns {Promise} The promise does not return a handle, we don't need
|
||||
* to keep track of various entities.
|
||||
*/
|
||||
load: function (params) {
|
||||
__load: function (params) {
|
||||
var groupBys = params.context.graph_groupbys || params.groupBys;
|
||||
this.initialGroupBys = groupBys;
|
||||
this.fields = params.fields;
|
||||
@@ -97,7 +97,7 @@ return AbstractModel.extend({
|
||||
* @param {Object} [params.timeRanges]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: function (handle, params) {
|
||||
__reload: function (handle, params) {
|
||||
if ('context' in params) {
|
||||
this.chart.context = params.context;
|
||||
this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy;
|
||||
|
||||
@@ -75,12 +75,6 @@ return AbstractRenderer.extend({
|
||||
this.$legendTooltip = null;
|
||||
this.$tooltip = null;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
updateState: function () {
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Chart.js does not need the canvas to be in dom in order
|
||||
* to be able to work well. We could avoid the calls to on_attach_callback
|
||||
@@ -754,10 +748,8 @@ return AbstractRenderer.extend({
|
||||
* immediately, then we render the chart when the widget is in the DOM.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @returns {Promise} The _super promise is actually resolved immediately
|
||||
*/
|
||||
_render: function () {
|
||||
async _renderView() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
@@ -796,7 +788,6 @@ return AbstractRenderer.extend({
|
||||
|
||||
this._renderTitle();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* create bar chart.
|
||||
|
||||
@@ -191,7 +191,7 @@ var KanbanColumn = Widget.extend({
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
addQuickCreate: function () {
|
||||
addQuickCreate: async function () {
|
||||
if (this.folded) {
|
||||
// first open the column, and then add the quick create
|
||||
this.trigger_up('column_toggle_fold', {
|
||||
@@ -204,7 +204,6 @@ var KanbanColumn = Widget.extend({
|
||||
return Promise.reject();
|
||||
}
|
||||
this.trigger_up('close_quick_create'); // close other quick create widgets
|
||||
this.trigger_up('start_quick_create');
|
||||
var context = this.data.getContext();
|
||||
context['default_' + this.groupedBy] = viewUtils.getGroupValue(this.data, this.groupedBy);
|
||||
this.quickCreateWidget = new RecordQuickCreate(this, {
|
||||
@@ -212,7 +211,10 @@ var KanbanColumn = Widget.extend({
|
||||
formViewRef: this.quickCreateView,
|
||||
model: this.modelName,
|
||||
});
|
||||
return this.quickCreateWidget.insertAfter(this.$header);
|
||||
await this.quickCreateWidget.appendTo(document.createDocumentFragment());
|
||||
this.trigger_up('start_quick_create');
|
||||
this.quickCreateWidget.$el.insertAfter(this.$header);
|
||||
this.quickCreateWidget.on_attach_callback();
|
||||
},
|
||||
/**
|
||||
* Closes the quick create widget if it isn't dirty.
|
||||
@@ -285,7 +287,7 @@ var KanbanColumn = Widget.extend({
|
||||
* @private
|
||||
*/
|
||||
_onAddQuickCreate: function () {
|
||||
this.addQuickCreate();
|
||||
this.trigger_up('add_quick_create', { groupId: this.db_id });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
|
||||
@@ -21,6 +21,7 @@ var qweb = core.qweb;
|
||||
var KanbanController = BasicController.extend({
|
||||
buttons_template: 'KanbanView.buttons',
|
||||
custom_events: _.extend({}, BasicController.prototype.custom_events, {
|
||||
add_quick_create: '_onAddQuickCreate',
|
||||
quick_create_add_column: '_onAddColumn',
|
||||
quick_create_record: '_onQuickCreateRecord',
|
||||
resequence_columns: '_onResequenceColumn',
|
||||
@@ -90,6 +91,20 @@ var KanbanController = BasicController.extend({
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Displays the record quick create widget in the requested column, given its
|
||||
* id (in the first column by default). Ensures that we removed sample data
|
||||
* if any, before displaying the quick create.
|
||||
*
|
||||
* @private
|
||||
* @param {string} [groupId]
|
||||
*/
|
||||
_addQuickCreate(groupId) {
|
||||
this._removeSampleData(async () => {
|
||||
await this.update({ shouldUpdateControlPanel: false }, { reload: false });
|
||||
return this.renderer.addQuickCreate(groupId);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override method comes from field manager mixin
|
||||
* @private
|
||||
@@ -205,12 +220,13 @@ var KanbanController = BasicController.extend({
|
||||
*/
|
||||
_shouldBounceOnClick(element) {
|
||||
const state = this.model.get(this.handle, {raw: true});
|
||||
if (!state.count) {
|
||||
if (!state.count || state.isSample) {
|
||||
const classesList = [
|
||||
'o_kanban_view',
|
||||
'o_kanban_group',
|
||||
'o_kanban_header',
|
||||
'o_column_quick_create',
|
||||
'o_view_nocontent_smiling_face'
|
||||
'o_view_nocontent_smiling_face',
|
||||
];
|
||||
return classesList.some(c => element.classList.contains(c));
|
||||
}
|
||||
@@ -266,6 +282,15 @@ var KanbanController = BasicController.extend({
|
||||
});
|
||||
}).guardedCatch(this.reload.bind(this));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @returns {string} ev.data.groupId
|
||||
*/
|
||||
_onAddQuickCreate(ev) {
|
||||
ev.stopPropagation();
|
||||
this._addQuickCreate(ev.data.groupId);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
@@ -309,16 +334,13 @@ var KanbanController = BasicController.extend({
|
||||
* @private
|
||||
*/
|
||||
_onButtonNew: function () {
|
||||
var self = this;
|
||||
var state = this.model.get(this.handle, {raw: true});
|
||||
var quickCreateEnabled = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state);
|
||||
if (this.on_create === 'quick_create' && quickCreateEnabled && state.data.length) {
|
||||
// activate the quick create in the first column when the mutex is
|
||||
// unlocked, to ensure that there is no pending re-rendering that
|
||||
// would remove it (e.g. if we are currently adding a new column)
|
||||
this.mutex.getUnlockedDef().then(function () {
|
||||
self.renderer.addQuickCreate();
|
||||
});
|
||||
this.mutex.getUnlockedDef().then(this._addQuickCreate.bind(this, null));
|
||||
} else if (this.on_create && this.on_create !== 'quick_create') {
|
||||
// Execute the given action
|
||||
this.do_action(this.on_create, {
|
||||
@@ -342,7 +364,7 @@ var KanbanController = BasicController.extend({
|
||||
_onButtonsKeyDown: function (ev) {
|
||||
switch(ev.keyCode) {
|
||||
case $.ui.keyCode.DOWN:
|
||||
this.$('.o_kanban_record:first').focus();
|
||||
this._giveFocus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -357,15 +379,12 @@ var KanbanController = BasicController.extend({
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onDeleteColumn: function (ev) {
|
||||
var self = this;
|
||||
var column = ev.target;
|
||||
var state = this.model.get(this.handle, {raw: true});
|
||||
var relatedModelName = state.fields[state.groupedBy[0]].relation;
|
||||
this.model
|
||||
.deleteRecords([column.db_id], relatedModelName)
|
||||
.then(function () {
|
||||
self.update({}, {reload: !column.isEmpty()});
|
||||
});
|
||||
.then(this.update.bind(this, {}, {}));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
|
||||
@@ -10,7 +10,6 @@ var BasicModel = require('web.BasicModel');
|
||||
var viewUtils = require('web.viewUtils');
|
||||
|
||||
var KanbanModel = BasicModel.extend({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -69,28 +68,36 @@ var KanbanModel = BasicModel.extend({
|
||||
context: parent.context, // todo: combine with view context
|
||||
})
|
||||
.then(function (result) {
|
||||
var newGroup = self._makeDataPoint({
|
||||
modelName: parent.model,
|
||||
context: parent.context,
|
||||
domain: parent.domain.concat([[groupBy,"=",result[0]]]),
|
||||
fields: parent.fields,
|
||||
fieldsInfo: parent.fieldsInfo,
|
||||
isOpen: true,
|
||||
limit: parent.limit,
|
||||
parentID: parent.id,
|
||||
openGroupByDefault: true,
|
||||
orderedBy: parent.orderedBy,
|
||||
value: result,
|
||||
viewType: parent.viewType,
|
||||
});
|
||||
if (parent.progressBar) {
|
||||
newGroup.progressBarValues = _.extend({
|
||||
counts: {},
|
||||
}, parent.progressBar);
|
||||
}
|
||||
|
||||
// newGroup.is_open = true;
|
||||
const createGroupDataPoint = (model, parent) => {
|
||||
const newGroup = model._makeDataPoint({
|
||||
modelName: parent.model,
|
||||
context: parent.context,
|
||||
domain: parent.domain.concat([[groupBy, "=", result[0]]]),
|
||||
fields: parent.fields,
|
||||
fieldsInfo: parent.fieldsInfo,
|
||||
isOpen: true,
|
||||
limit: parent.limit,
|
||||
parentID: parent.id,
|
||||
openGroupByDefault: true,
|
||||
orderedBy: parent.orderedBy,
|
||||
value: result,
|
||||
viewType: parent.viewType,
|
||||
});
|
||||
if (parent.progressBar) {
|
||||
newGroup.progressBarValues = _.extend({
|
||||
counts: {},
|
||||
}, parent.progressBar);
|
||||
}
|
||||
return newGroup;
|
||||
};
|
||||
const newGroup = createGroupDataPoint(self, parent);
|
||||
parent.data.push(newGroup.id);
|
||||
if (self.isInSampleMode()) {
|
||||
// in sample mode, create the new group in both models (main + sample)
|
||||
const sampleParent = self.sampleModel.localData[parentID];
|
||||
const newSampleGroup = createGroupDataPoint(self.sampleModel, sampleParent);
|
||||
sampleParent.data.push(newSampleGroup.id);
|
||||
}
|
||||
return newGroup.id;
|
||||
});
|
||||
},
|
||||
@@ -143,7 +150,7 @@ var KanbanModel = BasicModel.extend({
|
||||
* @see _readTooltipFields
|
||||
* @returns {Object}
|
||||
*/
|
||||
get: function () {
|
||||
__get: function () {
|
||||
var result = this._super.apply(this, arguments);
|
||||
var dp = result && this.localData[result.id];
|
||||
if (dp) {
|
||||
@@ -178,7 +185,7 @@ var KanbanModel = BasicModel.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
load: function (params) {
|
||||
__load: function (params) {
|
||||
this.defaultGroupedBy = params.groupBy || [];
|
||||
params.groupedBy = (params.groupedBy && params.groupedBy.length) ? params.groupedBy : this.defaultGroupedBy;
|
||||
return this._super(params);
|
||||
@@ -262,6 +269,12 @@ var KanbanModel = BasicModel.extend({
|
||||
if (options && options.groupBy && !options.groupBy.length) {
|
||||
options.groupBy = this.defaultGroupedBy;
|
||||
}
|
||||
return this._super(id, options);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
__reload: function (id, options) {
|
||||
var def = this._super(id, options);
|
||||
if (options && options.loadMoreOffset) {
|
||||
return def;
|
||||
|
||||
@@ -98,6 +98,14 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
events:_.extend({}, BasicRenderer.prototype.events || {}, {
|
||||
'keydown .o_kanban_record' : '_onRecordKeyDown'
|
||||
}),
|
||||
sampleDataTargets: [
|
||||
'.o_kanban_counter',
|
||||
'.o_kanban_record',
|
||||
'.o_kanban_toggle_fold',
|
||||
'.o_column_folded',
|
||||
'.o_column_archive_records',
|
||||
'.o_column_unarchive_records',
|
||||
],
|
||||
|
||||
/**
|
||||
* @override
|
||||
@@ -152,12 +160,20 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Displays the quick create record in the first column.
|
||||
* Displays the quick create record in the requested column (first one by
|
||||
* default)
|
||||
*
|
||||
* @params {string} [groupId] local id of the group in which the quick create
|
||||
* must be inserted
|
||||
* @returns {Promise}
|
||||
*/
|
||||
addQuickCreate: function () {
|
||||
return this.widgets[0].addQuickCreate();
|
||||
addQuickCreate: function (groupId) {
|
||||
let kanbanColumn;
|
||||
if (groupId) {
|
||||
kanbanColumn = this.widgets.find(column => column.db_id === groupId);
|
||||
}
|
||||
kanbanColumn = kanbanColumn || this.widgets[0];
|
||||
return kanbanColumn.addQuickCreate();
|
||||
},
|
||||
/**
|
||||
* Focuses the first kanban record
|
||||
@@ -192,7 +208,7 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
var column = this.widgets[index];
|
||||
this.widgets[index] = newColumn;
|
||||
if (options && options.state) {
|
||||
this.state = options.state;
|
||||
this._setState(options.state);
|
||||
}
|
||||
return newColumn.appendTo(document.createDocumentFragment()).then(function () {
|
||||
var def;
|
||||
@@ -239,13 +255,6 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
updateState: function (state) {
|
||||
this._setState(state);
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
@@ -263,7 +272,7 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
},
|
||||
/**
|
||||
* Tries to give focus to the previous card, and returns true if successful
|
||||
*
|
||||
*
|
||||
* @private
|
||||
* @param {DOMElement} currentColumn
|
||||
* @returns {boolean}
|
||||
@@ -490,16 +499,15 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
/**
|
||||
* Sets the current state and updates some internal attributes accordingly.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state
|
||||
* @override
|
||||
*/
|
||||
_setState: function (state) {
|
||||
this.state = state;
|
||||
_setState: function () {
|
||||
this._super(...arguments);
|
||||
|
||||
var groupByField = state.groupedBy[0];
|
||||
var groupByField = this.state.groupedBy[0];
|
||||
var cleanGroupByField = this._cleanGroupByField(groupByField);
|
||||
var groupByFieldAttrs = state.fields[cleanGroupByField];
|
||||
var groupByFieldInfo = state.fieldsInfo.kanban[cleanGroupByField];
|
||||
var groupByFieldAttrs = this.state.fields[cleanGroupByField];
|
||||
var groupByFieldInfo = this.state.fieldsInfo.kanban[cleanGroupByField];
|
||||
// Deactivate the drag'n'drop if the groupedBy field:
|
||||
// - is a date or datetime since we group by month or
|
||||
// - is readonly (on the field attrs or in the view)
|
||||
@@ -528,7 +536,7 @@ var KanbanRenderer = BasicRenderer.extend({
|
||||
grouped_by_m2o: this.groupedByM2O,
|
||||
grouped_by_date: grouped_by_date,
|
||||
relation: relation,
|
||||
quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state),
|
||||
quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(this.state),
|
||||
});
|
||||
this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable;
|
||||
},
|
||||
|
||||
@@ -213,20 +213,22 @@ var ListController = BasicController.extend({
|
||||
_addRecord: function (dataPointId) {
|
||||
var self = this;
|
||||
this._disableButtons();
|
||||
return this.renderer.unselectRow().then(function () {
|
||||
return self.model.addDefaultRecord(dataPointId, {
|
||||
position: self.editable,
|
||||
});
|
||||
}).then(function (recordID) {
|
||||
var state = self.model.get(self.handle);
|
||||
self.renderer.updateState(state, {keepWidths: true})
|
||||
.then(function () {
|
||||
self.renderer.editRecord(recordID);
|
||||
})
|
||||
.then(() => {
|
||||
self._updatePaging(state);
|
||||
return this._removeSampleData(() => {
|
||||
return this.renderer.unselectRow().then(function () {
|
||||
return self.model.addDefaultRecord(dataPointId, {
|
||||
position: self.editable,
|
||||
});
|
||||
}).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this));
|
||||
}).then(function (recordID) {
|
||||
var state = self.model.get(self.handle);
|
||||
self._updateRendererState(state, { keepWidths: true })
|
||||
.then(function () {
|
||||
self.renderer.editRecord(recordID);
|
||||
})
|
||||
.then(() => {
|
||||
self._updatePaging(state);
|
||||
});
|
||||
}).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard
|
||||
@@ -244,10 +246,14 @@ var ListController = BasicController.extend({
|
||||
break;
|
||||
case $.ui.keyCode.DOWN:
|
||||
e.preventDefault();
|
||||
self.renderer.giveFocus();
|
||||
self._giveFocus();
|
||||
break;
|
||||
case $.ui.keyCode.TAB:
|
||||
if (!e.shiftKey && e.target.classList.contains("btn-primary")) {
|
||||
if (
|
||||
!e.shiftKey &&
|
||||
e.target.classList.contains("btn-primary") &&
|
||||
!self.model.isInSampleMode()
|
||||
) {
|
||||
e.preventDefault();
|
||||
$createButton.tooltip('show');
|
||||
}
|
||||
@@ -266,7 +272,7 @@ var ListController = BasicController.extend({
|
||||
*/
|
||||
_confirmSave: function (id) {
|
||||
var state = this.model.get(this.handle);
|
||||
return this.renderer.updateState(state, {noRender: true})
|
||||
return this._updateRendererState(state, { noRender: true })
|
||||
.then(this._setMode.bind(this, 'readonly', id));
|
||||
},
|
||||
/**
|
||||
@@ -443,7 +449,7 @@ var ListController = BasicController.extend({
|
||||
// and the list was multi-editable, we do not want to select
|
||||
// the next row.
|
||||
this.selectedRecords = [];
|
||||
await this.renderer.updateState(state, {
|
||||
await this._updateRendererState(state, {
|
||||
keepWidths: true,
|
||||
selectedRecords: [],
|
||||
});
|
||||
@@ -524,7 +530,7 @@ var ListController = BasicController.extend({
|
||||
*/
|
||||
_shouldBounceOnClick() {
|
||||
const state = this.model.get(this.handle, {raw: true});
|
||||
return !state.count;
|
||||
return !state.count || state.isSample;
|
||||
},
|
||||
/**
|
||||
* Called when clicking on 'Archive' or 'Unarchive' in the sidebar.
|
||||
|
||||
@@ -223,7 +223,7 @@ ListRenderer.include({
|
||||
var self = this;
|
||||
|
||||
var oldData = this.state.data;
|
||||
this.state = state;
|
||||
this._setState(state);
|
||||
return this.confirmChange(state, id, fields, ev).then(function () {
|
||||
// If no record with 'id' can be found in the state, the
|
||||
// confirmChange method will have rerendered the whole view already,
|
||||
@@ -369,7 +369,7 @@ ListRenderer.include({
|
||||
* @param {string} recordID
|
||||
*/
|
||||
removeLine: function (state, recordID) {
|
||||
this.state = state;
|
||||
this._setState(state);
|
||||
var $row = this._getRow(recordID);
|
||||
if ($row.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -25,7 +25,7 @@ odoo.define('web.ListModel', function (require) {
|
||||
* @override
|
||||
* @see _readGroupExtraFields
|
||||
*/
|
||||
get: function () {
|
||||
__get: function () {
|
||||
var result = this._super.apply(this, arguments);
|
||||
var dp = result && this.localData[result.id];
|
||||
if (dp && dp.groupData) {
|
||||
|
||||
@@ -35,6 +35,7 @@ var FIELD_CLASSES = {
|
||||
};
|
||||
|
||||
var ListRenderer = BasicRenderer.extend({
|
||||
className: 'o_list_view',
|
||||
events: {
|
||||
"mousedown": "_onMouseDown",
|
||||
"click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn",
|
||||
@@ -49,6 +50,12 @@ var ListRenderer = BasicRenderer.extend({
|
||||
'keydown td': '_onKeyDown',
|
||||
'keydown th': '_onKeyDown',
|
||||
},
|
||||
sampleDataTargets: [
|
||||
'.o_data_row',
|
||||
'.o_group_header',
|
||||
'.o_list_table > tfoot',
|
||||
'.o_list_table > thead .o_list_record_selector',
|
||||
],
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Widget} parent
|
||||
@@ -89,8 +96,8 @@ var ListRenderer = BasicRenderer.extend({
|
||||
|
||||
/**
|
||||
* Order to focus to be given to the content of the current view
|
||||
*
|
||||
* @override
|
||||
* @public
|
||||
*/
|
||||
giveFocus: function () {
|
||||
this.$('th:eq(0) input, th:eq(1)').first().focus();
|
||||
@@ -99,7 +106,8 @@ var ListRenderer = BasicRenderer.extend({
|
||||
* @override
|
||||
*/
|
||||
updateState: function (state, params) {
|
||||
this.isGrouped = state.groupedBy.length > 0;
|
||||
this._setState(state);
|
||||
this.isGrouped = this.state.groupedBy.length > 0;
|
||||
this._processColumns(params.columnInvisibleFields || {});
|
||||
if (params.selectedRecords) {
|
||||
this.selection = params.selectedRecords;
|
||||
@@ -1021,80 +1029,82 @@ var ListRenderer = BasicRenderer.extend({
|
||||
* this method does not wait for the field widgets to be ready.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @returns {Promise} resolved when the view has been rendered
|
||||
*/
|
||||
_renderView: function () {
|
||||
var self = this;
|
||||
|
||||
async _renderView() {
|
||||
const oldPagers = this.pagers;
|
||||
this.pagers = [];
|
||||
let prom;
|
||||
let tableWrapper;
|
||||
if (this.state.count > 0 || !this.noContentHelp) {
|
||||
// render a table if there are records, or if there is no no content
|
||||
// helper (empty table in this case)
|
||||
this.pagers = [];
|
||||
|
||||
// display the no content helper if there is no data to display
|
||||
var displayNoContentHelper = !this._hasContent() && !!this.noContentHelp;
|
||||
this.$el.toggleClass('o_list_view', !displayNoContentHelper);
|
||||
if (displayNoContentHelper) {
|
||||
// destroy the previously instantiated pagers, if any
|
||||
oldPagers.forEach(pager => pager.destroy());
|
||||
const orderedBy = this.state.orderedBy;
|
||||
this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField;
|
||||
this._computeAggregates();
|
||||
|
||||
this.$el.removeClass('table-responsive');
|
||||
this.$el.html(this._renderNoContentHelper());
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
const $table = $(
|
||||
'<table class="o_list_table table table-sm table-hover table-striped"/>'
|
||||
);
|
||||
$table.toggleClass('o_list_table_grouped', this.isGrouped);
|
||||
$table.toggleClass('o_list_table_ungrouped', !this.isGrouped);
|
||||
const defs = [];
|
||||
this.defs = defs;
|
||||
if (this.isGrouped) {
|
||||
$table.append(this._renderHeader());
|
||||
$table.append(this._renderGroups(this.state.data));
|
||||
$table.append(this._renderFooter());
|
||||
|
||||
var orderedBy = this.state.orderedBy;
|
||||
this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField;
|
||||
this._computeAggregates();
|
||||
|
||||
var $table = $('<table>').addClass('o_list_table table table-sm table-hover table-striped');
|
||||
$table.toggleClass('o_list_table_grouped', this.isGrouped);
|
||||
$table.toggleClass('o_list_table_ungrouped', !this.isGrouped);
|
||||
var defs = [];
|
||||
this.defs = defs;
|
||||
if (this.isGrouped) {
|
||||
$table.append(this._renderHeader());
|
||||
$table.append(this._renderGroups(this.state.data));
|
||||
$table.append(this._renderFooter());
|
||||
|
||||
} else {
|
||||
$table.append(this._renderHeader());
|
||||
$table.append(this._renderBody());
|
||||
$table.append(this._renderFooter());
|
||||
}
|
||||
const tableWrapper = Object.assign(document.createElement('div'), {
|
||||
className: 'table-responsive',
|
||||
});
|
||||
tableWrapper.appendChild($table[0]);
|
||||
delete this.defs;
|
||||
|
||||
var prom = Promise.all(defs).then(() => {
|
||||
// destroy the previously instantiated pagers, if any
|
||||
oldPagers.forEach(pager => pager.destroy());
|
||||
|
||||
// Append the table to the main element
|
||||
self.el.innerHTML = "";
|
||||
dom.append(self.el, tableWrapper, {
|
||||
callbacks: [{ widget: this }],
|
||||
in_DOM: document.body.contains(self.el),
|
||||
});
|
||||
|
||||
if (self.optionalColumns.length) {
|
||||
self.$el.addClass('o_list_optional_columns');
|
||||
self.$('table').append($('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>'));
|
||||
self.$el.append(self._renderOptionalColumnsDropdown());
|
||||
} else {
|
||||
$table.append(this._renderHeader());
|
||||
$table.append(this._renderBody());
|
||||
$table.append(this._renderFooter());
|
||||
}
|
||||
tableWrapper = Object.assign(document.createElement('div'), {
|
||||
className: 'table-responsive',
|
||||
});
|
||||
tableWrapper.appendChild($table[0]);
|
||||
delete this.defs;
|
||||
prom = Promise.all(defs);
|
||||
}
|
||||
|
||||
if (self.selection.length) {
|
||||
var $checked_rows = self.$('tr').filter(function (index, el) {
|
||||
return _.contains(self.selection, $(el).data('id'));
|
||||
});
|
||||
await Promise.all([this._super.apply(this, arguments), prom]);
|
||||
|
||||
this.el.innerHTML = "";
|
||||
this.el.classList.remove('o_list_optional_columns');
|
||||
|
||||
// destroy the previously instantiated pagers, if any
|
||||
oldPagers.forEach(pager => pager.destroy());
|
||||
|
||||
// append the table (if any) to the main element
|
||||
if (tableWrapper) {
|
||||
dom.append(this.el, tableWrapper, {
|
||||
callbacks: [{ widget: this }],
|
||||
in_DOM: document.body.contains(this.el),
|
||||
});
|
||||
if (this.optionalColumns.length) {
|
||||
this.el.classList.add('o_list_optional_columns');
|
||||
this.$('table').append(
|
||||
$('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>')
|
||||
);
|
||||
this.$el.append(this._renderOptionalColumnsDropdown());
|
||||
}
|
||||
if (this.selection.length) {
|
||||
const $checked_rows = this.$('tr').filter(
|
||||
(i, el) => this.selection.includes(el.dataset.id)
|
||||
);
|
||||
$checked_rows.find('.o_list_record_selector input').prop('checked', true);
|
||||
if ($checked_rows.length === self.$('.o_data_row').length) { // all rows are checked
|
||||
self.$('thead .o_list_record_selector input').prop('checked', true);
|
||||
if ($checked_rows.length === this.$('.o_data_row').length) { // all rows are checked
|
||||
this.$('thead .o_list_record_selector input').prop('checked', true);
|
||||
}
|
||||
}
|
||||
});
|
||||
return Promise.all([this._super.apply(this, arguments), prom]);
|
||||
}
|
||||
|
||||
// display the no content helper if necessary
|
||||
if (!this._hasContent() && !!this.noContentHelp) {
|
||||
this.$el.append(this._renderNoContentHelper());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Each line or cell can be decorated according to a few simple rules. The
|
||||
@@ -1228,7 +1238,7 @@ var ListRenderer = BasicRenderer.extend({
|
||||
optionalColumnsEnabled: this.optionalColumnsEnabled,
|
||||
});
|
||||
this._processColumns(this.columnInvisibleFields || {});
|
||||
this._renderView().then(function () {
|
||||
this._render().then(function () {
|
||||
self._onToggleOptionalColumnDropdown(ev);
|
||||
});
|
||||
},
|
||||
@@ -1260,6 +1270,9 @@ var ListRenderer = BasicRenderer.extend({
|
||||
var $tr;
|
||||
var $futureCell;
|
||||
var colIndex;
|
||||
if (this.state.isSample) {
|
||||
return; // we disable keyboard navigation inside the table in "sample" mode
|
||||
}
|
||||
switch (ev.keyCode) {
|
||||
case $.ui.keyCode.LEFT:
|
||||
ev.preventDefault();
|
||||
@@ -1342,7 +1355,7 @@ var ListRenderer = BasicRenderer.extend({
|
||||
offset: currentMinimum - 1,
|
||||
on_success: reloadedGroup => {
|
||||
Object.assign(group, reloadedGroup);
|
||||
this._renderView();
|
||||
this._render();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ odoo.define('web.PivotController', function (require) {
|
||||
init: function (parent, model, renderer, params) {
|
||||
this._super(...arguments);
|
||||
|
||||
this.disableLinking = params.disableLinking;
|
||||
this.measures = params.measures;
|
||||
this.title = params.title;
|
||||
// views to use in the action triggered when a data cell is clicked
|
||||
@@ -273,7 +274,7 @@ odoo.define('web.PivotController', function (require) {
|
||||
_onOpenView: function (ev) {
|
||||
ev.stopPropagation();
|
||||
const cell = ev.data;
|
||||
if (cell.value === undefined || this.initialState.disableLinking) {
|
||||
if (cell.value === undefined || this.disableLinking) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ var PivotModel = AbstractModel.extend({
|
||||
* @param {boolean} [options.raw=false]
|
||||
* @returns {Object}
|
||||
*/
|
||||
get: function (options) {
|
||||
__get: function (options) {
|
||||
options = options || {};
|
||||
var raw = options.raw || false;
|
||||
var groupBys = this._getGroupBys();
|
||||
@@ -546,7 +546,7 @@ var PivotModel = AbstractModel.extend({
|
||||
* @param {Object} params.timeRanges
|
||||
* @returns {Promise}
|
||||
*/
|
||||
load: function (params) {
|
||||
__load: function (params) {
|
||||
this.initialDomain = params.domain;
|
||||
this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys;
|
||||
this.defaultGroupedBy = params.groupedBy;
|
||||
@@ -592,7 +592,7 @@ var PivotModel = AbstractModel.extend({
|
||||
* @param {Object} [params.timeRanges]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: function (handle, params) {
|
||||
__reload: function (handle, params) {
|
||||
var self = this;
|
||||
var oldColGroupBys = this.data.colGroupBys;
|
||||
var oldRowGroupBys = this.data.rowGroupBys;
|
||||
|
||||
@@ -114,9 +114,13 @@ odoo.define('web.PivotView', function (require) {
|
||||
this.loadParams.default_order = params.default_order || this.arch.attrs.default_order;
|
||||
this.loadParams.groupableFields = groupableFields;
|
||||
|
||||
this.rendererParams.widgets = widgets;
|
||||
this.rendererParams.disableLinking = this.arch.attrs.disable_linking;
|
||||
const disableLinking = !!(this.arch.attrs.disable_linking &&
|
||||
JSON.stringify(this.arch.attrs.disable_linking));
|
||||
|
||||
this.rendererParams.widgets = widgets;
|
||||
this.rendererParams.disableLinking = disableLinking;
|
||||
|
||||
this.controllerParams.disableLinking = disableLinking;
|
||||
this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
|
||||
this.controllerParams.measures = measures;
|
||||
|
||||
|
||||
@@ -51,13 +51,13 @@ var Model = AbstractModel.extend({
|
||||
/**
|
||||
* get
|
||||
*/
|
||||
get: function () {
|
||||
__get: function () {
|
||||
return this._state;
|
||||
},
|
||||
/**
|
||||
* load
|
||||
*/
|
||||
load: function (params) {
|
||||
__load: function (params) {
|
||||
_.extend(this._state, _.pick(params, ['viewId', 'modelName', 'domain', 'context']));
|
||||
|
||||
return this._fetch();
|
||||
@@ -65,7 +65,7 @@ var Model = AbstractModel.extend({
|
||||
/**
|
||||
* reload
|
||||
*/
|
||||
reload: function (_id, params) {
|
||||
__reload: function (_id, params) {
|
||||
_.extend(this._state, _.pick(params, ['domain', 'context']));
|
||||
|
||||
return this._fetch();
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
odoo.define('web.SampleServer', function (require) {
|
||||
"use strict";
|
||||
|
||||
const session = require('web.session');
|
||||
const utils = require('web.utils');
|
||||
const Registry = require('web.Registry');
|
||||
|
||||
class UnimplementedRouteError extends Error {}
|
||||
|
||||
/**
|
||||
* Helper function returning the value from a list of sample strings
|
||||
* corresponding to the given ID.
|
||||
* @param {number} id
|
||||
* @param {string[]} sampleTexts
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSampleFromId(id, sampleTexts) {
|
||||
return sampleTexts[(id - 1) % sampleTexts.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function returning a regular expression specifically matching
|
||||
* a given 'term' in a fieldName. For example `fieldNameRegex('abc')`:
|
||||
* will match:
|
||||
* - "abc"
|
||||
* - "field_abc__def"
|
||||
* will not match:
|
||||
* - "aabc"
|
||||
* - "abcd_ef"
|
||||
* @param {...string} term
|
||||
* @returns {RegExp}
|
||||
*/
|
||||
function fieldNameRegex(...terms) {
|
||||
return new RegExp(`\\b((\\w+)?_)?(${terms.join('|')})(_(\\w+)?)?\\b`);
|
||||
}
|
||||
|
||||
const DESCRIPTION_REGEX = fieldNameRegex('description', 'label', 'title', 'subject', 'message');
|
||||
const EMAIL_REGEX = fieldNameRegex('email');
|
||||
const PHONE_REGEX = fieldNameRegex('phone');
|
||||
const URL_REGEX = fieldNameRegex('url');
|
||||
|
||||
/**
|
||||
* Sample server class
|
||||
*
|
||||
* Represents a static instance of the server used when a RPC call sends
|
||||
* empty values/groups while the attribute 'sample' is set to true on the
|
||||
* view.
|
||||
*
|
||||
* This server will generate fake data and send them in the adequate format
|
||||
* according to the route/method used in the RPC.
|
||||
*/
|
||||
class SampleServer {
|
||||
|
||||
/**
|
||||
* @param {string} modelName
|
||||
* @param {Object} fields
|
||||
*/
|
||||
constructor(modelName, fields) {
|
||||
this.mainModel = modelName;
|
||||
this.data = {};
|
||||
this.data[modelName] = {
|
||||
fields,
|
||||
records: [],
|
||||
};
|
||||
// Generate relational fields' co models
|
||||
for (const fieldName in fields) {
|
||||
const field = fields[fieldName];
|
||||
if (['many2one', 'one2many', 'many2many'].includes(field.type)) {
|
||||
this.data[field.relation] = this.data[field.relation] || {
|
||||
fields: {
|
||||
display_name: { type: 'char' },
|
||||
id: { type: 'integer' },
|
||||
color: { type: 'integer' },
|
||||
},
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
// On some models, empty grouped Kanban or List view still contain
|
||||
// real (empty) groups. In this case, we re-use the result of the
|
||||
// web_read_group rpc to tweak sample data s.t. those real groups
|
||||
// contain sample records.
|
||||
this.existingGroups = null;
|
||||
// Sample records generation is only done if necessary, so we delay
|
||||
// it to the first "mockRPC" call. These flags allow us to know if
|
||||
// the records have been generated or not.
|
||||
this.populated = false;
|
||||
this.existingGroupsPopulated = false;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This is the main entry point of the SampleServer. Mocks a request to
|
||||
* the server with sample data.
|
||||
* @param {Object} params
|
||||
* @returns {any} the result obtained with the sample data
|
||||
* @throws {Error} If called on a route/method we do not handle
|
||||
*/
|
||||
mockRpc(params) {
|
||||
if (!(params.model in this.data)) {
|
||||
throw new Error(`SampleServer: unknown model ${params.model}`);
|
||||
}
|
||||
this._populateModels();
|
||||
switch (params.method || params.route) {
|
||||
case '/web/dataset/search_read':
|
||||
return this._mockSearchReadController(params);
|
||||
case 'web_read_group':
|
||||
return this._mockWebReadGroup(params);
|
||||
case 'read_group':
|
||||
return this._mockReadGroup(params);
|
||||
case 'read_progress_bar':
|
||||
return this._mockReadProgressBar(params);
|
||||
case 'read':
|
||||
return this._mockRead(params);
|
||||
}
|
||||
const mock = SampleServer.mockRegistry.get(params.model);
|
||||
const mockFunction = mock && mock[params.method || params.route];
|
||||
if (mockFunction) {
|
||||
return mockFunction.call(this, params);
|
||||
}
|
||||
console.log(`SampleServer: unimplemented route "${params.method || params.route}"`);
|
||||
throw new SampleServer.UnimplementedRouteError();
|
||||
}
|
||||
|
||||
setExistingGroups(groups) {
|
||||
this.existingGroups = groups;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object[]} measures, each measure has the form { fieldName, type }
|
||||
* @param {Object[]} records
|
||||
* @returns {Object}
|
||||
*/
|
||||
_aggregateFields(measures, records) {
|
||||
const values = {};
|
||||
for (const { fieldName, type } of measures) {
|
||||
if (type === 'float' || type === 'integer') {
|
||||
if (records.length) {
|
||||
let value = 0;
|
||||
for (const record of records) {
|
||||
value += record[fieldName];
|
||||
}
|
||||
values[fieldName] = value;
|
||||
} else {
|
||||
values[fieldName] = null;
|
||||
}
|
||||
}
|
||||
if (type === 'many2one') {
|
||||
const ids = new Set(records.map(r => r[fieldName]));
|
||||
values.fieldName = ids.size || null;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} value
|
||||
* @param {Object} options
|
||||
* @param {string} [options.interval]
|
||||
* @param {string} [options.relation]
|
||||
* @param {string} [options.type]
|
||||
* @returns {any}
|
||||
*/
|
||||
_formatValue(value, options) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const { type, interval, relation } = options;
|
||||
if (['date', 'datetime'].includes(type)) {
|
||||
const fmt = SampleServer.FORMATS[interval];
|
||||
return moment(value).format(fmt);
|
||||
} else if (type === 'many2one') {
|
||||
const rec = this.data[relation].records.find(({id}) => id === value);
|
||||
return [value, rec.display_name];
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates field values based on heuristics according to field types
|
||||
* and names.
|
||||
*
|
||||
* @private
|
||||
* @param {string} modelName
|
||||
* @param {string} fieldName
|
||||
* @param {number} id the record id
|
||||
* @returns {any} the field value
|
||||
*/
|
||||
_generateFieldValue(modelName, fieldName, id) {
|
||||
const field = this.data[modelName].fields[fieldName];
|
||||
switch (field.type) {
|
||||
case "boolean":
|
||||
return fieldName === 'active' ? true : Math.random() < 0.5;
|
||||
case "char":
|
||||
case "text":
|
||||
if (["display_name", "name"].includes(fieldName)) {
|
||||
if (SampleServer.PEOPLE_MODELS.includes(modelName)) {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE);
|
||||
} else if (modelName === 'res.country') {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES);
|
||||
}
|
||||
}
|
||||
if (fieldName === 'display_name') {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
|
||||
} else if (["name", "reference"].includes(fieldName)) {
|
||||
return `REF${String(id).padStart(4, '0')}`;
|
||||
} else if (DESCRIPTION_REGEX.test(fieldName)) {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
|
||||
} else if (EMAIL_REGEX.test(fieldName)) {
|
||||
const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE)
|
||||
.replace(/ /, ".")
|
||||
.toLowerCase();
|
||||
return `${emailName}@sample.demo`;
|
||||
} else if (PHONE_REGEX.test(fieldName)) {
|
||||
return `+1 555 754 ${String(id).padStart(4, '0')}`;
|
||||
} else if (URL_REGEX.test(fieldName)) {
|
||||
return `http://sample${id}.com`;
|
||||
}
|
||||
return false;
|
||||
case "date":
|
||||
case "datetime": {
|
||||
const delta = Math.floor((Math.random() - Math.random()) * SampleServer.DATE_DELTA);
|
||||
const date = new moment().add(delta, "hour").format(
|
||||
field.type === "date" ? "YYYY-MM-DD" : "YYYY-MM-DD HH:mm:ss"
|
||||
);
|
||||
return date;
|
||||
}
|
||||
case "float":
|
||||
return Math.random() * SampleServer.MAX_FLOAT;
|
||||
case "integer": {
|
||||
let max = SampleServer.MAX_INTEGER;
|
||||
if (fieldName.includes('color')) {
|
||||
max = Math.random() < 0.5 ? SampleServer.MAX_COLOR_INT : 0;
|
||||
}
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
case "monetary":
|
||||
return Math.floor(Math.random() * SampleServer.MAX_MONETARY);
|
||||
case "many2one":
|
||||
if (field.relation === 'res.currency') {
|
||||
return session.company_currency_id;
|
||||
}
|
||||
return this._getRandomSubRecordId();
|
||||
case "one2many":
|
||||
case "many2many": {
|
||||
const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()];
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
case "selection": {
|
||||
if (field.selection.length > 0) {
|
||||
const index = Math.floor(Math.random() * field.selection.length);
|
||||
return field.selection[index][0];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random id in the range of ids generated for sub models.
|
||||
* @private
|
||||
* @returns {number} id in [1, SUB_RECORDSET_SIZE]
|
||||
*/
|
||||
_getRandomSubRecordId() {
|
||||
return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the read method.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {Array[]} params.args (args[0] is the list of ids, args[1] is
|
||||
* the list of fields)
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_mockRead(params) {
|
||||
const model = this.data[params.model];
|
||||
const ids = params.args[0];
|
||||
const fieldNames = params.args[1];
|
||||
const records = [];
|
||||
for (const r of model.records) {
|
||||
if (!ids.includes(r.id)) {
|
||||
continue;
|
||||
}
|
||||
const record = { id: r.id };
|
||||
for (const fieldName of fieldNames) {
|
||||
const field = model.fields[fieldName];
|
||||
if (!field) {
|
||||
record[fieldName] = false; // unknown field
|
||||
} else if (field.type === 'many2one') {
|
||||
const relModel = this.data[field.relation];
|
||||
const relRecord = relModel.records.find(
|
||||
relR => r[fieldName] === relR.id
|
||||
);
|
||||
record[fieldName] = relRecord ?
|
||||
[relRecord.id, relRecord.display_name] :
|
||||
false;
|
||||
} else {
|
||||
record[fieldName] = r[fieldName];
|
||||
}
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the read_group method.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} [params.fields] defaults to the list of all fields
|
||||
* @param {string[]} params.groupBy
|
||||
* @param {boolean} [params.lazy=true]
|
||||
* @returns {Object[]} Object with keys groups and length
|
||||
*/
|
||||
_mockReadGroup(params) {
|
||||
const lazy = 'lazy' in params ? params.lazy : true;
|
||||
const model = params.model;
|
||||
const fields = this.data[model].fields;
|
||||
const records = this.data[model].records;
|
||||
|
||||
const normalizedGroupBys = [];
|
||||
let groupBy = [];
|
||||
if (params.groupBy.length) {
|
||||
groupBy = lazy ? [params.groupBy[0]] : params.groupBy;
|
||||
}
|
||||
for (const groupBySpec of groupBy) {
|
||||
let [fieldName, interval] = groupBySpec.split(':');
|
||||
const { type, relation } = fields[fieldName];
|
||||
if (type) {
|
||||
const gb = { fieldName, type, interval, relation };
|
||||
normalizedGroupBys.push(gb);
|
||||
}
|
||||
}
|
||||
const groups = utils.groupBy(records, (record) => {
|
||||
const vals = {};
|
||||
for (const gb of normalizedGroupBys) {
|
||||
const { fieldName, type } = gb;
|
||||
let value;
|
||||
if (['date', 'datetime'].includes(type)) {
|
||||
value = this._formatValue(record[fieldName], gb);
|
||||
} else {
|
||||
value = record[fieldName];
|
||||
}
|
||||
vals[fieldName] = value;
|
||||
}
|
||||
return JSON.stringify(vals);
|
||||
});
|
||||
const measures = [];
|
||||
for (const measureSpec of (params.fields || Object.keys(fields))) {
|
||||
const [fieldName, aggregateFunction] = measureSpec.split(':');
|
||||
const { type } = fields[fieldName];
|
||||
if (!params.groupBy.includes(fieldName) && type &&
|
||||
(type !== 'many2one' || aggregateFunction !== 'count_distinct')) {
|
||||
measures.push({ fieldName, type });
|
||||
}
|
||||
}
|
||||
|
||||
const result = [];
|
||||
for (const id in groups) {
|
||||
const records = groups[id];
|
||||
const group = { __domain: [] };
|
||||
let countKey = `__count`;
|
||||
if (normalizedGroupBys.length && lazy) {
|
||||
countKey = `${normalizedGroupBys[0].fieldName}_count`;
|
||||
}
|
||||
group[countKey] = records.length;
|
||||
const firstElem = records[0];
|
||||
for (const gb of normalizedGroupBys) {
|
||||
const { fieldName } = gb;
|
||||
group[fieldName] = this._formatValue(firstElem[fieldName], gb);
|
||||
}
|
||||
Object.assign(group, this._aggregateFields(measures, records));
|
||||
result.push(group);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the read_progress_bar method.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {Object} params.kwargs
|
||||
* @return {Object}
|
||||
*/
|
||||
_mockReadProgressBar(params) {
|
||||
const groupBy = params.kwargs.group_by;
|
||||
const progress_bar = params.kwargs.progress_bar;
|
||||
const groupByField = this.data[params.model].fields[groupBy];
|
||||
const data = {};
|
||||
for (const record of this.data[params.model].records) {
|
||||
let groupByValue = record[groupBy];
|
||||
if (groupByField.type === "many2one") {
|
||||
const relatedRecords = this.data[groupByField.relation].records;
|
||||
const relatedRecord = relatedRecords.find(r => r.id === groupByValue);
|
||||
groupByValue = relatedRecord.display_name;
|
||||
}
|
||||
if (!(groupByValue in data)) {
|
||||
data[groupByValue] = {};
|
||||
for (const key in progress_bar.colors) {
|
||||
data[groupByValue][key] = 0;
|
||||
}
|
||||
}
|
||||
const fieldValue = record[progress_bar.field];
|
||||
if (fieldValue in data[groupByValue]) {
|
||||
data[groupByValue][fieldValue]++;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the /web/dataset/search_read route to return sample
|
||||
* records.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} params.fields
|
||||
* @returns {{ records: Object[], length: number }}
|
||||
*/
|
||||
_mockSearchReadController(params) {
|
||||
const model = this.data[params.model];
|
||||
const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT);
|
||||
const records = this._mockRead({
|
||||
model: params.model,
|
||||
args: [rawRecords.map(r => r.id), params.fields],
|
||||
});
|
||||
return { records, length: records.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the web_read_group method to return groups populated
|
||||
* with sample records. Only handles the case where the real call to
|
||||
* web_read_group returned groups, but none of these groups contain
|
||||
* records. In this case, we keep the real groups, and populate them
|
||||
* with sample records.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {Object} [result] the result of a real call to web_read_group
|
||||
* @returns {{ groups: Object[], length: number }}
|
||||
*/
|
||||
_mockWebReadGroup(params) {
|
||||
let groups;
|
||||
if (this.existingGroups) {
|
||||
this._tweakExistingGroups(params);
|
||||
groups = this.existingGroups;
|
||||
} else {
|
||||
groups = this._mockReadGroup(params);
|
||||
}
|
||||
return {
|
||||
groups,
|
||||
length: groups.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the sample data such that the existing groups (in database)
|
||||
* also exists in the sample, and such that there are sample records in
|
||||
* those groups.
|
||||
* @private
|
||||
* @param {Object[]} groups empty groups returned by the server
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} params.groupBy
|
||||
*/
|
||||
_populateExistingGroups(params) {
|
||||
if (!this.existingGroupsPopulated) {
|
||||
const groups = this.existingGroups;
|
||||
this.groupsInfo = groups;
|
||||
const groupBy = params.groupBy[0];
|
||||
const values = groups.map(g => g[groupBy]);
|
||||
const groupByField = this.data[params.model].fields[groupBy];
|
||||
const groupedByM2O = groupByField.type === 'many2one';
|
||||
if (groupedByM2O) { // re-populate co model with relevant records
|
||||
this.data[groupByField.relation].records = values.map(v => {
|
||||
return { id: v[0], display_name: v[1] };
|
||||
});
|
||||
}
|
||||
for (const r of this.data[params.model].records) {
|
||||
const value = getSampleFromId(r.id, values);
|
||||
r[groupBy] = groupedByM2O ? value[0] : value;
|
||||
}
|
||||
this.existingGroupsPopulated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates sample records for the models in this.data. Records will be
|
||||
* generated once, and subsequent calls to this function will be skipped.
|
||||
* @private
|
||||
*/
|
||||
_populateModels() {
|
||||
if (!this.populated) {
|
||||
for (const modelName in this.data) {
|
||||
const model = this.data[modelName];
|
||||
const fieldNames = Object.keys(model.fields).filter(f => f !== 'id');
|
||||
const size = modelName === this.mainModel ?
|
||||
SampleServer.MAIN_RECORDSET_SIZE :
|
||||
SampleServer.SUB_RECORDSET_SIZE;
|
||||
for (let id = 1; id <= size; id++) {
|
||||
const record = { id };
|
||||
for (const fieldName of fieldNames) {
|
||||
record[fieldName] = this._generateFieldValue(modelName, fieldName, id);
|
||||
}
|
||||
model.records.push(record);
|
||||
}
|
||||
}
|
||||
this.populated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A real (web_)read_group call has been done, and it has returned groups,
|
||||
* but they are all empty. This function updates the sample data such
|
||||
* that those group values exist and those groups contain sample records.
|
||||
* @private
|
||||
* @param {Object[]} groups empty groups returned by the server
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} params.fields
|
||||
* @param {string[]} params.groupBy
|
||||
* @returns {Object[]} groups with count and aggregate values updated
|
||||
*
|
||||
* TODO: rename
|
||||
*/
|
||||
_tweakExistingGroups(params) {
|
||||
const groups = this.existingGroups;
|
||||
this._populateExistingGroups(params);
|
||||
|
||||
// update count and aggregates for each group
|
||||
const groupBy = params.groupBy[0].split(':')[0];
|
||||
const groupByField = this.data[params.model].fields[groupBy];
|
||||
const groupedByM2O = groupByField.type === 'many2one';
|
||||
const records = this.data[params.model].records;
|
||||
for (const g of groups) {
|
||||
const groupValue = groupedByM2O ? g[groupBy][0] : g[groupBy];
|
||||
const recordsInGroup = records.filter(r => r[groupBy] === groupValue);
|
||||
g[`${groupBy}_count`] = recordsInGroup.length;
|
||||
for (const field of params.fields) {
|
||||
const fieldType = this.data[params.model].fields[field].type;
|
||||
if (['integer, float', 'monetary'].includes(fieldType)) {
|
||||
g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0);
|
||||
}
|
||||
}
|
||||
g.__data = {
|
||||
records: this._mockRead({
|
||||
model: params.model,
|
||||
args: [recordsInGroup.map(r => r.id), params.fields],
|
||||
}),
|
||||
length: recordsInGroup.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SampleServer.FORMATS = {
|
||||
day: 'YYYY-MM-DD',
|
||||
week: 'ww YYYY',
|
||||
month: 'MMMM YYYY',
|
||||
quarter: '[Q]Q YYYY',
|
||||
year: 'Y',
|
||||
};
|
||||
|
||||
SampleServer.MAIN_RECORDSET_SIZE = 16;
|
||||
SampleServer.SUB_RECORDSET_SIZE = 5;
|
||||
SampleServer.SEARCH_READ_LIMIT = 10;
|
||||
|
||||
SampleServer.MAX_FLOAT = 100;
|
||||
SampleServer.MAX_INTEGER = 50;
|
||||
SampleServer.MAX_COLOR_INT = 7;
|
||||
SampleServer.MAX_MONETARY = 100000;
|
||||
SampleServer.DATE_DELTA = 24 * 7; // in hours -> 7 days
|
||||
|
||||
SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"];
|
||||
SampleServer.SAMPLE_PEOPLE = [
|
||||
"John Miller", "Henry Campbell", "Carrie Helle", "Wendi Baltz", "Thomas Passot",
|
||||
];
|
||||
SampleServer.SAMPLE_TEXTS = [
|
||||
"Laoreet id", "Volutpat blandit", "Integer vitae", "Viverra nam", "In massa",
|
||||
];
|
||||
SampleServer.PEOPLE_MODELS = [
|
||||
'res.users', 'res.partner', 'hr.employee', 'mail.followers', 'mailing.contact'
|
||||
];
|
||||
|
||||
SampleServer.UnimplementedRouteError = UnimplementedRouteError;
|
||||
|
||||
// mockRegistry allows to register mock version of methods or routes for
|
||||
// specific models, for instance
|
||||
// SampleServer.mockRegistry.registry('res.partner' {
|
||||
// some_method: () => 23,
|
||||
// some_route: () => "abcd",
|
||||
// });
|
||||
SampleServer.mockRegistry = new Registry();
|
||||
|
||||
return SampleServer;
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
.o_list_view {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
.o_list_table {
|
||||
position: relative;
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
// No content helper
|
||||
.o_view_nocontent {
|
||||
@include o-position-absolute(20%, 0, 0, 0);
|
||||
@include o-position-absolute(30%, 0, 0, 0);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
|
||||
@@ -72,3 +72,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_view_sample_data {
|
||||
|
||||
.o_list_table {
|
||||
cursor: default !important;
|
||||
|
||||
& > thead .o_list_record_selector {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_nocontent_help {
|
||||
border-radius: 100%;
|
||||
background-color: #f9f9f9;
|
||||
box-shadow: 0 0 120px 100px #f9f9f9;
|
||||
}
|
||||
|
||||
.o_sample_data_disabled {
|
||||
opacity: 0.33;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,59 @@ QUnit.module('core', {}, function () {
|
||||
|
||||
QUnit.module('utils');
|
||||
|
||||
QUnit.test('groupBy', function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
const { groupBy } = utils;
|
||||
|
||||
// Invalid
|
||||
assert.throws(
|
||||
() => groupBy({}),
|
||||
new TypeError(`list is not iterable`)
|
||||
);
|
||||
assert.throws(
|
||||
() => groupBy([], true),
|
||||
new Error(`Expected criterion of type 'string' or 'function' and got 'boolean'`)
|
||||
);
|
||||
assert.throws(
|
||||
() => groupBy([], 3),
|
||||
new Error(`Expected criterion of type 'string' or 'function' and got 'number'`)
|
||||
);
|
||||
assert.throws(
|
||||
() => groupBy([], {}),
|
||||
new Error(`Expected criterion of type 'string' or 'function' and got 'object'`)
|
||||
);
|
||||
|
||||
// criterion = default
|
||||
assert.deepEqual(
|
||||
groupBy(["a", "b", 1, true]),
|
||||
{
|
||||
1: [1],
|
||||
a: ["a"],
|
||||
b: ["b"],
|
||||
true: [true],
|
||||
}
|
||||
);
|
||||
// criterion = string
|
||||
assert.deepEqual(
|
||||
groupBy([{ x: "a" }, { x: "a" }, { x: "b" }], "x"),
|
||||
{
|
||||
a: [{ x: "a" }, { x: "a" }],
|
||||
b: [{ x: "b" }],
|
||||
}
|
||||
);
|
||||
// criterion = function
|
||||
assert.deepEqual(
|
||||
groupBy(["a", "b", 1, true], (x) => `el${x}`),
|
||||
{
|
||||
ela: ["a"],
|
||||
elb: ["b"],
|
||||
el1: [1],
|
||||
eltrue: [true],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('intersperse', function (assert) {
|
||||
assert.expect(27);
|
||||
|
||||
|
||||
@@ -188,6 +188,10 @@ QUnit.on('OdooAfterTestHook', function () {
|
||||
}
|
||||
}
|
||||
|
||||
for (const tooltip of document.querySelectorAll('.tooltip')) {
|
||||
tooltip.remove();
|
||||
}
|
||||
|
||||
// check for leftovers in #qunit-fixture
|
||||
var qunitFixture = document.getElementById('qunit-fixture');
|
||||
if (qunitFixture.children.length) {
|
||||
|
||||
@@ -282,7 +282,7 @@ odoo.define('web.test_utils_create', function (require) {
|
||||
async function createModel(params) {
|
||||
const widget = new Widget();
|
||||
|
||||
const model = new params.Model(widget);
|
||||
const model = new params.Model(widget, params);
|
||||
|
||||
await testUtilsMock.addMockEnvironment(widget, params);
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
odoo.define('web.abstract_model_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const AbstractModel = require('web.AbstractModel');
|
||||
const Domain = require('web.Domain');
|
||||
|
||||
QUnit.module('Views', {}, function () {
|
||||
QUnit.module('AbstractModel');
|
||||
|
||||
QUnit.test('leave sample mode when unknown route is called on sample server', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const Model = AbstractModel.extend({
|
||||
_isEmpty() {
|
||||
return true;
|
||||
},
|
||||
async __load() {
|
||||
if (this.isSampleModel) {
|
||||
await this._rpc({ model: 'partner', method: 'unknown' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const model = new Model(null, {
|
||||
modelName: 'partner',
|
||||
fields: {},
|
||||
useSampleModel: true,
|
||||
SampleModel: Model,
|
||||
});
|
||||
|
||||
assert.ok(model.useSampleModel);
|
||||
assert.notOk(model._isInSampleMode);
|
||||
|
||||
await model.load({});
|
||||
|
||||
assert.notOk(model.useSampleModel);
|
||||
assert.notOk(model._isInSampleMode);
|
||||
|
||||
model.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("don't cath general error on sample server in sample mode", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const error = new Error();
|
||||
|
||||
const Model = AbstractModel.extend({
|
||||
_isEmpty() {
|
||||
return true;
|
||||
},
|
||||
async __reload() {
|
||||
if (this.isSampleModel) {
|
||||
await this._rpc({ model: 'partner', method: 'read_group' });
|
||||
}
|
||||
},
|
||||
async _rpc() {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const model = new Model(null, {
|
||||
modelName: 'partner',
|
||||
fields: {},
|
||||
useSampleModel: true,
|
||||
SampleModel: Model,
|
||||
});
|
||||
|
||||
assert.ok(model.useSampleModel);
|
||||
assert.notOk(model._isInSampleMode);
|
||||
|
||||
await model.load({});
|
||||
|
||||
assert.ok(model.useSampleModel);
|
||||
assert.ok(model._isInSampleMode);
|
||||
|
||||
async function reloadModel() {
|
||||
try {
|
||||
await model.reload();
|
||||
} catch (e) {
|
||||
assert.strictEqual(e, error);
|
||||
}
|
||||
}
|
||||
|
||||
await reloadModel();
|
||||
|
||||
model.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('fetch sample data: concurrency', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const Model = AbstractModel.extend({
|
||||
_isEmpty() {
|
||||
return true;
|
||||
},
|
||||
__get() {
|
||||
return { isSample: !!this.isSampleModel };
|
||||
},
|
||||
});
|
||||
|
||||
const model = new Model(null, {
|
||||
modelName: 'partner',
|
||||
fields: {},
|
||||
useSampleModel: true,
|
||||
SampleModel: Model,
|
||||
});
|
||||
|
||||
await model.load({ domain: Domain.FALSE_DOMAIN, });
|
||||
|
||||
const beforeReload = model.get(null, { withSampleData: true });
|
||||
|
||||
const reloaded = model.reload(null, { domain: Domain.TRUE_DOMAIN });
|
||||
const duringReload = model.get(null, { withSampleData: true });
|
||||
|
||||
await reloaded;
|
||||
|
||||
const afterReload = model.get(null, { withSampleData: true });
|
||||
|
||||
assert.strictEqual(beforeReload.isSample, true,
|
||||
"Sample data flag must be true before reload"
|
||||
);
|
||||
assert.strictEqual(duringReload.isSample, true,
|
||||
"Sample data flag must be true during reload"
|
||||
);
|
||||
assert.strictEqual(afterReload.isSample, false,
|
||||
"Sample data flag must be true after reload"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1309,7 +1309,7 @@ odoo.define('web.basic_model_tests', function (require) {
|
||||
res_id: 1,
|
||||
};
|
||||
|
||||
model.load(params).then(function (resultID) {
|
||||
await model.load(params).then(function (resultID) {
|
||||
var record = model.get(resultID);
|
||||
assert.ok(record.data.date instanceof moment,
|
||||
"fetched date field should have been formatted");
|
||||
@@ -1317,7 +1317,7 @@ odoo.define('web.basic_model_tests', function (require) {
|
||||
|
||||
params.res_id = 2;
|
||||
|
||||
model.load(params).then(function (resultID) {
|
||||
await model.load(params).then(function (resultID) {
|
||||
var record = model.get(resultID);
|
||||
assert.strictEqual(record.data.date, false,
|
||||
"unset date field should be false");
|
||||
@@ -1340,7 +1340,7 @@ odoo.define('web.basic_model_tests', function (require) {
|
||||
type: 'list',
|
||||
};
|
||||
|
||||
model.load(params).then(function (resultID) {
|
||||
await model.load(params).then(function (resultID) {
|
||||
var record = model.get(resultID);
|
||||
var firstRecord = record.data[0];
|
||||
var secondRecord = record.data[1];
|
||||
@@ -1367,7 +1367,7 @@ odoo.define('web.basic_model_tests', function (require) {
|
||||
type: 'record',
|
||||
};
|
||||
|
||||
model.load(params).then(function (resultID) {
|
||||
await model.load(params).then(function (resultID) {
|
||||
var record = model.get(resultID);
|
||||
assert.strictEqual(record.data.date, false, "date default value should be false");
|
||||
});
|
||||
@@ -1391,7 +1391,7 @@ odoo.define('web.basic_model_tests', function (require) {
|
||||
type: 'record',
|
||||
};
|
||||
|
||||
model.load(params).then(function (resultID) {
|
||||
await model.load(params).then(function (resultID) {
|
||||
var record = model.get(resultID);
|
||||
assert.ok(_.isEqual(record.data.category.res_ids, [12, 14]),
|
||||
"category field should have correct default value");
|
||||
@@ -1958,7 +1958,7 @@ odoo.define('web.basic_model_tests', function (require) {
|
||||
},
|
||||
});
|
||||
|
||||
model.load(this.params);
|
||||
await model.load(this.params);
|
||||
assert.verifySteps(['read'],
|
||||
"there should be only one read");
|
||||
model.destroy();
|
||||
@@ -1996,7 +1996,7 @@ odoo.define('web.basic_model_tests', function (require) {
|
||||
data: this.data,
|
||||
});
|
||||
|
||||
model.load(params).then(function (resultID) {
|
||||
await model.load(params).then(function (resultID) {
|
||||
var record = model.get(resultID);
|
||||
assert.strictEqual(record.data.product_ids.data[0].data.date, false,
|
||||
"date value should be in data, and should be false");
|
||||
|
||||
@@ -783,7 +783,7 @@ QUnit.module('Views', {
|
||||
'<field name="bar"/>' +
|
||||
'</graph>',
|
||||
});
|
||||
graph.renderer.giveFocus();
|
||||
graph._giveFocus();
|
||||
assert.ok(true,"should not generate any error");
|
||||
graph.destroy();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ odoo.define('web.kanban_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractField = require('web.AbstractField');
|
||||
const Domain = require('web.Domain');
|
||||
var fieldRegistry = require('web.field_registry');
|
||||
var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
|
||||
var kanbanExamplesRegistry = require('web.kanban_examples_registry');
|
||||
@@ -4045,6 +4046,576 @@ QUnit.module('Views', {
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty grouped kanban with sample data and no columns', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
this.data.partner.records = [];
|
||||
|
||||
const kanban = await createView({
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<div t-name="kanban-box">
|
||||
<field name="foo"/>
|
||||
</div>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
data: this.data,
|
||||
groupBy: ['product_id'],
|
||||
model: 'partner',
|
||||
View: KanbanView,
|
||||
viewOptions: {
|
||||
action: {
|
||||
help: "No content helper",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsNone(kanban, '.o_view_nocontent');
|
||||
assert.containsOnce(kanban, '.o_quick_create_unfolded');
|
||||
assert.containsOnce(kanban, '.o_kanban_example_background_container');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty grouped kanban with sample data and click quick create', async function (assert) {
|
||||
assert.expect(11);
|
||||
|
||||
const kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="foo"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
groupBy: ['product_id'],
|
||||
async mockRPC(route, { kwargs, method }) {
|
||||
const result = await this._super(...arguments);
|
||||
if (method === 'web_read_group') {
|
||||
// override read_group to return empty groups, as this is
|
||||
// the case for several models (e.g. project.task grouped
|
||||
// by stage_id)
|
||||
result.groups.forEach(group => {
|
||||
group[`${kwargs.groupby[0]}_count`] = 0;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
viewOptions: {
|
||||
action: {
|
||||
help: "No content helper",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(kanban, '.o_kanban_group', 2,
|
||||
"there should be two columns");
|
||||
assert.hasClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsOnce(kanban, '.o_view_nocontent');
|
||||
assert.containsN(kanban, '.o_kanban_record', 16,
|
||||
"there should be 8 sample records by column");
|
||||
|
||||
await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first'));
|
||||
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsNone(kanban, '.o_kanban_record');
|
||||
assert.containsNone(kanban, '.o_view_nocontent');
|
||||
assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create');
|
||||
|
||||
await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle');
|
||||
await testUtils.dom.click(kanban.$('.o_kanban_quick_create button.o_kanban_add'));
|
||||
|
||||
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_record');
|
||||
assert.containsNone(kanban, '.o_view_nocontent');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty grouped kanban with sample data and cancel quick create', async function (assert) {
|
||||
assert.expect(12);
|
||||
|
||||
const kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="foo"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
groupBy: ['product_id'],
|
||||
async mockRPC(route, { kwargs, method }) {
|
||||
const result = await this._super(...arguments);
|
||||
if (method === 'web_read_group') {
|
||||
// override read_group to return empty groups, as this is
|
||||
// the case for several models (e.g. project.task grouped
|
||||
// by stage_id)
|
||||
result.groups.forEach(group => {
|
||||
group[`${kwargs.groupby[0]}_count`] = 0;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
viewOptions: {
|
||||
action: {
|
||||
help: "No content helper",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(kanban, '.o_kanban_group', 2,
|
||||
"there should be two columns");
|
||||
assert.hasClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsOnce(kanban, '.o_view_nocontent');
|
||||
assert.containsN(kanban, '.o_kanban_record', 16,
|
||||
"there should be 8 sample records by column");
|
||||
|
||||
await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first'));
|
||||
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsNone(kanban, '.o_kanban_record');
|
||||
assert.containsNone(kanban, '.o_view_nocontent');
|
||||
assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create');
|
||||
|
||||
await testUtils.dom.click(kanban.$('.o_kanban_view'));
|
||||
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsNone(kanban, '.o_kanban_quick_create');
|
||||
assert.containsNone(kanban, '.o_kanban_record');
|
||||
assert.containsOnce(kanban, '.o_view_nocontent');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty grouped kanban with sample data: keyboard navigation', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const kanban = await createView({
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<div t-name="kanban-box">
|
||||
<field name="foo"/>
|
||||
<field name="state" widget="priority"/>
|
||||
</div>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
data: this.data,
|
||||
groupBy: ['product_id'],
|
||||
model: 'partner',
|
||||
View: KanbanView,
|
||||
async mockRPC(route, { kwargs, method }) {
|
||||
const result = await this._super(...arguments);
|
||||
if (method === 'web_read_group') {
|
||||
result.groups.forEach(g => g.product_id_count = 0);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
// Check keynav is disabled
|
||||
assert.hasClass(
|
||||
kanban.el.querySelector('.o_kanban_record'),
|
||||
'o_sample_data_disabled'
|
||||
);
|
||||
assert.hasClass(
|
||||
kanban.el.querySelector('.o_kanban_toggle_fold'),
|
||||
'o_sample_data_disabled'
|
||||
);
|
||||
assert.containsNone(kanban.renderer, '[tabindex]:not([tabindex="-1"])');
|
||||
|
||||
assert.hasClass(document.activeElement, 'o_searchview_input');
|
||||
|
||||
await testUtils.fields.triggerKeydown(document.activeElement, 'down');
|
||||
|
||||
assert.hasClass(document.activeElement, 'o_searchview_input');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty kanban with sample data', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
this.data.partner.records = [];
|
||||
|
||||
const kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="foo"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
viewOptions: {
|
||||
action: {
|
||||
help: "No content helper",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 10,
|
||||
"there should be 10 sample records");
|
||||
assert.containsOnce(kanban, '.o_view_nocontent');
|
||||
|
||||
await kanban.reload({ domain: [['id', '<', 0]]});
|
||||
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
|
||||
assert.containsOnce(kanban, '.o_view_nocontent');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty grouped kanban with sample data and many2many_tags', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div>
|
||||
<field name="int_field"/>
|
||||
<field name="category_ids" widget="many2many_tags"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
groupBy: ['product_id'],
|
||||
async mockRPC(route, { kwargs, method }) {
|
||||
assert.step(method || route);
|
||||
const result = await this._super(...arguments);
|
||||
if (method === 'web_read_group') {
|
||||
// override read_group to return empty groups, as this is
|
||||
// the case for several models (e.g. project.task grouped
|
||||
// by stage_id)
|
||||
result.groups.forEach(group => {
|
||||
group[`${kwargs.groupby[0]}_count`] = 0;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(kanban, '.o_kanban_group', 2, "there should be 2 'real' columns");
|
||||
assert.hasClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.ok(kanban.$('.o_kanban_record').length >= 1, "there should be sample records");
|
||||
assert.ok(kanban.$('.o_field_many2manytags .o_tag').length >= 1, "there should be tags");
|
||||
|
||||
assert.verifySteps(["web_read_group"], "should not read the tags");
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('sample data does not change after reload with sample data', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="int_field"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
groupBy: ['product_id'],
|
||||
async mockRPC(route, { kwargs, method }) {
|
||||
const result = await this._super(...arguments);
|
||||
if (method === 'web_read_group') {
|
||||
// override read_group to return empty groups, as this is
|
||||
// the case for several models (e.g. project.task grouped
|
||||
// by stage_id)
|
||||
result.groups.forEach(group => {
|
||||
group[`${kwargs.groupby[0]}_count`] = 0;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const columns = kanban.el.querySelectorAll('.o_kanban_group');
|
||||
|
||||
assert.ok(columns.length >= 1, "there should be at least 1 sample column");
|
||||
assert.hasClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsN(kanban, '.o_kanban_record', 16);
|
||||
|
||||
const kanbanText = kanban.el.innerText;
|
||||
await kanban.reload();
|
||||
|
||||
assert.strictEqual(kanbanText, kanban.el.innerText,
|
||||
"the content should be the same after reloading the view");
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('non empty kanban with sample data', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const kanban = await createView({
|
||||
View: KanbanView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div><field name="foo"/></div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
viewOptions: {
|
||||
action: {
|
||||
help: "No content helper",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
|
||||
assert.containsNone(kanban, '.o_view_nocontent');
|
||||
|
||||
await kanban.reload({ domain: [['id', '<', 0]]});
|
||||
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
|
||||
assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty grouped kanban with sample data: add a column', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const kanban = await createView({
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<div t-name="kanban-box">
|
||||
<field name="foo"/>
|
||||
</div>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
data: this.data,
|
||||
groupBy: ['product_id'],
|
||||
model: 'partner',
|
||||
View: KanbanView,
|
||||
async mockRPC(route, { method }) {
|
||||
const result = await this._super(...arguments);
|
||||
if (method === 'web_read_group') {
|
||||
result.groups = this.data.product.records.map(r => {
|
||||
return {
|
||||
product_id: [r.id, r.display_name],
|
||||
product_id_count: 0,
|
||||
__domain: ['product_id', '=', r.id],
|
||||
};
|
||||
});
|
||||
result.length = result.groups.length;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(kanban, 'o_view_sample_data');
|
||||
assert.containsN(kanban, '.o_kanban_group', 2);
|
||||
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
|
||||
|
||||
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column'));
|
||||
await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo");
|
||||
await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add'));
|
||||
|
||||
assert.hasClass(kanban, 'o_view_sample_data');
|
||||
assert.containsN(kanban, '.o_kanban_group', 3);
|
||||
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.skip('empty grouped kanban with sample data: fold/unfold a column', async function (assert) {
|
||||
// folding/unfolding of grouped kanban with sample data is currently disabled
|
||||
assert.expect(8);
|
||||
|
||||
const kanban = await createView({
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<div t-name="kanban-box">
|
||||
<field name="foo"/>
|
||||
</div>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
data: this.data,
|
||||
groupBy: ['product_id'],
|
||||
model: 'partner',
|
||||
View: KanbanView,
|
||||
async mockRPC(route, { kwargs, method }) {
|
||||
const result = await this._super(...arguments);
|
||||
if (method === 'web_read_group') {
|
||||
// override read_group to return a single, empty group
|
||||
result.groups = result.groups.slice(0, 1);
|
||||
result.groups[0][`${kwargs.groupby[0]}_count`] = 0;
|
||||
result.length = 1;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(kanban, 'o_view_sample_data');
|
||||
assert.containsOnce(kanban, '.o_kanban_group');
|
||||
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
|
||||
|
||||
// Fold the column
|
||||
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a'));
|
||||
await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_kanban_toggle_fold'));
|
||||
|
||||
assert.containsOnce(kanban, '.o_kanban_group');
|
||||
assert.hasClass(kanban.$('.o_kanban_group'), 'o_column_folded');
|
||||
|
||||
// Unfold the column
|
||||
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_group.o_column_folded'));
|
||||
|
||||
assert.containsOnce(kanban, '.o_kanban_group');
|
||||
assert.doesNotHaveClass(kanban.$('.o_kanban_group'), 'o_column_folded');
|
||||
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty grouped kanban with sample data: delete a column', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
this.data.partner.records = [];
|
||||
|
||||
let groups = [{
|
||||
product_id: [1, 'New'],
|
||||
product_id_count: 0,
|
||||
__domain: [],
|
||||
}];
|
||||
const kanban = await createView({
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<div t-name="kanban-box">
|
||||
<field name="foo"/>
|
||||
</div>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
data: this.data,
|
||||
groupBy: ['product_id'],
|
||||
model: 'partner',
|
||||
View: KanbanView,
|
||||
async mockRPC(route, { method }) {
|
||||
let result = await this._super(...arguments);
|
||||
if (method === 'web_read_group') {
|
||||
// override read_group to return a single, empty group
|
||||
return {
|
||||
groups,
|
||||
length: groups.length,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(kanban, 'o_view_sample_data');
|
||||
assert.containsOnce(kanban, '.o_kanban_group');
|
||||
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
|
||||
|
||||
// Delete the first column
|
||||
groups = [];
|
||||
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a'));
|
||||
await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_column_delete'));
|
||||
await testUtils.dom.click(document.querySelector('.modal .btn-primary'));
|
||||
|
||||
assert.containsNone(kanban, '.o_kanban_group');
|
||||
assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('empty grouped kanban with sample data: add a column and delete it right away', async function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
const kanban = await createView({
|
||||
arch: `
|
||||
<kanban sample="1">
|
||||
<field name="product_id"/>
|
||||
<templates>
|
||||
<div t-name="kanban-box">
|
||||
<field name="foo"/>
|
||||
</div>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
data: this.data,
|
||||
groupBy: ['product_id'],
|
||||
model: 'partner',
|
||||
View: KanbanView,
|
||||
async mockRPC(route, { method }) {
|
||||
const result = await this._super(...arguments);
|
||||
if (method === 'web_read_group') {
|
||||
result.groups = this.data.product.records.map(r => {
|
||||
return {
|
||||
product_id: [r.id, r.display_name],
|
||||
product_id_count: 0,
|
||||
__domain: ['product_id', '=', r.id],
|
||||
};
|
||||
});
|
||||
result.length = result.groups.length;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(kanban, 'o_view_sample_data');
|
||||
assert.containsN(kanban, '.o_kanban_group', 2);
|
||||
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
|
||||
|
||||
// add a new column
|
||||
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column'));
|
||||
await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo");
|
||||
await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add'));
|
||||
|
||||
assert.hasClass(kanban, 'o_view_sample_data');
|
||||
assert.containsN(kanban, '.o_kanban_group', 3);
|
||||
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
|
||||
|
||||
// delete the column we just created
|
||||
const newColumn = kanban.el.querySelectorAll('.o_kanban_group')[2];
|
||||
await testUtils.dom.click(newColumn.querySelector('.o_kanban_config > a'));
|
||||
await testUtils.dom.click(newColumn.querySelector('.dropdown-item.o_column_delete'));
|
||||
await testUtils.dom.click(document.querySelector('.modal .btn-primary'));
|
||||
|
||||
assert.hasClass(kanban, 'o_view_sample_data');
|
||||
assert.containsN(kanban, '.o_kanban_group', 2);
|
||||
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('bounce create button when no data and click on empty area', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
@@ -4535,7 +5106,6 @@ QUnit.module('Views', {
|
||||
await testUtils.dom.click(kanban.$('.o_column_quick_create'));
|
||||
kanban.$('.o_column_quick_create input').val('new column');
|
||||
await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
|
||||
|
||||
assert.isVisible(kanban.$buttons.find('.o-kanban-button-new'),
|
||||
"Create button should now be visible");
|
||||
kanban.destroy();
|
||||
|
||||
@@ -5,6 +5,7 @@ var AbstractFieldOwl = require('web.AbstractFieldOwl');
|
||||
var AbstractStorageService = require('web.AbstractStorageService');
|
||||
var BasicModel = require('web.BasicModel');
|
||||
var core = require('web.core');
|
||||
const Domain = require('web.Domain')
|
||||
var basicFields = require('web.basic_fields');
|
||||
var fieldRegistry = require('web.field_registry');
|
||||
var fieldRegistryOwl = require('web.field_registry_owl');
|
||||
@@ -3764,6 +3765,386 @@ QUnit.module('Views', {
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("empty list with sample data", async function (assert) {
|
||||
assert.expect(19);
|
||||
|
||||
const list = await createView({
|
||||
View: ListView,
|
||||
model: 'foo',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<tree sample="1">
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
<field name="int_field"/>
|
||||
<field name="m2o"/>
|
||||
<field name="m2m" widget="many2many_tags"/>
|
||||
<field name="date"/>
|
||||
<field name="datetime"/>
|
||||
</tree>`,
|
||||
domain: [['id', '<', 0]], // such that no record matches the domain
|
||||
viewOptions: {
|
||||
action: {
|
||||
help: '<p class="hello">click to add a partner</p>'
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(list.$el, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsN(list, '.o_data_row', 10);
|
||||
assert.containsOnce(list, '.o_nocontent_help .hello');
|
||||
|
||||
// Check list sample data
|
||||
const firstRow = list.el.querySelector('.o_data_row');
|
||||
const cells = firstRow.querySelectorAll(':scope > .o_data_cell');
|
||||
assert.strictEqual(cells[0].innerText.trim(), "",
|
||||
"Char field should yield an empty element"
|
||||
);
|
||||
assert.containsOnce(cells[1], '.custom-checkbox',
|
||||
"Boolean field has been instantiated"
|
||||
);
|
||||
assert.notOk(isNaN(cells[2].innerText.trim()), "Intger value is a number");
|
||||
assert.ok(cells[3].innerText.trim(), "Many2one field is a string");
|
||||
|
||||
const firstM2MTag = cells[4].querySelector(
|
||||
':scope span.o_badge_text'
|
||||
).innerText.trim();
|
||||
assert.ok(firstM2MTag.length > 0, "Many2many contains at least one string tag");
|
||||
|
||||
assert.ok(/\d{2}\/\d{2}\/\d{4}/.test(cells[5].innerText.trim()),
|
||||
"Date field should have the right format"
|
||||
);
|
||||
assert.ok(/\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}/.test(cells[6].innerText.trim()),
|
||||
"Datetime field should have the right format"
|
||||
);
|
||||
|
||||
const textContent = list.$el.text();
|
||||
await list.reload();
|
||||
assert.strictEqual(textContent, list.$el.text(),
|
||||
'The content should be the same after reloading the view without change'
|
||||
);
|
||||
|
||||
// reload with another domain -> should no longer display the sample records
|
||||
await list.reload({ domain: Domain.FALSE_DOMAIN });
|
||||
|
||||
assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
|
||||
assert.containsNone(list, '.o_list_table');
|
||||
assert.containsOnce(list, '.o_nocontent_help .hello');
|
||||
|
||||
// reload with another domain matching records
|
||||
await list.reload({ domain: Domain.TRUE_DOMAIN });
|
||||
|
||||
assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsN(list, '.o_data_row', 4);
|
||||
assert.containsNone(list, '.o_nocontent_help .hello');
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("empty list with sample data: toggle optional field", async function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
const RamStorageService = AbstractStorageService.extend({
|
||||
storage: new RamStorage(),
|
||||
});
|
||||
|
||||
const list = await createView({
|
||||
View: ListView,
|
||||
model: 'foo',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<tree sample="1">
|
||||
<field name="foo"/>
|
||||
<field name="m2o" optional="hide"/>
|
||||
</tree>`,
|
||||
domain: Domain.FALSE_DOMAIN,
|
||||
services: {
|
||||
local_storage: RamStorageService,
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(list.$el, 'o_view_sample_data');
|
||||
assert.ok(list.$('.o_data_row').length > 0);
|
||||
assert.hasClass(list.el.querySelector('.o_data_row'), 'o_sample_data_disabled');
|
||||
assert.containsN(list, 'th', 2, "should have 2 th, 1 for selector and 1 for foo");
|
||||
assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle');
|
||||
|
||||
await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
|
||||
await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
|
||||
|
||||
assert.hasClass(list.$el, 'o_view_sample_data');
|
||||
assert.ok(list.$('.o_data_row').length > 0);
|
||||
assert.hasClass(list.el.querySelector('.o_data_row'), 'o_sample_data_disabled');
|
||||
assert.containsN(list, 'th', 3);
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("empty list with sample data: keyboard navigation", async function (assert) {
|
||||
assert.expect(11);
|
||||
|
||||
const list = await createView({
|
||||
arch: `
|
||||
<tree sample="1">
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
<field name="int_field"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
domain: Domain.FALSE_DOMAIN,
|
||||
model: 'foo',
|
||||
View: ListView,
|
||||
});
|
||||
|
||||
// Check keynav is disabled
|
||||
assert.hasClass(
|
||||
list.el.querySelector('.o_data_row'),
|
||||
'o_sample_data_disabled'
|
||||
);
|
||||
assert.hasClass(
|
||||
list.el.querySelector('.o_list_table > tfoot'),
|
||||
'o_sample_data_disabled'
|
||||
);
|
||||
assert.hasClass(
|
||||
list.el.querySelector('.o_list_table > thead .o_list_record_selector'),
|
||||
'o_sample_data_disabled'
|
||||
);
|
||||
assert.containsNone(list.renderer, 'input:not([tabindex="-1"])');
|
||||
|
||||
// From search bar
|
||||
assert.hasClass(document.activeElement, 'o_searchview_input');
|
||||
|
||||
await testUtils.fields.triggerKeydown(document.activeElement, 'down');
|
||||
|
||||
assert.hasClass(document.activeElement, 'o_searchview_input');
|
||||
|
||||
// From 'Create' button
|
||||
document.querySelector('.btn.o_list_button_add').focus();
|
||||
|
||||
assert.hasClass(document.activeElement, 'o_list_button_add');
|
||||
|
||||
await testUtils.fields.triggerKeydown(document.activeElement, 'down');
|
||||
|
||||
assert.hasClass(document.activeElement, 'o_list_button_add');
|
||||
|
||||
await testUtils.fields.triggerKeydown(document.activeElement, 'tab');
|
||||
|
||||
assert.containsNone(document.body, '.oe_tooltip_string');
|
||||
|
||||
// From column header
|
||||
list.el.querySelector(':scope th[data-name="foo"]').focus();
|
||||
|
||||
assert.ok(document.activeElement.dataset.name === 'foo');
|
||||
|
||||
await testUtils.fields.triggerKeydown(document.activeElement, 'down');
|
||||
|
||||
assert.ok(document.activeElement.dataset.name === 'foo');
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("non empty list with sample data", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const list = await createView({
|
||||
View: ListView,
|
||||
model: 'foo',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<tree sample="1">
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
<field name="int_field"/>
|
||||
</tree>`,
|
||||
domain: Domain.TRUE_DOMAIN,
|
||||
});
|
||||
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsN(list, '.o_data_row', 4);
|
||||
assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
|
||||
|
||||
// reload with another domain matching no record (should not display the sample records)
|
||||
await list.reload({ domain: Domain.FALSE_DOMAIN });
|
||||
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsNone(list, '.o_data_row');
|
||||
assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('click on header in empty list with sample data', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const list = await createView({
|
||||
View: ListView,
|
||||
model: 'foo',
|
||||
data: this.data,
|
||||
arch: `
|
||||
<tree sample="1">
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
<field name="int_field"/>
|
||||
</tree>`,
|
||||
domain: Domain.FALSE_DOMAIN,
|
||||
});
|
||||
|
||||
assert.hasClass(list, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsN(list, '.o_data_row', 10);
|
||||
|
||||
const content = list.$el.text();
|
||||
await testUtils.dom.click(list.$('tr:first .o_column_sortable:first'));
|
||||
assert.strictEqual(list.$el.text(), content, "the content should still be the same");
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("non empty editable list with sample data: delete all records", async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
const list = await createView({
|
||||
arch: `
|
||||
<tree editable="top" sample="1">
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
<field name="int_field"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
domain: Domain.TRUE_DOMAIN,
|
||||
model: 'foo',
|
||||
View: ListView,
|
||||
viewOptions: {
|
||||
action: {
|
||||
help: '<p class="hello">click to add a partner</p>'
|
||||
},
|
||||
hasActionMenus: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Initial state: all records displayed
|
||||
assert.doesNotHaveClass(list, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsN(list, '.o_data_row', 4);
|
||||
assert.containsNone(list, '.o_nocontent_help');
|
||||
|
||||
// Delete all records
|
||||
await testUtils.dom.click(list.el.querySelector('thead .o_list_record_selector input'));
|
||||
await cpHelpers.toggleActionMenu(list);
|
||||
await cpHelpers.toggleMenuItem(list, "Delete");
|
||||
await testUtils.dom.click($('.modal-footer .btn-primary'));
|
||||
|
||||
// Final state: no more sample data, but nocontent helper displayed
|
||||
assert.doesNotHaveClass(list, 'o_view_sample_data');
|
||||
assert.containsNone(list, '.o_list_table');
|
||||
assert.containsOnce(list, '.o_nocontent_help');
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("empty editable list with sample data: start create record and cancel", async function (assert) {
|
||||
assert.expect(10);
|
||||
|
||||
const list = await createView({
|
||||
arch: `
|
||||
<tree editable="top" sample="1">
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
<field name="int_field"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
domain: Domain.FALSE_DOMAIN,
|
||||
model: 'foo',
|
||||
View: ListView,
|
||||
viewOptions: {
|
||||
action: {
|
||||
help: '<p class="hello">click to add a partner</p>'
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Initial state: sample data and nocontent helper displayed
|
||||
assert.hasClass(list, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsN(list, '.o_data_row', 10);
|
||||
assert.containsOnce(list, '.o_nocontent_help');
|
||||
|
||||
// Start creating a record
|
||||
await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
|
||||
|
||||
assert.doesNotHaveClass(list, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_data_row');
|
||||
|
||||
// Discard temporary record
|
||||
await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_discard'));
|
||||
|
||||
// Final state: table should be displayed with no data at all
|
||||
assert.doesNotHaveClass(list, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsNone(list, '.o_data_row');
|
||||
assert.containsNone(list, '.o_nocontent_help');
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("empty editable list with sample data: create and delete record", async function (assert) {
|
||||
assert.expect(13);
|
||||
|
||||
const list = await createView({
|
||||
arch: `
|
||||
<tree editable="top" sample="1">
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
<field name="int_field"/>
|
||||
</tree>`,
|
||||
data: this.data,
|
||||
domain: Domain.FALSE_DOMAIN,
|
||||
model: 'foo',
|
||||
View: ListView,
|
||||
viewOptions: {
|
||||
action: {
|
||||
help: '<p class="hello">click to add a partner</p>'
|
||||
},
|
||||
hasActionMenus: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Initial state: sample data and nocontent helper displayed
|
||||
assert.hasClass(list, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsN(list, '.o_data_row', 10);
|
||||
assert.containsOnce(list, '.o_nocontent_help');
|
||||
|
||||
// Start creating a record
|
||||
await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
|
||||
|
||||
assert.doesNotHaveClass(list, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_data_row');
|
||||
|
||||
// Save temporary record
|
||||
await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_save'));
|
||||
|
||||
assert.doesNotHaveClass(list, 'o_view_sample_data');
|
||||
assert.containsOnce(list, '.o_list_table');
|
||||
assert.containsOnce(list, '.o_data_row');
|
||||
assert.containsNone(list, '.o_nocontent_help');
|
||||
|
||||
// Delete newly created record
|
||||
await testUtils.dom.click(list.el.querySelector('.o_data_row input'));
|
||||
await cpHelpers.toggleActionMenu(list);
|
||||
await cpHelpers.toggleMenuItem(list, "Delete");
|
||||
await testUtils.dom.click($('.modal-footer .btn-primary'));
|
||||
|
||||
// Final state: there should be no table, but the no content helper
|
||||
assert.doesNotHaveClass(list, 'o_view_sample_data');
|
||||
assert.containsNone(list, '.o_list_table');
|
||||
assert.containsOnce(list, '.o_nocontent_help');
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('groupby node with a button', async function (assert) {
|
||||
assert.expect(14);
|
||||
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
odoo.define('web.sample_server_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const SampleServer = require('web.SampleServer');
|
||||
const session = require('web.session');
|
||||
const { mock } = require('web.test_utils');
|
||||
|
||||
const {
|
||||
MAIN_RECORDSET_SIZE, SEARCH_READ_LIMIT, // Limits
|
||||
SAMPLE_COUNTRIES, SAMPLE_PEOPLE, SAMPLE_TEXTS, // Text values
|
||||
MAX_COLOR_INT, MAX_FLOAT, MAX_INTEGER, MAX_MONETARY // Number values
|
||||
} = SampleServer;
|
||||
|
||||
QUnit.module("Sample Server", {
|
||||
beforeEach() {
|
||||
this.fields = {
|
||||
'res.users': {
|
||||
display_name: { string: "Name", type: 'char' },
|
||||
name: { string: "Reference", type: 'char' },
|
||||
email: { string: "Email", type: 'char' },
|
||||
phone_number: { string: "Phone number", type: 'char' },
|
||||
brol_machin_url_truc: { string: "URL", type: 'char' },
|
||||
urlemailphone: { string: "Whatever", type: 'char' },
|
||||
active: { string: "Active", type: 'boolean' },
|
||||
is_alive: { string: "Is alive", type: 'boolean' },
|
||||
description: { string: "Description", type: 'text' },
|
||||
birthday: { string: "Birthday", type: 'date' },
|
||||
arrival_date: { string: "Date of arrival", type: 'datetime' },
|
||||
height: { string: "Height", type: 'float' },
|
||||
color: { string: "Color", type: 'integer' },
|
||||
age: { string: "Age", type: 'integer' },
|
||||
salary: { string: "Salary", type: 'monetary' },
|
||||
currency: { string: "Currency", type: 'many2one', relation: 'res.currency' },
|
||||
manager_id: { string: "Manager", type: 'many2one', relation: 'res.users' },
|
||||
managed_ids: { string: "Managing", type: 'one2many', relation: 'res.users' },
|
||||
tag_ids: { string: "Tags", type: 'many2many', relation: 'tag' },
|
||||
type: { string: "Type", type: 'selection', selection: [
|
||||
['client', "Client"], ['partner', "Partner"], ['employee', "Employee"]
|
||||
] },
|
||||
},
|
||||
'res.country': {
|
||||
display_name: { string: "Name", type: 'char' },
|
||||
},
|
||||
'hobbit': {
|
||||
display_name: { string: "Name", type: 'char' },
|
||||
profession: { string: "Profession", type: 'selection', selection: [
|
||||
['gardener', "Gardener"], ['brewer', "Brewer"], ['adventurer', "Adventurer"]
|
||||
] },
|
||||
age: { string: "Age", type: 'integer' },
|
||||
},
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.module("Basic behaviour");
|
||||
|
||||
QUnit.test("Sample data: people type + all field names", async function (assert) {
|
||||
assert.expect(24);
|
||||
|
||||
mock.patch(session, {
|
||||
company_currency_id: 4,
|
||||
});
|
||||
|
||||
const allFieldNames = Object.keys(this.fields['res.users']);
|
||||
const server = new SampleServer('res.users', this.fields['res.users']);
|
||||
const { records } = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'res.users',
|
||||
fields: allFieldNames,
|
||||
});
|
||||
const rec = records[0];
|
||||
|
||||
function assertFormat(fieldName, regex) {
|
||||
if (regex instanceof RegExp) {
|
||||
assert.ok(
|
||||
regex.test(rec[fieldName].toString()),
|
||||
`Field "${fieldName}" has the correct format`
|
||||
);
|
||||
} else {
|
||||
assert.strictEqual(
|
||||
typeof rec[fieldName], regex,
|
||||
`Field "${fieldName}" is of type ${regex}`
|
||||
);
|
||||
}
|
||||
}
|
||||
function assertBetween(fieldName, min, max, decimal = 1) {
|
||||
assert.ok(
|
||||
min <= rec[fieldName] && rec[fieldName] < max &&
|
||||
rec[fieldName].toString().split(".").length === decimal,
|
||||
`Field "${fieldName}" is between ${min} and ${max} and is ${
|
||||
decimal === 1 ? "an integer" : "a float number"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// Basic fields
|
||||
assert.ok(SAMPLE_PEOPLE.includes(rec.display_name));
|
||||
assert.ok(SAMPLE_PEOPLE.includes(rec.name));
|
||||
assert.strictEqual(rec.email,
|
||||
`${rec.display_name.replace(/ /, ".").toLowerCase()}@sample.demo`
|
||||
);
|
||||
assertFormat('phone_number', /\+1 555 754 000\d/);
|
||||
assertFormat('brol_machin_url_truc', /http:\/\/sample\d\.com/);
|
||||
assert.strictEqual(rec.urlemailphone, false);
|
||||
assert.strictEqual(rec.active, true);
|
||||
assertFormat('is_alive', 'boolean');
|
||||
assert.ok(SAMPLE_TEXTS.includes(rec.description));
|
||||
assertFormat('birthday', /\d{4}-\d{2}-\d{2}/);
|
||||
assertFormat('arrival_date', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
|
||||
assertBetween('height', 0, MAX_FLOAT, 2);
|
||||
assertBetween('color', 0, MAX_COLOR_INT);
|
||||
assertBetween('age', 0, MAX_INTEGER);
|
||||
assertBetween('salary', 0, MAX_MONETARY);
|
||||
|
||||
const selectionValues = this.fields['res.users'].type.selection.map(
|
||||
(sel) => sel[0]
|
||||
);
|
||||
assert.ok(selectionValues.includes(rec.type));
|
||||
|
||||
// Relational fields
|
||||
assert.strictEqual(rec.currency[0], 4);
|
||||
// Currently we expect the currency name to be a latin string, which
|
||||
// is not important; in most case we only need the ID. The following
|
||||
// assertion can be removed if needed.
|
||||
assert.ok(SAMPLE_TEXTS.includes(rec.currency[1]));
|
||||
|
||||
assert.strictEqual(typeof rec.manager_id[0], 'number');
|
||||
assert.ok(SAMPLE_PEOPLE.includes(rec.manager_id[1]));
|
||||
|
||||
assert.ok([1, 2].includes(rec.managed_ids.length));
|
||||
assert.ok(rec.managed_ids.every(
|
||||
(id) => typeof id === 'number')
|
||||
);
|
||||
|
||||
assert.ok([1, 2].includes(rec.tag_ids.length));
|
||||
assert.ok(rec.tag_ids.every(
|
||||
(id) => typeof id === 'number')
|
||||
);
|
||||
|
||||
mock.unpatch(session);
|
||||
});
|
||||
|
||||
QUnit.test("Sample data: country type", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new SampleServer('res.country', this.fields['res.country']);
|
||||
const { records } = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'res.country',
|
||||
fields: ['display_name'],
|
||||
});
|
||||
|
||||
assert.ok(SAMPLE_COUNTRIES.includes(records[0].display_name));
|
||||
});
|
||||
|
||||
QUnit.test("Sample data: any type", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const { records } = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'hobbit',
|
||||
fields: ['display_name'],
|
||||
});
|
||||
|
||||
assert.ok(SAMPLE_TEXTS.includes(records[0].display_name));
|
||||
});
|
||||
|
||||
QUnit.module("RPC calls");
|
||||
|
||||
QUnit.test("Send 'search_read' RPC: valid field names", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'hobbit',
|
||||
fields: ['display_name'],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(result.records[0]),
|
||||
['id', 'display_name']
|
||||
);
|
||||
assert.strictEqual(result.length, SEARCH_READ_LIMIT);
|
||||
assert.ok(/\w+/.test(result.records[0].display_name),
|
||||
"Display name has been mocked"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'search_read' RPC: invalid field names", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'hobbit',
|
||||
fields: ['name'],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(result.records[0]),
|
||||
['id', 'name']
|
||||
);
|
||||
assert.strictEqual(result.length, SEARCH_READ_LIMIT);
|
||||
assert.strictEqual(result.records[0].name, false,
|
||||
`Field "name" doesn't exist => returns false`
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'web_read_group' RPC: no group", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
server.setExistingGroups([]);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'web_read_group',
|
||||
model: 'hobbit',
|
||||
groupBy: ['profession'],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { groups: [], length: 0 });
|
||||
});
|
||||
|
||||
QUnit.test("Send 'web_read_group' RPC: 2 groups", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
const existingGroups = [
|
||||
{ profession: 'gardener', profession_count: 0 },
|
||||
{ profession: 'adventurer', profession_count: 0 },
|
||||
];
|
||||
server.setExistingGroups(existingGroups);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'web_read_group',
|
||||
model: 'hobbit',
|
||||
groupBy: ['profession'],
|
||||
fields: [],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 2);
|
||||
assert.strictEqual(result.groups.length, 2);
|
||||
|
||||
const professions = result.groups.map((g) => g.profession);
|
||||
assert.ok(professions.includes("gardener"));
|
||||
assert.ok(professions.includes("adventurer"));
|
||||
|
||||
assert.strictEqual(
|
||||
result.groups.reduce((acc, g) => acc + g.profession_count, 0),
|
||||
MAIN_RECORDSET_SIZE
|
||||
);
|
||||
assert.ok(
|
||||
result.groups.every((g) => g.profession_count === g.__data.length)
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'web_read_group' RPC: all groups", async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
const existingGroups = [
|
||||
{ profession: 'gardener', profession_count: 0 },
|
||||
{ profession: 'brewer', profession_count: 0 },
|
||||
{ profession: 'adventurer', profession_count: 0 },
|
||||
];
|
||||
server.setExistingGroups(existingGroups);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'web_read_group',
|
||||
model: 'hobbit',
|
||||
groupBy: ['profession'],
|
||||
fields: [],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 3);
|
||||
assert.strictEqual(result.groups.length, 3);
|
||||
|
||||
const professions = result.groups.map((g) => g.profession);
|
||||
assert.ok(professions.includes("gardener"));
|
||||
assert.ok(professions.includes("brewer"));
|
||||
assert.ok(professions.includes("adventurer"));
|
||||
|
||||
assert.strictEqual(
|
||||
result.groups.reduce((acc, g) => acc + g.profession_count, 0),
|
||||
MAIN_RECORDSET_SIZE
|
||||
);
|
||||
assert.ok(
|
||||
result.groups.every((g) => g.profession_count === g.__data.length)
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: no group", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: [],
|
||||
groupBy: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [{
|
||||
__count: MAIN_RECORDSET_SIZE,
|
||||
__domain: [],
|
||||
}]);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: groupBy", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: [],
|
||||
groupBy: ['profession'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 3);
|
||||
|
||||
const professions = result.map((g) => g.profession);
|
||||
assert.ok(professions.includes("gardener"));
|
||||
assert.ok(professions.includes("brewer"));
|
||||
assert.ok(professions.includes("adventurer"));
|
||||
|
||||
assert.strictEqual(
|
||||
result.reduce((acc, g) => acc + g.profession_count, 0),
|
||||
MAIN_RECORDSET_SIZE,
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: groupBy and field", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: ['age'],
|
||||
groupBy: ['profession'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 3);
|
||||
|
||||
const professions = result.map((g) => g.profession);
|
||||
assert.ok(professions.includes("gardener"));
|
||||
assert.ok(professions.includes("brewer"));
|
||||
assert.ok(professions.includes("adventurer"));
|
||||
|
||||
assert.strictEqual(
|
||||
result.reduce((acc, g) => acc + g.profession_count, 0),
|
||||
MAIN_RECORDSET_SIZE,
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.reduce((acc, g) => acc + g.age, 0),
|
||||
server.data.hobbit.records.reduce((acc, g) => acc + g.age, 0)
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: multiple groupBys and lazy", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: [],
|
||||
groupBy: ['profession', 'age'],
|
||||
});
|
||||
|
||||
assert.ok('profession' in result[0]);
|
||||
assert.notOk('age' in result[0]);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: multiple groupBys and not lazy", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: [],
|
||||
groupBy: ['profession', 'age'],
|
||||
lazy: false,
|
||||
});
|
||||
|
||||
assert.ok('profession' in result[0]);
|
||||
assert.ok('age' in result[0]);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read' RPC: no id", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read',
|
||||
model: 'hobbit',
|
||||
args: [
|
||||
[], ['display_name']
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read' RPC: one id", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read',
|
||||
model: 'hobbit',
|
||||
args: [
|
||||
[1], ['display_name']
|
||||
],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.ok(
|
||||
/\w+/.test(result[0].display_name),
|
||||
"Display name has been mocked"
|
||||
);
|
||||
assert.strictEqual(result[0].id, 1);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read' RPC: more than all available ids", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new SampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const amount = MAIN_RECORDSET_SIZE + 3;
|
||||
const ids = new Array(amount).fill().map((_, i) => i + 1);
|
||||
const result = await server.mockRpc({
|
||||
method: 'read',
|
||||
model: 'hobbit',
|
||||
args: [
|
||||
ids, ['display_name']
|
||||
],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, MAIN_RECORDSET_SIZE);
|
||||
});
|
||||
|
||||
// To be implemented if needed
|
||||
// QUnit.test("Send 'read_progress_bar' RPC", async function (assert) { ... });
|
||||
});
|
||||
});
|
||||
@@ -337,6 +337,7 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/views/file_upload_mixin.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/file_upload_progress_bar.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/file_upload_progress_card.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/sample_server.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/select_create_controllers_registry.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/signature_dialog.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/standalone_field_manager_mixin.js"></script>
|
||||
@@ -701,6 +702,7 @@
|
||||
<script type="text/javascript" src="/web/static/tests/fields/special_fields_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/fields/upgrade_fields_tests.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/web/static/tests/views/sample_server_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/abstract_controller_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/abstract_view_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/form_tests.js"></script>
|
||||
@@ -710,6 +712,7 @@
|
||||
<script type="text/javascript" src="/web/static/tests/views/kanban_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/calendar_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/qweb_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/abstract_model_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/basic_model_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/abstract_view_banner_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/kanban_model_tests.js"></script>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<field name="name">website.visitor.view.kanban</field>
|
||||
<field name="model">website.visitor</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_wvisitor_kanban">
|
||||
<kanban class="o_wvisitor_kanban" sample="1">
|
||||
<field name="id"/>
|
||||
<field name="country_id"/>
|
||||
<field name="email"/>
|
||||
@@ -225,7 +225,7 @@
|
||||
<field name="name">website.visitor.view.tree</field>
|
||||
<field name="model">website.visitor</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Web Visitors" decoration-success="is_connected" decoration-danger="not is_connected">
|
||||
<tree string="Web Visitors" decoration-success="is_connected" decoration-danger="not is_connected" sample="1">
|
||||
<!--TODO DBE : Handle no_label in treeview-->
|
||||
<field name="country_flag" widget="image" options='{"size": [20, 20]}' string=" "/>
|
||||
<field name="display_name"/>
|
||||
@@ -294,7 +294,7 @@
|
||||
<field name="name">website.track.view.tree</field>
|
||||
<field name="model">website.track</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Visitor Views History" create="0" edit="0">
|
||||
<tree string="Visitor Views History" create="0" edit="0" sample="1">
|
||||
<field name="visitor_id"/>
|
||||
<field name="page_id"/>
|
||||
<field name="url"/>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<field name="name">blog.post.list</field>
|
||||
<field name="model">blog.post</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Blog Posts" multi_edit="1">
|
||||
<tree string="Blog Posts" multi_edit="1" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="author_id"/>
|
||||
@@ -121,7 +121,7 @@
|
||||
<field name="name">blog.post.kanban</field>
|
||||
<field name="model">blog.post</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="blog_id"/>
|
||||
<field name="author_id"/>
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
<field name="name">slide.channel.view.tree</field>
|
||||
<field name="model">slide.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Courses">
|
||||
<tree string="Courses" sample="1">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="channel_type"/>
|
||||
@@ -178,7 +178,7 @@
|
||||
<field name="model">slide.channel</field>
|
||||
<field name="priority">20</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Courses" create="false" default_order="total_views desc">
|
||||
<tree string="Courses" create="false" default_order="total_views desc" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="total_views"/>
|
||||
<field name="members_count"/>
|
||||
@@ -214,7 +214,7 @@
|
||||
<field name="name">slide.channel.view.kanban</field>
|
||||
<field name="model">slide.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban string="eLearning Overview" class="o_emphasize_colors o_kanban_dashboard o_slide_kanban breadcrumb_item active" edit="false">
|
||||
<kanban string="eLearning Overview" class="o_emphasize_colors o_kanban_dashboard o_slide_kanban breadcrumb_item active" edit="false" sample="1">
|
||||
<field name="color"/>
|
||||
<field name="website_published"/>
|
||||
<templates>
|
||||
|
||||
@@ -169,7 +169,8 @@
|
||||
<field name="arch" type="xml">
|
||||
<kanban edit="false" group_create="0"
|
||||
records_draggable="0"
|
||||
class="o_slide_kanban">
|
||||
class="o_slide_kanban"
|
||||
sample="1">
|
||||
<field name="id"/>
|
||||
<field name="channel_id"/>
|
||||
<field name="slide_type"/>
|
||||
@@ -238,7 +239,7 @@
|
||||
<field name="name">slide.slide.tree</field>
|
||||
<field name="model">slide.slide</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Contents">
|
||||
<tree string="Contents" sample="1">
|
||||
<field name="name"/>
|
||||
<field name="website_id" groups="website.group_multi_website"/>
|
||||
<field name="active" invisible="1"/>
|
||||
|
||||
@@ -121,6 +121,18 @@ an impact on all view types.
|
||||
``info``, ``warning``, ``danger`` and ``secondary`` displays. The list view supports ``bf``,
|
||||
``it``, ``success``, ``info``, ``warning``, ``danger``, ``muted`` and ``primary`` displays.
|
||||
|
||||
* ``sample`` (``kanban`` & ``list`` & ``gantt``)
|
||||
|
||||
Populate the view with a set of sample records if none are found for the current model.
|
||||
This attribute is false by default.
|
||||
|
||||
These fake records will have heuristics for certain field names/models. For example,
|
||||
a field 'display_name' on the model 'res.users' will be populated with sample people names
|
||||
while an 'email' field will be in the form 'firstname.lastname@sample.demo'.
|
||||
|
||||
The user will not be able to interact with these data and they will be discarded as soon as
|
||||
an action is performed (record created, column added, etc.)
|
||||
|
||||
* ``banner_route``
|
||||
a route address to be fetched and prepended to the view.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user