Initial commit: Odoo 18.0-20251222 extra-addons
This commit is contained in:
BIN
web_timeline/static/description/icon.png
Executable file
BIN
web_timeline/static/description/icon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
699
web_timeline/static/description/index.html
Executable file
699
web_timeline/static/description/index.html
Executable file
@@ -0,0 +1,699 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
|
||||
<title>Web timeline</title>
|
||||
<style type="text/css">
|
||||
|
||||
/*
|
||||
:Author: David Goodger (goodger@python.org)
|
||||
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
|
||||
:Copyright: This stylesheet has been placed in the public domain.
|
||||
|
||||
Default cascading style sheet for the HTML output of Docutils.
|
||||
Despite the name, some widely supported CSS2 features are used.
|
||||
|
||||
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
|
||||
customize this style sheet.
|
||||
*/
|
||||
|
||||
/* used to remove borders from tables and images */
|
||||
.borderless, table.borderless td, table.borderless th {
|
||||
border: 0 }
|
||||
|
||||
table.borderless td, table.borderless th {
|
||||
/* Override padding for "table.docutils td" with "! important".
|
||||
The right padding separates the table cells. */
|
||||
padding: 0 0.5em 0 0 ! important }
|
||||
|
||||
.first {
|
||||
/* Override more specific margin styles with "! important". */
|
||||
margin-top: 0 ! important }
|
||||
|
||||
.last, .with-subtitle {
|
||||
margin-bottom: 0 ! important }
|
||||
|
||||
.hidden {
|
||||
display: none }
|
||||
|
||||
.subscript {
|
||||
vertical-align: sub;
|
||||
font-size: smaller }
|
||||
|
||||
.superscript {
|
||||
vertical-align: super;
|
||||
font-size: smaller }
|
||||
|
||||
a.toc-backref {
|
||||
text-decoration: none ;
|
||||
color: black }
|
||||
|
||||
blockquote.epigraph {
|
||||
margin: 2em 5em ; }
|
||||
|
||||
dl.docutils dd {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Uncomment (and remove this text!) to get bold-faced definition list terms
|
||||
dl.docutils dt {
|
||||
font-weight: bold }
|
||||
*/
|
||||
|
||||
div.abstract {
|
||||
margin: 2em 5em }
|
||||
|
||||
div.abstract p.topic-title {
|
||||
font-weight: bold ;
|
||||
text-align: center }
|
||||
|
||||
div.admonition, div.attention, div.caution, div.danger, div.error,
|
||||
div.hint, div.important, div.note, div.tip, div.warning {
|
||||
margin: 2em ;
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.admonition p.admonition-title, div.hint p.admonition-title,
|
||||
div.important p.admonition-title, div.note p.admonition-title,
|
||||
div.tip p.admonition-title {
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
div.attention p.admonition-title, div.caution p.admonition-title,
|
||||
div.danger p.admonition-title, div.error p.admonition-title,
|
||||
div.warning p.admonition-title, .code .error {
|
||||
color: red ;
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
/* Uncomment (and remove this text!) to get reduced vertical space in
|
||||
compound paragraphs.
|
||||
div.compound .compound-first, div.compound .compound-middle {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
div.compound .compound-last, div.compound .compound-middle {
|
||||
margin-top: 0.5em }
|
||||
*/
|
||||
|
||||
div.dedication {
|
||||
margin: 2em 5em ;
|
||||
text-align: center ;
|
||||
font-style: italic }
|
||||
|
||||
div.dedication p.topic-title {
|
||||
font-weight: bold ;
|
||||
font-style: normal }
|
||||
|
||||
div.figure {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
div.footer, div.header {
|
||||
clear: both;
|
||||
font-size: smaller }
|
||||
|
||||
div.line-block {
|
||||
display: block ;
|
||||
margin-top: 1em ;
|
||||
margin-bottom: 1em }
|
||||
|
||||
div.line-block div.line-block {
|
||||
margin-top: 0 ;
|
||||
margin-bottom: 0 ;
|
||||
margin-left: 1.5em }
|
||||
|
||||
div.sidebar {
|
||||
margin: 0 0 0.5em 1em ;
|
||||
border: medium outset ;
|
||||
padding: 1em ;
|
||||
background-color: #ffffee ;
|
||||
width: 40% ;
|
||||
float: right ;
|
||||
clear: right }
|
||||
|
||||
div.sidebar p.rubric {
|
||||
font-family: sans-serif ;
|
||||
font-size: medium }
|
||||
|
||||
div.system-messages {
|
||||
margin: 5em }
|
||||
|
||||
div.system-messages h1 {
|
||||
color: red }
|
||||
|
||||
div.system-message {
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.system-message p.system-message-title {
|
||||
color: red ;
|
||||
font-weight: bold }
|
||||
|
||||
div.topic {
|
||||
margin: 2em }
|
||||
|
||||
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
|
||||
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
|
||||
margin-top: 0.4em }
|
||||
|
||||
h1.title {
|
||||
text-align: center }
|
||||
|
||||
h2.subtitle {
|
||||
text-align: center }
|
||||
|
||||
hr.docutils {
|
||||
width: 75% }
|
||||
|
||||
img.align-left, .figure.align-left, object.align-left, table.align-left {
|
||||
clear: left ;
|
||||
float: left ;
|
||||
margin-right: 1em }
|
||||
|
||||
img.align-right, .figure.align-right, object.align-right, table.align-right {
|
||||
clear: right ;
|
||||
float: right ;
|
||||
margin-left: 1em }
|
||||
|
||||
img.align-center, .figure.align-center, object.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
table.align-center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left }
|
||||
|
||||
.align-center {
|
||||
clear: both ;
|
||||
text-align: center }
|
||||
|
||||
.align-right {
|
||||
text-align: right }
|
||||
|
||||
/* reset inner alignment in figures */
|
||||
div.align-right {
|
||||
text-align: inherit }
|
||||
|
||||
/* div.align-center * { */
|
||||
/* text-align: left } */
|
||||
|
||||
.align-top {
|
||||
vertical-align: top }
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle }
|
||||
|
||||
.align-bottom {
|
||||
vertical-align: bottom }
|
||||
|
||||
ol.simple, ul.simple {
|
||||
margin-bottom: 1em }
|
||||
|
||||
ol.arabic {
|
||||
list-style: decimal }
|
||||
|
||||
ol.loweralpha {
|
||||
list-style: lower-alpha }
|
||||
|
||||
ol.upperalpha {
|
||||
list-style: upper-alpha }
|
||||
|
||||
ol.lowerroman {
|
||||
list-style: lower-roman }
|
||||
|
||||
ol.upperroman {
|
||||
list-style: upper-roman }
|
||||
|
||||
p.attribution {
|
||||
text-align: right ;
|
||||
margin-left: 50% }
|
||||
|
||||
p.caption {
|
||||
font-style: italic }
|
||||
|
||||
p.credits {
|
||||
font-style: italic ;
|
||||
font-size: smaller }
|
||||
|
||||
p.label {
|
||||
white-space: nowrap }
|
||||
|
||||
p.rubric {
|
||||
font-weight: bold ;
|
||||
font-size: larger ;
|
||||
color: maroon ;
|
||||
text-align: center }
|
||||
|
||||
p.sidebar-title {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold ;
|
||||
font-size: larger }
|
||||
|
||||
p.sidebar-subtitle {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
p.topic-title {
|
||||
font-weight: bold }
|
||||
|
||||
pre.address {
|
||||
margin-bottom: 0 ;
|
||||
margin-top: 0 ;
|
||||
font: inherit }
|
||||
|
||||
pre.literal-block, pre.doctest-block, pre.math, pre.code {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
pre.code .ln { color: gray; } /* line numbers */
|
||||
pre.code, code { background-color: #eeeeee }
|
||||
pre.code .comment, code .comment { color: #5C6576 }
|
||||
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
|
||||
pre.code .literal.string, code .literal.string { color: #0C5404 }
|
||||
pre.code .name.builtin, code .name.builtin { color: #352B84 }
|
||||
pre.code .deleted, code .deleted { background-color: #DEB0A1}
|
||||
pre.code .inserted, code .inserted { background-color: #A3D289}
|
||||
|
||||
span.classifier {
|
||||
font-family: sans-serif ;
|
||||
font-style: oblique }
|
||||
|
||||
span.classifier-delimiter {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
span.interpreted {
|
||||
font-family: sans-serif }
|
||||
|
||||
span.option {
|
||||
white-space: nowrap }
|
||||
|
||||
span.pre {
|
||||
white-space: pre }
|
||||
|
||||
span.problematic, pre.problematic {
|
||||
color: red }
|
||||
|
||||
span.section-subtitle {
|
||||
/* font-size relative to parent (h1..h6 element) */
|
||||
font-size: 80% }
|
||||
|
||||
table.citation {
|
||||
border-left: solid 1px gray;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docinfo {
|
||||
margin: 2em 4em }
|
||||
|
||||
table.docutils {
|
||||
margin-top: 0.5em ;
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
table.footnote {
|
||||
border-left: solid 1px black;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docutils td, table.docutils th,
|
||||
table.docinfo td, table.docinfo th {
|
||||
padding-left: 0.5em ;
|
||||
padding-right: 0.5em ;
|
||||
vertical-align: top }
|
||||
|
||||
table.docutils th.field-name, table.docinfo th.docinfo-name {
|
||||
font-weight: bold ;
|
||||
text-align: left ;
|
||||
white-space: nowrap ;
|
||||
padding-left: 0 }
|
||||
|
||||
/* "booktabs" style (no vertical lines) */
|
||||
table.docutils.booktabs {
|
||||
border: 0px;
|
||||
border-top: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.docutils.booktabs * {
|
||||
border: 0px;
|
||||
}
|
||||
table.docutils.booktabs th {
|
||||
border-bottom: thin solid;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
|
||||
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
|
||||
font-size: 100% }
|
||||
|
||||
ul.auto-toc {
|
||||
list-style-type: none }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document" id="web-timeline">
|
||||
<h1 class="title">Web timeline</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:f7afcee2d058a44cc7b44daf86593d10300c09feae36a156e0bdebaf3cb9836a
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Production/Stable" src="https://img.shields.io/badge/maturity-Production%2FStable-green.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/web/tree/18.0/web_timeline"><img alt="OCA/web" src="https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_timeline"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
|
||||
<p>Define a new view displaying events in an interactive visualization
|
||||
chart.</p>
|
||||
<p>The widget is based on the external library
|
||||
<a class="reference external" href="https://visjs.github.io/vis-timeline/examples/timeline">https://visjs.github.io/vis-timeline/examples/timeline</a></p>
|
||||
<p><strong>Table of contents</strong></p>
|
||||
<div class="contents local topic" id="contents">
|
||||
<ul class="simple">
|
||||
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
|
||||
<li><a class="reference internal" href="#usage" id="toc-entry-2">Usage</a></li>
|
||||
<li><a class="reference internal" href="#known-issues-roadmap" id="toc-entry-3">Known issues / Roadmap</a></li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-4">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="toc-entry-5">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="toc-entry-6">Authors</a></li>
|
||||
<li><a class="reference internal" href="#contributors" id="toc-entry-7">Contributors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="toc-entry-8">Maintainers</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="configuration">
|
||||
<h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1>
|
||||
<p>You need to define a view with the tag <timeline> as base element. These
|
||||
are the possible attributes for the tag:</p>
|
||||
<table border="1" class="docutils">
|
||||
<colgroup>
|
||||
<col width="27%" />
|
||||
<col width="17%" />
|
||||
<col width="56%" />
|
||||
</colgroup>
|
||||
<thead valign="bottom">
|
||||
<tr><th class="head">Attribute</th>
|
||||
<th class="head">Required?</th>
|
||||
<th class="head">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody valign="top">
|
||||
<tr><td>date_start</td>
|
||||
<td><strong>Yes</strong></td>
|
||||
<td>Defines the name of the field of
|
||||
type date that contains the start
|
||||
of the event.</td>
|
||||
</tr>
|
||||
<tr><td>date_stop</td>
|
||||
<td>No</td>
|
||||
<td>Defines the name of the field of
|
||||
type date that contains the end of
|
||||
the event. The date_stop can be
|
||||
equal to the attribute date_start
|
||||
to display events has ‘point’ on
|
||||
the Timeline (instantaneous event).</td>
|
||||
</tr>
|
||||
<tr><td>date_delay</td>
|
||||
<td>No</td>
|
||||
<td>Defines the name of the field of
|
||||
type float/integer that contain the
|
||||
duration in hours of the event,
|
||||
default = 1.</td>
|
||||
</tr>
|
||||
<tr><td>default_group_by</td>
|
||||
<td><strong>Yes</strong></td>
|
||||
<td>Defines the name of the field that
|
||||
will be taken as default group by
|
||||
when accessing the view or when no
|
||||
other group by is selected.</td>
|
||||
</tr>
|
||||
<tr><td>zoomKey</td>
|
||||
<td>No</td>
|
||||
<td>Specifies whether the Timeline is
|
||||
only zoomed when an additional key
|
||||
is down. Available values are ‘’
|
||||
(does not apply), ‘altKey’,
|
||||
‘ctrlKey’, or ‘metaKey’. Set this
|
||||
option if you want to be able to
|
||||
use the scroll to navigate
|
||||
vertically on views with a lot of
|
||||
events.</td>
|
||||
</tr>
|
||||
<tr><td>mode</td>
|
||||
<td>No</td>
|
||||
<td>Specifies the initial visible
|
||||
window. Available values are: ‘day’
|
||||
to display the current day, ‘week’,
|
||||
‘month’ and ‘fit’. Default value is
|
||||
‘fit’ to adjust the visible window
|
||||
such that it fits all items.</td>
|
||||
</tr>
|
||||
<tr><td>margin</td>
|
||||
<td>No</td>
|
||||
<td>Specifies the margins around the
|
||||
items. It should respect the JSON
|
||||
format. For example
|
||||
‘{“item”:{“horizontal”:-10}}’.
|
||||
Available values are:
|
||||
‘{“axis”:<number>}’ (The minimal
|
||||
margin in pixels between items and
|
||||
the time axis) ‘{“item”:<number>}’
|
||||
(The minimal margin in pixels
|
||||
between items in both horizontal
|
||||
and vertical direction),
|
||||
‘{“item”:{“horizontal”:<number>}}’
|
||||
(The minimal horizontal margin in
|
||||
pixels between items),
|
||||
‘{“item”:{“vertical”:<number>}}’
|
||||
(The minimal vertical margin in
|
||||
pixels between items),
|
||||
‘{“item”:{“horizont
|
||||
al”:<number>,”vertical”:<number>}}’
|
||||
(Combination between horizontal and
|
||||
vertical margins in pixels between
|
||||
items).</td>
|
||||
</tr>
|
||||
<tr><td>event_open_popup</td>
|
||||
<td>No</td>
|
||||
<td>When set to true, it allows to edit
|
||||
the events in a popup. If not
|
||||
(default value), the record is
|
||||
edited changing to form view.</td>
|
||||
</tr>
|
||||
<tr><td>stack</td>
|
||||
<td>No</td>
|
||||
<td>When set to false, items will not
|
||||
be stacked on top of each other
|
||||
such that they do overlap.</td>
|
||||
</tr>
|
||||
<tr><td>colors</td>
|
||||
<td>No</td>
|
||||
<td>Allows to set certain specific
|
||||
colors if the expressed condition
|
||||
(JS syntax) is met.</td>
|
||||
</tr>
|
||||
<tr><td>dependency_arrow</td>
|
||||
<td>No</td>
|
||||
<td>Set this attribute to a x2many
|
||||
field to draw arrows between the
|
||||
records referenced in the x2many
|
||||
field.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Optionally you can declare a custom template, which will be used to
|
||||
render the timeline items. You have to name the template
|
||||
‘timeline-item’. These are the variables available in template
|
||||
rendering:</p>
|
||||
<ul class="simple">
|
||||
<li><tt class="docutils literal">record</tt>: to access the fields values selected in the timeline
|
||||
definition.</li>
|
||||
<li><tt class="docutils literal">formatters</tt>: used to format values (see available functions in
|
||||
<tt class="docutils literal">@web/views/fields/formatters</tt>).</li>
|
||||
<li><tt class="docutils literal">parsers</tt>: used to parse values (see available functions in
|
||||
<tt class="docutils literal">@web/views/fields/parsers</tt>).</li>
|
||||
</ul>
|
||||
<p>You also need to declare the view in an action window of the involved
|
||||
model.</p>
|
||||
<p>See <tt class="docutils literal">web_timeline/demo/ir_cron_view.xml</tt> for a very basic timeline
|
||||
view example added onto cron tasks.</p>
|
||||
<p>More evolved example, from <tt class="docutils literal">project_timeline</tt>:</p>
|
||||
<pre class="code xml literal-block">
|
||||
<span class="cp"><?xml version="1.0" encoding="utf-8"?></span><span class="w">
|
||||
</span><span class="nt"><odoo></span><span class="w">
|
||||
</span><span class="nt"><record</span><span class="w"> </span><span class="na">id=</span><span class="s">"view_task_timeline"</span><span class="w"> </span><span class="na">model=</span><span class="s">"ir.ui.view"</span><span class="nt">></span><span class="w">
|
||||
</span><span class="nt"><field</span><span class="w"> </span><span class="na">name=</span><span class="s">"model"</span><span class="nt">></span>project.task<span class="nt"></field></span><span class="w">
|
||||
</span><span class="nt"><field</span><span class="w"> </span><span class="na">name=</span><span class="s">"type"</span><span class="nt">></span>timeline<span class="nt"></field></span><span class="w">
|
||||
</span><span class="nt"><field</span><span class="w"> </span><span class="na">name=</span><span class="s">"arch"</span><span class="w"> </span><span class="na">type=</span><span class="s">"xml"</span><span class="nt">></span><span class="w">
|
||||
</span><span class="nt"><timeline</span><span class="w"> </span><span class="na">date_start=</span><span class="s">"date_assign"</span><span class="w">
|
||||
</span><span class="na">date_stop=</span><span class="s">"date_end"</span><span class="w">
|
||||
</span><span class="na">string=</span><span class="s">"Tasks"</span><span class="w">
|
||||
</span><span class="na">default_group_by=</span><span class="s">"project_id"</span><span class="w">
|
||||
</span><span class="na">event_open_popup=</span><span class="s">"true"</span><span class="w">
|
||||
</span><span class="na">colors=</span><span class="s">"white: user_ids == []; #2ecb71: state == '1_done'; #ec7063: state == '1_canceled'"</span><span class="w">
|
||||
</span><span class="na">dependency_arrow=</span><span class="s">"depend_on_ids"</span><span class="w">
|
||||
</span><span class="nt">></span><span class="w">
|
||||
</span><span class="nt"><field</span><span class="w"> </span><span class="na">name=</span><span class="s">"user_ids"</span><span class="w"> </span><span class="nt">/></span><span class="w">
|
||||
</span><span class="nt"><field</span><span class="w"> </span><span class="na">name=</span><span class="s">"allocated_hours"</span><span class="w"> </span><span class="nt">/></span><span class="w">
|
||||
</span><span class="nt"><templates></span><span class="w">
|
||||
</span><span class="nt"><t</span><span class="w"> </span><span class="na">t-name=</span><span class="s">"timeline-item"</span><span class="nt">></span><span class="w">
|
||||
</span><span class="nt"><div</span><span class="w"> </span><span class="na">class=</span><span class="s">"o_project_timeline_item"</span><span class="nt">></span><span class="w">
|
||||
</span><span class="nt"><t</span><span class="w"> </span><span class="na">t-foreach=</span><span class="s">"record.user_ids"</span><span class="w"> </span><span class="na">t-as=</span><span class="s">"user"</span><span class="w"> </span><span class="na">t-key=</span><span class="s">"user.id"</span><span class="nt">></span><span class="w">
|
||||
</span><span class="nt"><img</span><span class="w">
|
||||
</span><span class="na">t-if=</span><span class="s">"record.user_ids"</span><span class="w">
|
||||
</span><span class="na">t-attf-src=</span><span class="s">"/web/image/res.users/#{user}/image_128/16x16"</span><span class="w">
|
||||
</span><span class="na">t-att-title=</span><span class="s">"record.user"</span><span class="w">
|
||||
</span><span class="na">width=</span><span class="s">"16"</span><span class="w">
|
||||
</span><span class="na">height=</span><span class="s">"16"</span><span class="w">
|
||||
</span><span class="na">class=</span><span class="s">"mr8"</span><span class="w">
|
||||
</span><span class="na">alt=</span><span class="s">"User"</span><span class="w">
|
||||
</span><span class="nt">/></span><span class="w">
|
||||
</span><span class="nt"></t></span><span class="w">
|
||||
</span><span class="nt"><span</span><span class="w"> </span><span class="na">name=</span><span class="s">"display_name"</span><span class="nt">></span><span class="w">
|
||||
</span><span class="nt"><t</span><span class="w"> </span><span class="na">t-esc=</span><span class="s">"record.display_name"</span><span class="w"> </span><span class="nt">/></span><span class="w">
|
||||
</span><span class="nt"></span></span><span class="w">
|
||||
</span><span class="nt"><small</span><span class="w">
|
||||
</span><span class="na">name=</span><span class="s">"allocated_hours"</span><span class="w">
|
||||
</span><span class="na">class=</span><span class="s">"text-info ml4"</span><span class="w">
|
||||
</span><span class="na">t-if=</span><span class="s">"record.allocated_hours"</span><span class="w">
|
||||
</span><span class="nt">></span><span class="w">
|
||||
</span><span class="nt"><t</span><span class="w">
|
||||
</span><span class="na">t-out=</span><span class="s">"formatters.get('float_time')(record.allocated_hours)"</span><span class="w">
|
||||
</span><span class="nt">/></span><span class="w">
|
||||
</span><span class="nt"></small></span><span class="w">
|
||||
</span><span class="nt"></div></span><span class="w">
|
||||
</span><span class="nt"></t></span><span class="w">
|
||||
</span><span class="nt"></templates></span><span class="w">
|
||||
</span><span class="nt"></timeline></span><span class="w">
|
||||
</span><span class="nt"></field></span><span class="w">
|
||||
</span><span class="nt"></record></span><span class="w">
|
||||
|
||||
</span><span class="nt"><record</span><span class="w"> </span><span class="na">id=</span><span class="s">"project.action_view_task"</span><span class="w"> </span><span class="na">model=</span><span class="s">"ir.actions.act_window"</span><span class="nt">></span><span class="w">
|
||||
</span><span class="nt"><field</span><span class="w">
|
||||
</span><span class="na">name=</span><span class="s">"view_mode"</span><span class="w">
|
||||
</span><span class="nt">></span>kanban,list,form,calendar,timeline,pivot,graph,activity<span class="nt"></field></span><span class="w">
|
||||
</span><span class="nt"></record></span><span class="w">
|
||||
</span><span class="nt"></odoo></span>
|
||||
</pre>
|
||||
</div>
|
||||
<div class="section" id="usage">
|
||||
<h1><a class="toc-backref" href="#toc-entry-2">Usage</a></h1>
|
||||
<p>For accessing the timeline view, you have to click on the button with
|
||||
the clock icon in the view switcher. The first time you access to it,
|
||||
the timeline window is zoomed to fit all the current elements, the same
|
||||
as when you perform a search, filter or group by operation.</p>
|
||||
<p>You can use the mouse scroll to zoom in or out in the timeline, and
|
||||
click on any free area and drag for panning the view in that direction.</p>
|
||||
<p>The records of your model will be shown as rectangles whose widths are
|
||||
the duration of the event according our definition. You can select them
|
||||
clicking on this rectangle. You can also use Ctrl or Shift keys for
|
||||
adding discrete or range selections. Selected records are hightlighted
|
||||
with a different color (but the difference will be more noticeable
|
||||
depending on the background color). Once selected, you can drag and move
|
||||
the selected records across the timeline.</p>
|
||||
<p>When a record is selected, a red cross button appears on the upper left
|
||||
corner that allows to remove that record. This doesn’t work for multiple
|
||||
records although they were selected.</p>
|
||||
<p>Records are grouped in different blocks depending on the group by
|
||||
criteria selected (if none is specified, then the default group by is
|
||||
applied). Dragging a record from one block to another change the
|
||||
corresponding field to the value that represents the block. You can also
|
||||
click on the group name to edit the involved record directly.</p>
|
||||
<p>Double-click on the record to edit it. Double-click in open area to
|
||||
create a new record with the group and start date linked to the area you
|
||||
clicked in. By holding the Ctrl key and dragging left to right, you can
|
||||
create a new record with the dragged start and end date.</p>
|
||||
</div>
|
||||
<div class="section" id="known-issues-roadmap">
|
||||
<h1><a class="toc-backref" href="#toc-entry-3">Known issues / Roadmap</a></h1>
|
||||
<ul class="simple">
|
||||
<li>Implement a more efficient way of refreshing timeline after a record
|
||||
update;</li>
|
||||
<li>Make <tt class="docutils literal">attrs</tt> attribute work;</li>
|
||||
<li>When grouping by m2m and more than one record is set, the timeline
|
||||
item appears only on one group. Allow showing in both groups.</li>
|
||||
<li>When grouping by m2m and dragging for changing the time or the group,
|
||||
the changes on the group will not be set, because it could make
|
||||
disappear the records not related with the changes that we want to
|
||||
make. When the item is showed in all groups change the value according
|
||||
the group of the dragged item.</li>
|
||||
<li>When an item label does not fit in its date-range box: ✅ the label
|
||||
correctly overflows the box; ✅ clicking anywhere on the label allows
|
||||
moving the box; ❌ double-clicking the label outside of the box does
|
||||
not open that item.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h1><a class="toc-backref" href="#toc-entry-4">Bug Tracker</a></h1>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/web/issues">GitHub Issues</a>.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us to smash it by providing a detailed and welcomed
|
||||
<a class="reference external" href="https://github.com/OCA/web/issues/new?body=module:%20web_timeline%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
|
||||
<p>Do not contact contributors directly about support or help with technical issues.</p>
|
||||
</div>
|
||||
<div class="section" id="credits">
|
||||
<h1><a class="toc-backref" href="#toc-entry-5">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#toc-entry-6">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>ACSONE SA/NV</li>
|
||||
<li>Tecnativa</li>
|
||||
<li>Monk Software</li>
|
||||
<li>Onestein</li>
|
||||
<li>Trobz</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="contributors">
|
||||
<h2><a class="toc-backref" href="#toc-entry-7">Contributors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Laurent Mignon <<a class="reference external" href="mailto:laurent.mignon@acsone.eu">laurent.mignon@acsone.eu</a>></li>
|
||||
<li>Adrien Peiffer <<a class="reference external" href="mailto:adrien.peiffer@acsone.eu">adrien.peiffer@acsone.eu</a>></li>
|
||||
<li>Leonardo Donelli <<a class="reference external" href="mailto:donelli@webmonks.it">donelli@webmonks.it</a>></li>
|
||||
<li>Adrien Didenot <<a class="reference external" href="mailto:adrien.didenot@horanet.com">adrien.didenot@horanet.com</a>></li>
|
||||
<li>Thong Nguyen Van <<a class="reference external" href="mailto:thongnv@trobz.com">thongnv@trobz.com</a>></li>
|
||||
<li>Murtaza Mithaiwala <<a class="reference external" href="mailto:mmithaiwala@opensourceintegrators.com">mmithaiwala@opensourceintegrators.com</a>></li>
|
||||
<li>Ammar Officewala <<a class="reference external" href="mailto:aofficewala@opensourceintegrators.com">aofficewala@opensourceintegrators.com</a>></li>
|
||||
<li><a class="reference external" href="https://www.tecnativa.com">Tecnativa</a>:<ul>
|
||||
<li>Pedro M. Baeza</li>
|
||||
<li>Alexandre Díaz</li>
|
||||
<li>César A. Sánchez</li>
|
||||
<li>Carlos López</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference external" href="https://www.onestein.nl">Onestein</a>:<ul>
|
||||
<li>Dennis Sluijk <<a class="reference external" href="mailto:d.sluijk@onestein.nl">d.sluijk@onestein.nl</a>></li>
|
||||
<li>Anjeel Haria</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference external" href="https://xcg-consulting.fr">XCG Consulting</a>:<ul>
|
||||
<li>Houzéfa Abbasbhay</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#toc-entry-8">Maintainers</a></h2>
|
||||
<p>This module is maintained by the OCA.</p>
|
||||
<a class="reference external image-reference" href="https://odoo-community.org">
|
||||
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
|
||||
</a>
|
||||
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||
mission is to support the collaborative development of Odoo features and
|
||||
promote its widespread use.</p>
|
||||
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p>
|
||||
<p><a class="reference external image-reference" href="https://github.com/tarteo"><img alt="tarteo" src="https://github.com/tarteo.png?size=40px" /></a></p>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/web/tree/18.0/web_timeline">OCA/web</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1215
web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css
Executable file
1215
web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css
Executable file
File diff suppressed because it is too large
Load Diff
47300
web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js
Executable file
47300
web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js
Executable file
File diff suppressed because one or more lines are too long
190
web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js
Executable file
190
web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js
Executable file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Copyright 2024 Tecnativa - Carlos López
|
||||
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
*/
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
import {exprToBoolean} from "@web/core/utils/strings";
|
||||
import {parseExpr} from "@web/core/py_js/py";
|
||||
import {visitXML} from "@web/core/utils/xml";
|
||||
|
||||
const MODES = ["day", "week", "month", "fit"];
|
||||
|
||||
export class TimelineParseArchError extends Error {}
|
||||
|
||||
export class TimelineArchParser {
|
||||
parse(arch, fields) {
|
||||
const archInfo = {
|
||||
colors: [],
|
||||
class: "",
|
||||
templateDocs: {},
|
||||
min_height: 300,
|
||||
mode: "fit",
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
options: {
|
||||
groupOrder: "order",
|
||||
orientation: {axis: "both", item: "top"},
|
||||
selectable: true,
|
||||
multiselect: true,
|
||||
showCurrentTime: true,
|
||||
stack: true,
|
||||
margin: {item: 2},
|
||||
zoomKey: "ctrlKey",
|
||||
},
|
||||
};
|
||||
const fieldNames = fields.display_name ? ["display_name"] : [];
|
||||
visitXML(arch, (node) => {
|
||||
switch (node.tagName) {
|
||||
case "timeline": {
|
||||
if (!node.hasAttribute("date_start")) {
|
||||
throw new TimelineParseArchError(
|
||||
_t("Timeline view has not defined 'date_start' attribute.")
|
||||
);
|
||||
}
|
||||
if (!node.hasAttribute("default_group_by")) {
|
||||
throw new TimelineParseArchError(
|
||||
_t(
|
||||
"Timeline view has not defined 'default_group_by' attribute."
|
||||
)
|
||||
);
|
||||
}
|
||||
archInfo.date_start = node.getAttribute("date_start");
|
||||
archInfo.default_group_by = node.getAttribute("default_group_by");
|
||||
if (node.hasAttribute("class")) {
|
||||
archInfo.class = node.getAttribute("class");
|
||||
}
|
||||
if (node.hasAttribute("date_stop")) {
|
||||
archInfo.date_stop = node.getAttribute("date_stop");
|
||||
}
|
||||
if (node.hasAttribute("date_delay")) {
|
||||
archInfo.date_delay = node.getAttribute("date_delay");
|
||||
}
|
||||
if (node.hasAttribute("colors")) {
|
||||
archInfo.colors = this.parse_colors(
|
||||
node.getAttribute("colors")
|
||||
);
|
||||
}
|
||||
if (node.hasAttribute("dependency_arrow")) {
|
||||
archInfo.dependency_arrow =
|
||||
node.getAttribute("dependency_arrow");
|
||||
}
|
||||
if (node.hasAttribute("stack")) {
|
||||
archInfo.options.stack = exprToBoolean(
|
||||
node.getAttribute("stack"),
|
||||
true
|
||||
);
|
||||
}
|
||||
if (node.hasAttribute("zoomKey")) {
|
||||
archInfo.options.zoomKey =
|
||||
node.getAttribute("zoomKey") || "ctrlKey";
|
||||
}
|
||||
if (node.hasAttribute("margin")) {
|
||||
archInfo.options.margin = node.getAttribute("margin")
|
||||
? JSON.parse(node.getAttribute("margin"))
|
||||
: {item: 2};
|
||||
}
|
||||
if (node.hasAttribute("min_height")) {
|
||||
archInfo.min_height = node.getAttribute("min_height");
|
||||
}
|
||||
if (node.hasAttribute("mode")) {
|
||||
archInfo.mode = node.getAttribute("mode");
|
||||
if (!MODES.includes(archInfo.mode)) {
|
||||
throw new TimelineParseArchError(
|
||||
`Timeline view cannot display mode: ${archInfo.mode}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (node.hasAttribute("event_open_popup")) {
|
||||
archInfo.open_popup_action = exprToBoolean(
|
||||
node.getAttribute("event_open_popup")
|
||||
);
|
||||
}
|
||||
if (node.hasAttribute("create")) {
|
||||
archInfo.canCreate = exprToBoolean(
|
||||
node.getAttribute("create"),
|
||||
true
|
||||
);
|
||||
}
|
||||
if (node.hasAttribute("edit")) {
|
||||
archInfo.canUpdate = exprToBoolean(
|
||||
node.getAttribute("edit"),
|
||||
true
|
||||
);
|
||||
}
|
||||
if (node.hasAttribute("delete")) {
|
||||
archInfo.canDelete = exprToBoolean(
|
||||
node.getAttribute("delete"),
|
||||
true
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "field": {
|
||||
const fieldName = node.getAttribute("name");
|
||||
if (!fieldNames.includes(fieldName)) {
|
||||
fieldNames.push(fieldName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "t": {
|
||||
if (node.hasAttribute("t-name")) {
|
||||
archInfo.templateDocs[node.getAttribute("t-name")] = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fieldsToGather = [
|
||||
"date_start",
|
||||
"date_stop",
|
||||
"default_group_by",
|
||||
"progress",
|
||||
"date_delay",
|
||||
archInfo.default_group_by,
|
||||
];
|
||||
|
||||
for (const field of fieldsToGather) {
|
||||
if (archInfo[field] && !fieldNames.includes(archInfo[field])) {
|
||||
fieldNames.push(archInfo[field]);
|
||||
}
|
||||
}
|
||||
for (const color of archInfo.colors) {
|
||||
if (!fieldNames.includes(color.field)) {
|
||||
fieldNames.push(color.field);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
archInfo.dependency_arrow &&
|
||||
!fieldNames.includes(archInfo.dependency_arrow)
|
||||
) {
|
||||
fieldNames.push(archInfo.dependency_arrow);
|
||||
}
|
||||
archInfo.fieldNames = fieldNames;
|
||||
return archInfo;
|
||||
}
|
||||
/**
|
||||
* Parse the colors attribute.
|
||||
* @param {Array} colors
|
||||
* @returns {Array}
|
||||
*/
|
||||
parse_colors(colors) {
|
||||
if (colors) {
|
||||
return colors
|
||||
.split(";")
|
||||
.filter(Boolean)
|
||||
.map((color_pair) => {
|
||||
const [color, expr] = color_pair.split(":");
|
||||
const ast = parseExpr(expr);
|
||||
return {
|
||||
color: color,
|
||||
field: ast.left.value,
|
||||
ast,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
166
web_timeline/static/src/views/timeline/timeline_canvas.esm.js
Executable file
166
web_timeline/static/src/views/timeline/timeline_canvas.esm.js
Executable file
@@ -0,0 +1,166 @@
|
||||
/* Copyright 2018 Onestein
|
||||
Copyright 2024 Tecnativa - Carlos López
|
||||
* License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */
|
||||
|
||||
/**
|
||||
* Used to draw stuff on upon the timeline view.
|
||||
*/
|
||||
|
||||
export class TimelineCanvas {
|
||||
constructor(canvas_ref) {
|
||||
this.canvas_ref = canvas_ref;
|
||||
}
|
||||
/**
|
||||
* Clears all drawings (svg elements) from the canvas.
|
||||
*/
|
||||
clear() {
|
||||
if (this.canvas_ref) {
|
||||
const tempElement = document.createElement("div");
|
||||
tempElement.innerHTML = this.canvas_ref;
|
||||
Array.from(tempElement.children).forEach((child) => {
|
||||
if (child.tagName.toLowerCase() !== "defs") {
|
||||
child.remove();
|
||||
}
|
||||
});
|
||||
this.canvas_ref = tempElement.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path from one point to another.
|
||||
*
|
||||
* @param {Object} rectFrom
|
||||
* @param {Object} rectTo
|
||||
* @param {Number} widthMarker The marker's width of the polyline
|
||||
* @param {Number} breakAt The space between the line turns
|
||||
* @returns {Array} Each item represents a coordinate
|
||||
*/
|
||||
get_polyline_points(rectFrom, rectTo, widthMarker, breakAt) {
|
||||
let fromX = 0,
|
||||
toX = 0;
|
||||
if (rectFrom.x < rectTo.x + rectTo.w) {
|
||||
fromX = rectFrom.x + rectFrom.w + widthMarker;
|
||||
toX = rectTo.x;
|
||||
} else {
|
||||
fromX = rectFrom.x - widthMarker;
|
||||
toX = rectTo.x + rectTo.w;
|
||||
}
|
||||
let deltaBreak = 0;
|
||||
if (fromX < toX) {
|
||||
deltaBreak = fromX + breakAt - (toX - breakAt);
|
||||
} else {
|
||||
deltaBreak = fromX - breakAt - (toX + breakAt);
|
||||
}
|
||||
const fromHalfHeight = rectFrom.h / 2;
|
||||
const fromY = rectFrom.y + fromHalfHeight;
|
||||
const toHalfHeight = rectTo.h / 2;
|
||||
const toY = rectTo.y + toHalfHeight;
|
||||
const xDiff = fromX - toX;
|
||||
const yDiff = fromY - toY;
|
||||
const threshold = breakAt + widthMarker;
|
||||
const spaceY = toHalfHeight + 2;
|
||||
|
||||
const points = [[fromX, fromY]];
|
||||
const _addPoints = (space, ePoint, mode) => {
|
||||
if (mode) {
|
||||
points.push([fromX + breakAt, fromY]);
|
||||
points.push([fromX + breakAt, ePoint + space]);
|
||||
points.push([toX - breakAt, toY + space]);
|
||||
points.push([toX - breakAt, toY]);
|
||||
} else {
|
||||
points.push([fromX - breakAt, fromY]);
|
||||
points.push([fromX - breakAt, ePoint + space]);
|
||||
points.push([toX + breakAt, toY + space]);
|
||||
points.push([toX + breakAt, toY]);
|
||||
}
|
||||
};
|
||||
if (fromY !== toY) {
|
||||
if (Math.abs(xDiff) < threshold) {
|
||||
points.push([fromX + breakAt, toY + yDiff]);
|
||||
points.push([fromX + breakAt, toY]);
|
||||
} else {
|
||||
const yDiffSpace = yDiff > 0 ? spaceY : -spaceY;
|
||||
_addPoints(yDiffSpace, toY, rectFrom.x < rectTo.x + rectTo.w);
|
||||
}
|
||||
} else if (Math.abs(deltaBreak) >= threshold) {
|
||||
_addPoints(spaceY, fromY, fromX < toX);
|
||||
}
|
||||
points.push([toX, toY]);
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws an arrow.
|
||||
*
|
||||
* @param {HTMLElement} from Element to draw the arrow from
|
||||
* @param {HTMLElement} to Element to draw the arrow to
|
||||
* @param {String} color Color of the line
|
||||
* @param {Number} width Width of the line
|
||||
* @returns {HTMLElement} The created SVG polyline
|
||||
*/
|
||||
draw_arrow(from, to, color, width) {
|
||||
return this.draw_line(from, to, color, width, "#arrowhead", 10, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a line.
|
||||
*
|
||||
* @param {HTMLElement} from Element to draw the line from
|
||||
* @param {HTMLElement} to Element to draw the line to
|
||||
* @param {String} color Color of the line
|
||||
* @param {Number} width Width of the line
|
||||
* @param {String} markerStart Start marker of the line
|
||||
* @param {Number} widthMarker The marker's width of the polyline
|
||||
* @param {Number} breakLineAt The space between the line turns
|
||||
* @returns {HTMLElement} The created SVG polyline
|
||||
*/
|
||||
draw_line(
|
||||
from,
|
||||
to,
|
||||
color = "#000",
|
||||
width = 1,
|
||||
markerStart,
|
||||
widthMarker,
|
||||
breakLineAt
|
||||
) {
|
||||
const fromElement =
|
||||
typeof from === "string" ? document.querySelector(from) : from;
|
||||
const toElement = typeof to === "string" ? document.querySelector(to) : to;
|
||||
if (!fromElement || !toElement) return;
|
||||
const childPosFrom = fromElement.getBoundingClientRect();
|
||||
const parentFrom = fromElement.closest(".vis-center")?.getBoundingClientRect();
|
||||
const rectFrom = {
|
||||
x: childPosFrom.left - (parentFrom?.left || 0),
|
||||
y: childPosFrom.top - (parentFrom?.top || 0),
|
||||
w: fromElement.offsetWidth,
|
||||
h: fromElement.offsetHeight,
|
||||
};
|
||||
const childPosTo = toElement.getBoundingClientRect();
|
||||
const parentTo = toElement.closest(".vis-center")?.getBoundingClientRect();
|
||||
const rectTo = {
|
||||
x: childPosTo.left - (parentTo?.left || 0),
|
||||
y: childPosTo.top - (parentTo?.top || 0),
|
||||
w: toElement.offsetWidth,
|
||||
h: toElement.offsetHeight,
|
||||
};
|
||||
const points = this.get_polyline_points(
|
||||
rectFrom,
|
||||
rectTo,
|
||||
widthMarker,
|
||||
breakLineAt
|
||||
);
|
||||
const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
||||
line.setAttribute("points", points.flat().join(","));
|
||||
line.setAttribute("stroke", color);
|
||||
line.setAttribute("stroke-width", width);
|
||||
line.setAttribute("fill", "none");
|
||||
if (markerStart) {
|
||||
line.setAttribute("marker-start", `url(${markerStart})`);
|
||||
}
|
||||
if (this.canvas_ref instanceof HTMLElement) {
|
||||
this.canvas_ref.appendChild(line);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
}
|
||||
8
web_timeline/static/src/views/timeline/timeline_canvas.scss
Executable file
8
web_timeline/static/src/views/timeline/timeline_canvas.scss
Executable file
@@ -0,0 +1,8 @@
|
||||
.oe_timeline_view_canvas {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
271
web_timeline/static/src/views/timeline/timeline_controller.esm.js
Executable file
271
web_timeline/static/src/views/timeline/timeline_controller.esm.js
Executable file
@@ -0,0 +1,271 @@
|
||||
/** @odoo-module alias=web_timeline.TimelineController **/
|
||||
/**
|
||||
* Copyright 2023 Onestein - Anjeel Haria
|
||||
* Copyright 2024 Tecnativa - Carlos López
|
||||
* License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
*/
|
||||
import {Component, useRef} from "@odoo/owl";
|
||||
import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import {FormViewDialog} from "@web/views/view_dialogs/form_view_dialog";
|
||||
import {Layout} from "@web/search/layout";
|
||||
import {SearchBar} from "@web/search/search_bar/search_bar";
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
import {makeContext} from "@web/core/context";
|
||||
import {standardViewProps} from "@web/views/standard_view_props";
|
||||
import {useDebounced} from "@web/core/utils/timing";
|
||||
import {useModel} from "@web/model/model";
|
||||
import {useSearchBarToggler} from "@web/search/search_bar/search_bar_toggler";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import {useSetupAction} from "@web/search/action_hook";
|
||||
|
||||
const {DateTime} = luxon;
|
||||
|
||||
export class TimelineController extends Component {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
this.model = useModel(this.props.Model, this.props.modelParams);
|
||||
useSetupAction({rootRef: useRef("root")});
|
||||
this.searchBarToggler = useSearchBarToggler();
|
||||
this.date_start = this.props.modelParams.date_start;
|
||||
this.date_stop = this.props.modelParams.date_stop;
|
||||
this.date_delay = this.props.modelParams.date_delay;
|
||||
this.open_popup_action = this.props.modelParams.open_popup_action;
|
||||
this.moveQueue = [];
|
||||
this.debouncedInternalMove = useDebounced(this.internalMove, 0);
|
||||
this.dialogService = useService("dialog");
|
||||
this.actionService = useService("action");
|
||||
}
|
||||
get rendererProps() {
|
||||
return {
|
||||
model: this.model,
|
||||
onAdd: this._onAdd.bind(this),
|
||||
onGroupClick: this._onGroupClick.bind(this),
|
||||
onItemDoubleClick: this._onItemDoubleClick.bind(this),
|
||||
onMove: this._onMove.bind(this),
|
||||
onRemove: this._onRemove.bind(this),
|
||||
onUpdate: this._onUpdate.bind(this),
|
||||
};
|
||||
}
|
||||
getSearchProps() {
|
||||
const {comparision, context, domain, groupBy, orderBy} = this.env.searchModel;
|
||||
return {comparision, context, domain, groupBy, orderBy};
|
||||
}
|
||||
/**
|
||||
* Gets triggered when a group in the timeline is
|
||||
* clicked (by the TimelineRenderer).
|
||||
*
|
||||
* @private
|
||||
* @param {EventObject} item
|
||||
*/
|
||||
_onGroupClick(item) {
|
||||
const groupField = this.model.last_group_bys[0];
|
||||
this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: this.model.fields[groupField].relation,
|
||||
res_id: item.group,
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
target: "new",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered on double-click on an item in read-only mode (otherwise, we use _onUpdate).
|
||||
*
|
||||
* @private
|
||||
* @param {EventObject} event
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
_onItemDoubleClick(event) {
|
||||
return this.openItem(event.item, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a form view of a clicked timeline
|
||||
* item (triggered by the TimelineRenderer).
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item
|
||||
* @returns {Object}
|
||||
*/
|
||||
_onUpdate(item) {
|
||||
const item_id = Number(item.evt.id) || item.evt.id;
|
||||
return this.openItem(item_id, true);
|
||||
}
|
||||
|
||||
/** Open specified item, either through modal, or by navigating to form view.
|
||||
* @param {Integer} item_id
|
||||
* @param {Boolean} is_editable
|
||||
*/
|
||||
openItem(item_id, is_editable) {
|
||||
if (this.open_popup_action) {
|
||||
const options = {
|
||||
resModel: this.model.model_name,
|
||||
resId: item_id,
|
||||
};
|
||||
if (is_editable) {
|
||||
options.onRecordSaved = async () => {
|
||||
await this.model.load(this.getSearchProps());
|
||||
this.render();
|
||||
};
|
||||
} else {
|
||||
options.preventEdit = true;
|
||||
}
|
||||
this.Dialog = this.dialogService.add(FormViewDialog, options, {});
|
||||
} else {
|
||||
this.env.services.action.switchView("form", {
|
||||
resId: item_id,
|
||||
mode: is_editable ? "edit" : "readonly",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets triggered when a timeline item is
|
||||
* moved (triggered by the TimelineRenderer).
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
*/
|
||||
_onMove(item, callback) {
|
||||
const event_start = DateTime.fromJSDate(item.start);
|
||||
const event_end = item.end ? DateTime.fromJSDate(item.end) : false;
|
||||
let group = false;
|
||||
if (item.group !== -1) {
|
||||
group = item.group;
|
||||
}
|
||||
const data = {};
|
||||
// In case of a move event, the date_delay stay the same,
|
||||
// only date_start and stop must be updated
|
||||
data[this.date_start] = this.model.serializeDate(this.date_start, event_start);
|
||||
if (this.date_stop) {
|
||||
// In case of instantaneous event, item.end is not defined
|
||||
if (event_end) {
|
||||
data[this.date_stop] = this.model.serializeDate(
|
||||
this.date_stop,
|
||||
event_end
|
||||
);
|
||||
} else {
|
||||
data[this.date_stop] = data[this.date_start];
|
||||
}
|
||||
}
|
||||
if (this.date_delay && event_end) {
|
||||
const diff = event_end.diff(event_start, "hours");
|
||||
data[this.date_delay] = diff.hours;
|
||||
}
|
||||
const grouped_field = this.model.last_group_bys[0];
|
||||
if (this.model.fields[grouped_field].type !== "many2many") {
|
||||
data[grouped_field] = group;
|
||||
}
|
||||
this.moveQueue.push({
|
||||
id: item.id,
|
||||
data,
|
||||
item,
|
||||
callback,
|
||||
});
|
||||
this.debouncedInternalMove();
|
||||
}
|
||||
/**
|
||||
* Write enqueued moves to Odoo. After all writes are finished it updates
|
||||
* the view once (prevents flickering of the view when multiple timeline items
|
||||
* are moved at once).
|
||||
*
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
async internalMove() {
|
||||
const queues = this.moveQueue.slice();
|
||||
this.moveQueue = [];
|
||||
for (const item of queues) {
|
||||
await this.model.write_completed(item.id, item.data);
|
||||
item.callback(item.item);
|
||||
}
|
||||
await this.model.load(this.getSearchProps());
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when a timeline item gets removed from the view.
|
||||
* Requires user confirmation before it gets actually deleted.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
*/
|
||||
_onRemove(item, callback) {
|
||||
this.dialogService.add(ConfirmationDialog, {
|
||||
title: _t("Warning"),
|
||||
body: _t("Are you sure you want to delete this record?"),
|
||||
confirmLabel: _t("Confirm"),
|
||||
cancelLabel: _t("Discard"),
|
||||
confirm: async () => {
|
||||
await this.model.remove_completed(item);
|
||||
callback(item);
|
||||
},
|
||||
cancel: () => {
|
||||
return;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when a timeline item gets added and opens a form view.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
*/
|
||||
_onAdd(item, callback) {
|
||||
// Initialize default values for creation
|
||||
const context = {};
|
||||
let item_start = false,
|
||||
item_end = false;
|
||||
item_start = DateTime.fromJSDate(item.start);
|
||||
context[`default_${this.date_start}`] = this.model.serializeDate(
|
||||
this.date_start,
|
||||
item_start
|
||||
);
|
||||
if (this.date_delay) {
|
||||
context[`default_${this.date_delay}`] = 1;
|
||||
}
|
||||
if (this.date_stop && item.end) {
|
||||
item_end = DateTime.fromJSDate(item.end);
|
||||
context[`default_${this.date_stop}`] = this.model.serializeDate(
|
||||
this.date_stop,
|
||||
item_end
|
||||
);
|
||||
}
|
||||
if (this.date_delay && this.date_stop && item_end) {
|
||||
const diff = item_end.diff(item_start, "hours");
|
||||
context[`default_${this.date_delay}`] = diff.hours;
|
||||
}
|
||||
if (item.group > 0) {
|
||||
context[`default_${this.model.last_group_bys[0]}`] = item.group;
|
||||
}
|
||||
// Show popup
|
||||
this.dialogService.add(
|
||||
FormViewDialog,
|
||||
{
|
||||
resId: false,
|
||||
context: makeContext([context], this.env.searchModel.context),
|
||||
onRecordSaved: async (record) => {
|
||||
const new_record = await this.model.create_completed(record.resId);
|
||||
callback(new_record);
|
||||
},
|
||||
resModel: this.model.model_name,
|
||||
},
|
||||
{onClose: () => callback()}
|
||||
);
|
||||
}
|
||||
}
|
||||
TimelineController.template = "web_timeline.TimelineView";
|
||||
TimelineController.components = {Layout, SearchBar};
|
||||
TimelineController.props = {
|
||||
...standardViewProps,
|
||||
Model: Function,
|
||||
modelParams: Object,
|
||||
Renderer: Function,
|
||||
};
|
||||
18
web_timeline/static/src/views/timeline/timeline_controller.xml
Executable file
18
web_timeline/static/src/views/timeline/timeline_controller.xml
Executable file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web_timeline.TimelineView">
|
||||
<div t-att-class="props.className" t-ref="root">
|
||||
<Layout
|
||||
className="model.useSampleModel ? 'o_view_sample_data' : ''"
|
||||
display="props.display"
|
||||
>
|
||||
<t t-set-slot="layout-actions">
|
||||
<SearchBar t-if="searchBarToggler.state.showSearchBar" />
|
||||
</t>
|
||||
<t t-component="props.Renderer" t-props="rendererProps" />
|
||||
</Layout>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
234
web_timeline/static/src/views/timeline/timeline_model.esm.js
Executable file
234
web_timeline/static/src/views/timeline/timeline_model.esm.js
Executable file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Copyright 2024 Tecnativa - Carlos López
|
||||
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
*/
|
||||
import {serializeDate, serializeDateTime} from "@web/core/l10n/dates";
|
||||
import {KanbanCompiler} from "@web/views/kanban/kanban_compiler";
|
||||
import {KeepLast} from "@web/core/utils/concurrency";
|
||||
import {Model} from "@web/model/model";
|
||||
import {evaluate} from "@web/core/py_js/py";
|
||||
import {onWillStart} from "@odoo/owl";
|
||||
import {registry} from "@web/core/registry";
|
||||
import {renderToString} from "@web/core/utils/render";
|
||||
import {useViewCompiler} from "@web/views/view_compiler";
|
||||
|
||||
const {DateTime} = luxon;
|
||||
const parsers = registry.category("parsers");
|
||||
const formatters = registry.category("formatters");
|
||||
|
||||
export class TimelineModel extends Model {
|
||||
setup(params) {
|
||||
this.params = params;
|
||||
this.model_name = params.resModel;
|
||||
this.fields = this.params.fields;
|
||||
this.date_start = this.params.date_start;
|
||||
this.date_stop = this.params.date_stop;
|
||||
this.date_delay = this.params.date_delay;
|
||||
this.colors = this.params.colors;
|
||||
this.last_group_bys = this.params.default_group_by.split(",");
|
||||
const templates = useViewCompiler(KanbanCompiler, this.params.templateDocs);
|
||||
this.recordTemplate = templates["timeline-item"];
|
||||
|
||||
this.keepLast = new KeepLast();
|
||||
onWillStart(async () => {
|
||||
this.write_right = await this.orm.call(
|
||||
this.model_name,
|
||||
"check_access_rights",
|
||||
["write", false]
|
||||
);
|
||||
this.unlink_right = await this.orm.call(
|
||||
this.model_name,
|
||||
"check_access_rights",
|
||||
["unlink", false]
|
||||
);
|
||||
this.create_right = await this.orm.call(
|
||||
this.model_name,
|
||||
"check_access_rights",
|
||||
["create", false]
|
||||
);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Read the records for the timeline.
|
||||
* @param {Object} searchParams
|
||||
*/
|
||||
async load(searchParams) {
|
||||
if (searchParams.groupBy && searchParams.groupBy.length) {
|
||||
this.last_group_bys = searchParams.groupBy;
|
||||
} else {
|
||||
this.last_group_bys = this.params.default_group_by.split(",");
|
||||
}
|
||||
let fields = this.params.fieldNames;
|
||||
fields = [...new Set(fields.concat(this.last_group_bys))];
|
||||
// Avoid ordering by many2many fields
|
||||
// because it is not supported by Odoo
|
||||
// In the module sale_timesheet_timeline, it is used
|
||||
// with default_group_by = task_user_ids
|
||||
let field_to_order = this.params.default_group_by;
|
||||
if (this.fields[field_to_order].type === "many2many") {
|
||||
field_to_order = undefined;
|
||||
}
|
||||
this.data = await this.keepLast.add(
|
||||
this.orm.call(this.model_name, "search_read", [], {
|
||||
fields: fields,
|
||||
domain: searchParams.domain,
|
||||
order: field_to_order,
|
||||
context: searchParams.context,
|
||||
})
|
||||
);
|
||||
this.notify();
|
||||
}
|
||||
/**
|
||||
* Transform Odoo event object to timeline event object.
|
||||
*
|
||||
* @param {Object} record
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_event_data_transform(record) {
|
||||
const [date_start, date_stop] = this._get_event_dates(record);
|
||||
let group = record[this.last_group_bys[0]];
|
||||
if (group && Array.isArray(group) && group.length > 0) {
|
||||
group = group[0];
|
||||
} else {
|
||||
group = -1;
|
||||
}
|
||||
let colorToApply = false;
|
||||
for (const color of this.colors) {
|
||||
if (evaluate(color.ast, record)) {
|
||||
colorToApply = color.color;
|
||||
}
|
||||
}
|
||||
|
||||
let content = record.display_name;
|
||||
if (this.recordTemplate) {
|
||||
content = this._render_timeline_item(record);
|
||||
}
|
||||
|
||||
const timeline_item = {
|
||||
start: date_start.toJSDate(),
|
||||
content: content,
|
||||
id: record.id,
|
||||
order: record.order,
|
||||
group: group,
|
||||
evt: record,
|
||||
style: `background-color: ${colorToApply};`,
|
||||
};
|
||||
// Only specify range end when there actually is one.
|
||||
// ➔ Instantaneous events / those with inverted dates are displayed as points.
|
||||
if (date_stop && DateTime.fromISO(date_start) < DateTime.fromISO(date_stop)) {
|
||||
timeline_item.end = date_stop.toJSDate();
|
||||
}
|
||||
return timeline_item;
|
||||
}
|
||||
/**
|
||||
* Get dates from given event
|
||||
*
|
||||
* @param {Object} record
|
||||
* @returns {Object}
|
||||
*/
|
||||
_get_event_dates(record) {
|
||||
let date_start = DateTime.now();
|
||||
let date_stop = null;
|
||||
const date_delay = record[this.date_delay] || false;
|
||||
date_start = this.parseDate(
|
||||
this.fields[this.date_start],
|
||||
record[this.date_start]
|
||||
);
|
||||
if (this.date_stop && record[this.date_stop]) {
|
||||
date_stop = this.parseDate(
|
||||
this.fields[this.date_stop],
|
||||
record[this.date_stop]
|
||||
);
|
||||
}
|
||||
if (!date_stop && date_delay) {
|
||||
date_stop = date_start.plus({hours: date_delay});
|
||||
}
|
||||
|
||||
return [date_start, date_stop];
|
||||
}
|
||||
/**
|
||||
* Parse Date or DateTime field
|
||||
*
|
||||
* @param {Object} field
|
||||
* @param {Object} value
|
||||
* @returns {DateTime} new_date in UTC timezone if field is datetime
|
||||
*/
|
||||
parseDate(field, value) {
|
||||
let new_date = parsers.get(field.type)(value);
|
||||
if (field.type === "datetime") {
|
||||
new_date = new_date.setZone("UTC", {keepLocalTime: true});
|
||||
}
|
||||
return new_date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a date or datetime value based on the field type.
|
||||
* to send it to the server.
|
||||
* @param {String} field_name - The field name.
|
||||
* @param {DateTime} value - The value to be serialized, either a date or datetime.
|
||||
* @returns {String} - The serialized date or datetime string.
|
||||
*/
|
||||
serializeDate(field_name, value) {
|
||||
const field = this.fields[field_name];
|
||||
return field.type === "date" ? serializeDate(value) : serializeDateTime(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render timeline item template.
|
||||
*
|
||||
* @param {Object} record Record
|
||||
* @private
|
||||
* @returns {String} Rendered template
|
||||
*/
|
||||
_render_timeline_item(record) {
|
||||
return renderToString(this.recordTemplate, {
|
||||
record: record,
|
||||
formatters,
|
||||
parsers,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Triggered upon confirm of removing a record.
|
||||
* @param {EventObject} event
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
async remove_completed(event) {
|
||||
await this.orm.call(this.model_name, "unlink", [[event.evt.id]]);
|
||||
const unlink_index = this.data.findIndex((item) => item.id === event.evt.id);
|
||||
if (unlink_index !== -1) {
|
||||
this.data.splice(unlink_index, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Triggered upon completion of a new record.
|
||||
* Updates the timeline view with the new record.
|
||||
*
|
||||
* @param {RecordId} id
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
async create_completed(id) {
|
||||
const records = await this.orm.call(this.model_name, "read", [
|
||||
[id],
|
||||
this.params.fieldNames,
|
||||
]);
|
||||
return this._event_data_transform(records[0]);
|
||||
}
|
||||
/**
|
||||
* Triggered upon completion of writing a record.
|
||||
* @param {Integer} id
|
||||
* @param {Object} vals
|
||||
*/
|
||||
async write_completed(id, vals) {
|
||||
return this.orm.call(this.model_name, "write", [id, vals]);
|
||||
}
|
||||
get canCreate() {
|
||||
return this.params.canCreate && this.create_right;
|
||||
}
|
||||
get canDelete() {
|
||||
return this.params.canDelete && this.unlink_right;
|
||||
}
|
||||
get canEdit() {
|
||||
return this.params.canUpdate && this.write_right;
|
||||
}
|
||||
}
|
||||
486
web_timeline/static/src/views/timeline/timeline_renderer.esm.js
Executable file
486
web_timeline/static/src/views/timeline/timeline_renderer.esm.js
Executable file
@@ -0,0 +1,486 @@
|
||||
/* global vis */
|
||||
/**
|
||||
* Copyright 2024 Tecnativa - Carlos López
|
||||
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
*/
|
||||
import {
|
||||
Component,
|
||||
onMounted,
|
||||
onWillStart,
|
||||
onWillUpdateProps,
|
||||
useRef,
|
||||
useState,
|
||||
} from "@odoo/owl";
|
||||
import {TimelineCanvas} from "./timeline_canvas.esm";
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
import {loadBundle} from "@web/core/assets";
|
||||
import {renderToString} from "@web/core/utils/render";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
|
||||
const {DateTime} = luxon;
|
||||
|
||||
export class TimelineRenderer extends Component {
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.rootRef = useRef("root");
|
||||
this.canvasRef = useRef("canvas");
|
||||
this.model = this.props.model;
|
||||
this.params = this.model.params;
|
||||
this.mode = useState({data: this.params.mode});
|
||||
this.options = this.params.options;
|
||||
this.min_height = this.params.min_height;
|
||||
this.date_start = this.params.date_start;
|
||||
this.dependency_arrow = this.params.dependency_arrow;
|
||||
this.fields = this.params.fields;
|
||||
this.timeline = false;
|
||||
this.initial_data_loaded = false;
|
||||
this.canvas_ref = renderToString("TimelineView.Canvas", {});
|
||||
onWillUpdateProps(async (props) => {
|
||||
this.on_data_loaded(props.model.data);
|
||||
});
|
||||
onWillStart(async () => {
|
||||
await loadBundle("web_timeline.vis-timeline_lib");
|
||||
});
|
||||
onMounted(() => {
|
||||
// Prevent Double Rendering on Updates
|
||||
if (!this.timeline) {
|
||||
this.init_timeline();
|
||||
}
|
||||
this.on_attach_callback();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when the timeline is attached to the DOM.
|
||||
*/
|
||||
on_attach_callback() {
|
||||
const $root = this.rootRef.el;
|
||||
if (this.params.class) {
|
||||
$root.classList.add(this.params.class);
|
||||
}
|
||||
if (this.rootRef.el) {
|
||||
const parentHeight = this.rootRef.el.parentElement?.clientHeight || 0;
|
||||
const buttonHeight =
|
||||
this.rootRef.el.querySelector(".oe_timeline_buttons")?.clientHeight ||
|
||||
0;
|
||||
const height = parentHeight - buttonHeight;
|
||||
if (height > this.min_height && this.timeline) {
|
||||
this.timeline.setOptions({
|
||||
height: height,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Set the timeline window to today (day).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onTodayClicked() {
|
||||
this.mode.data = "today";
|
||||
if (this.timeline) {
|
||||
this.timeline.setWindow({
|
||||
start: DateTime.now().toJSDate(),
|
||||
end: DateTime.now().plus({hours: 24}).toJSDate(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the timeline window to a day.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onScaleDayClicked() {
|
||||
this.mode.data = "day";
|
||||
this._scaleCurrentWindow(() => 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the timeline window to a week.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onScaleWeekClicked() {
|
||||
this.mode.data = "week";
|
||||
this._scaleCurrentWindow(() => 24 * 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the timeline window to a month.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onScaleMonthClicked() {
|
||||
this.mode.data = "month";
|
||||
this._scaleCurrentWindow((start) => 24 * start.daysInMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the timeline window to a year.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onScaleYearClicked() {
|
||||
this.mode.data = "year";
|
||||
this._scaleCurrentWindow((start) => 24 * (start.isInLeapYear ? 366 : 365));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales the timeline window based on the current window.
|
||||
*
|
||||
* @param {Function} getHoursFromStart Function which returns the timespan
|
||||
* (in hours) the window must be scaled to, starting from the "start" moment.
|
||||
* @private
|
||||
*/
|
||||
_scaleCurrentWindow(getHoursFromStart) {
|
||||
if (this.timeline) {
|
||||
const start = DateTime.fromJSDate(this.timeline.getWindow().start);
|
||||
const end = start.plus({hours: getHoursFromStart(start)});
|
||||
this.timeline.setWindow(start.toJSDate(), end.toJSDate());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the initial visible window.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_computeMode() {
|
||||
if (this.mode.data) {
|
||||
let start = false,
|
||||
end = false;
|
||||
const current_date = DateTime.now();
|
||||
switch (this.mode.data) {
|
||||
case "day":
|
||||
start = current_date.startOf("day");
|
||||
end = current_date.endOf("day");
|
||||
break;
|
||||
case "week":
|
||||
start = current_date.startOf("week");
|
||||
end = current_date.endOf("week");
|
||||
break;
|
||||
case "month":
|
||||
start = current_date.startOf("month");
|
||||
end = current_date.endOf("month");
|
||||
break;
|
||||
}
|
||||
if (end && start) {
|
||||
this.options.start = start.toJSDate();
|
||||
this.options.end = end.toJSDate();
|
||||
} else {
|
||||
this.mode.data = "fit";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the timeline
|
||||
* (https://visjs.github.io/vis-timeline/docs/timeline).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
init_timeline() {
|
||||
this._computeMode();
|
||||
this.options.editable = {};
|
||||
if (this.model.canEdit) {
|
||||
this.options.onMove = this.on_move.bind(this);
|
||||
this.options.onUpdate = this.on_update.bind(this);
|
||||
// Drag items horizontally
|
||||
this.options.editable.updateTime = true;
|
||||
// Drag items from one group to another
|
||||
this.options.editable.updateGroup = true;
|
||||
if (this.model.canCreate) {
|
||||
this.options.onAdd = this.on_add.bind(this);
|
||||
// Add new items by double tapping
|
||||
this.options.editable.add = true;
|
||||
}
|
||||
}
|
||||
if (this.model.canDelete) {
|
||||
this.options.onRemove = this.on_remove.bind(this);
|
||||
// Delete an item by tapping the delete button top right
|
||||
this.options.editable.remove = true;
|
||||
}
|
||||
// Configure XSS filtering options to mitigate potential security risks.
|
||||
// Disabling XSS filtering can lead to vulnerabilities, as highlighted in:
|
||||
// - CVE-2020-28487 (https://www.cve.org/CVERecord?id=CVE-2020-28487)
|
||||
// - https://github.com/visjs/vis-timeline/pull/840
|
||||
// The solution is to define a whitelist of allowed HTML elements and attributes.
|
||||
// TODO: Check if this can be removed when this PR is merged: https://github.com/visjs/vis-timeline/pull/1860
|
||||
this.options.xss = {
|
||||
filterOptions: {
|
||||
whiteList: this.getXSSWhiteList(),
|
||||
},
|
||||
};
|
||||
this.timeline = new vis.Timeline(this.canvasRef.el, {}, this.options);
|
||||
this.timeline.on("click", this.on_timeline_click.bind(this));
|
||||
if (!this.options.onUpdate) {
|
||||
// In read-only mode, catch double-clicks this way.
|
||||
this.timeline.on("doubleClick", this.on_timeline_double_click.bind(this));
|
||||
}
|
||||
this.$centerContainer = this.timeline.dom.centerContainer;
|
||||
this.canvas = new TimelineCanvas(this.canvas_ref);
|
||||
if (this.$centerContainer.el) {
|
||||
this.$centerContainer.el.appendChild(this.canvas_ref);
|
||||
}
|
||||
this.timeline.on("changed", () => {
|
||||
this.draw_canvas();
|
||||
this.load_initial_data();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Returns the XSS whitelist for the timeline library.
|
||||
* This is used to filter out potentially harmful HTML elements and attributes.
|
||||
* The white list allows only specific elements and attributes to be rendered.
|
||||
* This is important for security reasons, as it helps prevent XSS attacks.
|
||||
* @returns {Object} The XSS white list.
|
||||
* Key: element name; value: array of allowed attributes.
|
||||
*/
|
||||
getXSSWhiteList() {
|
||||
// Add more elements to the whitelist as needed.
|
||||
return {
|
||||
b: [],
|
||||
div: ["class", "style"],
|
||||
span: ["class", "name"],
|
||||
small: ["class", "name"],
|
||||
img: ["src", "width", "height", "alt", "loading", "class"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears and draws the canvas items.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
draw_canvas() {
|
||||
this.canvas.clear();
|
||||
if (this.dependency_arrow) {
|
||||
this.draw_dependencies();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw item dependencies on canvas.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
draw_dependencies() {
|
||||
const items = this.timeline.itemSet.items;
|
||||
const datas = this.timeline.itemsData;
|
||||
if (!items || !datas) {
|
||||
return;
|
||||
}
|
||||
const keys = Object.keys(items);
|
||||
for (const key of keys) {
|
||||
const item = items[key];
|
||||
const data = datas.get(Number(key));
|
||||
if (!data || !data.evt) {
|
||||
return;
|
||||
}
|
||||
for (const id of data.evt[this.dependency_arrow]) {
|
||||
if (keys.indexOf(id.toString()) !== -1) {
|
||||
this.draw_dependency(item, items[id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a dependency arrow between 2 timeline items.
|
||||
*
|
||||
* @param {Object} from Start timeline item
|
||||
* @param {Object} to Destination timeline item
|
||||
* @param {Object} options
|
||||
* @param {Object} options.line_color Color of the line
|
||||
* @param {Object} options.line_width The width of the line
|
||||
* @private
|
||||
*/
|
||||
draw_dependency(from, to, options) {
|
||||
if (!from.displayed || !to.displayed) {
|
||||
return;
|
||||
}
|
||||
const defaults = Object.assign({line_color: "black", line_width: 1}, options);
|
||||
this.canvas.draw_arrow(
|
||||
from.dom.box,
|
||||
to.dom.box,
|
||||
defaults.line_color,
|
||||
defaults.line_width
|
||||
);
|
||||
}
|
||||
|
||||
/* Load initial data. This is called once after each redraw; we only handle the first one.
|
||||
* Deferring this initial load here avoids rendering issues. */
|
||||
load_initial_data() {
|
||||
if (!this.initial_data_loaded) {
|
||||
this.on_data_loaded(this.model.data);
|
||||
this.initial_data_loaded = true;
|
||||
this.timeline.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set groups and events.
|
||||
*
|
||||
* @param {Object[]} records
|
||||
* @param {Boolean} adjust_window
|
||||
* @private
|
||||
*/
|
||||
async on_data_loaded(records, adjust_window) {
|
||||
const data = [];
|
||||
for (const record of records) {
|
||||
if (record[this.date_start]) {
|
||||
data.push(this.model._event_data_transform(record));
|
||||
}
|
||||
}
|
||||
const groups = await this.split_groups(records);
|
||||
this.timeline.setGroups(groups);
|
||||
this.timeline.setItems(data);
|
||||
const mode = !this.mode.data || this.mode.data === "fit";
|
||||
const adjust = typeof adjust_window === "undefined" || adjust_window;
|
||||
if (mode && adjust) {
|
||||
this.timeline.fit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the groups.
|
||||
*
|
||||
* @param {Object[]} records
|
||||
* @private
|
||||
* @returns {Array}
|
||||
*/
|
||||
async split_groups(records) {
|
||||
if (this.model.last_group_bys.length === 0) {
|
||||
return records;
|
||||
}
|
||||
const groups = [];
|
||||
groups.push({id: -1, content: _t("<b>UNASSIGNED</b>"), order: -1});
|
||||
var seq = 1;
|
||||
for (const evt of records) {
|
||||
const grouped_field = this.model.last_group_bys[0];
|
||||
const group_name = evt[grouped_field];
|
||||
if (group_name && group_name instanceof Array) {
|
||||
const group = groups.find(
|
||||
(existing_group) => existing_group.id === group_name[0]
|
||||
);
|
||||
if (group) {
|
||||
continue;
|
||||
}
|
||||
// Check if group is m2m in this case add id -> value of all
|
||||
// found entries.
|
||||
if (this.fields[grouped_field].type === "many2many") {
|
||||
const list_values = await this.get_m2m_grouping_datas(
|
||||
this.fields[grouped_field].relation,
|
||||
group_name
|
||||
);
|
||||
for (const vals of list_values) {
|
||||
const is_inside = groups.some((gr) => gr.id === vals.id);
|
||||
if (!is_inside) {
|
||||
vals.order = seq;
|
||||
seq += 1;
|
||||
groups.push(vals);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
groups.push({
|
||||
id: group_name[0],
|
||||
content: group_name[1],
|
||||
order: seq,
|
||||
});
|
||||
seq += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
async get_m2m_grouping_datas(model, group_name) {
|
||||
const groups = [];
|
||||
for (const gr of group_name) {
|
||||
const record_info = await this.orm.call(model, "read", [
|
||||
gr,
|
||||
["display_name"],
|
||||
]);
|
||||
groups.push({id: record_info[0].id, content: record_info[0].display_name});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a click within the timeline.
|
||||
*
|
||||
* @param {Object} e
|
||||
* @private
|
||||
*/
|
||||
on_timeline_click(e) {
|
||||
if (e.what === "group-label" && e.group !== -1) {
|
||||
this.props.onGroupClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a double-click within the timeline.
|
||||
*
|
||||
* @param {Object} e
|
||||
* @private
|
||||
*/
|
||||
on_timeline_double_click(e) {
|
||||
if (e.what === "item" && e.item !== -1) {
|
||||
this.props.onItemDoubleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger onUpdate.
|
||||
*
|
||||
* @param {Object} item
|
||||
* @private
|
||||
*/
|
||||
on_update(item) {
|
||||
this.props.onUpdate(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger onMove.
|
||||
*
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
* @private
|
||||
*/
|
||||
on_move(item, callback) {
|
||||
this.props.onMove(item, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger onRemove.
|
||||
*
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
* @private
|
||||
*/
|
||||
on_remove(item, callback) {
|
||||
this.props.onRemove(item, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger onAdd.
|
||||
*
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
* @private
|
||||
*/
|
||||
on_add(item, callback) {
|
||||
this.props.onAdd(item, callback);
|
||||
}
|
||||
}
|
||||
|
||||
TimelineRenderer.template = "web_timeline.TimelineRenderer";
|
||||
TimelineRenderer.props = {
|
||||
model: Object,
|
||||
onAdd: Function,
|
||||
onGroupClick: Function,
|
||||
onItemDoubleClick: Function,
|
||||
onMove: Function,
|
||||
onRemove: Function,
|
||||
onUpdate: Function,
|
||||
};
|
||||
46
web_timeline/static/src/views/timeline/timeline_renderer.xml
Executable file
46
web_timeline/static/src/views/timeline/timeline_renderer.xml
Executable file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web_timeline.TimelineRenderer">
|
||||
<div class="oe_timeline_view" t-ref="root">
|
||||
<div class="oe_timeline_buttons">
|
||||
<button
|
||||
t-att-class="'oe_timeline_button_today btn ' + (mode.data == 'today' ? ' btn-primary' : 'btn-default')"
|
||||
t-on-click="_onTodayClicked"
|
||||
>Today</button>
|
||||
<div class="btn-group btn-sm">
|
||||
<button
|
||||
t-att-class="'oe_timeline_button_scale_day btn ' + (mode.data == 'day' ? ' btn-primary' : 'btn-default')"
|
||||
t-on-click="_onScaleDayClicked"
|
||||
>Day</button>
|
||||
<button
|
||||
t-att-class="'oe_timeline_button_scale_week btn ' + (mode.data == 'week' ? ' btn-primary' : 'btn-default')"
|
||||
t-on-click="_onScaleWeekClicked"
|
||||
>Week</button>
|
||||
<button
|
||||
t-att-class="'oe_timeline_button_scale_month btn ' + (mode.data == 'month' ? ' btn-primary' : 'btn-default')"
|
||||
t-on-click="_onScaleMonthClicked"
|
||||
>Month</button>
|
||||
<button
|
||||
t-att-class="'oe_timeline_button_scale_year btn ' + (mode.data == 'year' ? ' btn-primary' : 'btn-default')"
|
||||
t-on-click="_onScaleYearClicked"
|
||||
>Year</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oe_timeline_widget" t-ref="canvas" />
|
||||
</div>
|
||||
</t>
|
||||
<svg t-name="TimelineView.Canvas" class="oe_timeline_view_canvas">
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="10"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="10 0, 10 7, 0 3.5" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</templates>
|
||||
47
web_timeline/static/src/views/timeline/timeline_view.esm.js
Executable file
47
web_timeline/static/src/views/timeline/timeline_view.esm.js
Executable file
@@ -0,0 +1,47 @@
|
||||
/* Odoo web_timeline
|
||||
* Copyright 2015 ACSONE SA/NV
|
||||
* Copyright 2016 Pedro M. Baeza <pedro.baeza@tecnativa.com>
|
||||
* Copyright 2023 Onestein - Anjeel Haria
|
||||
* Copyright 2024 Tecnativa - Carlos López
|
||||
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
|
||||
|
||||
import {TimelineArchParser} from "./timeline_arch_parser.esm";
|
||||
import {TimelineController} from "./timeline_controller.esm";
|
||||
import {TimelineModel} from "./timeline_model.esm";
|
||||
import {TimelineRenderer} from "./timeline_renderer.esm";
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
import {registry} from "@web/core/registry";
|
||||
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
export const TimelineView = {
|
||||
display_name: _t("Timeline"),
|
||||
icon: "fa fa-tasks",
|
||||
multiRecord: true,
|
||||
ArchParser: TimelineArchParser,
|
||||
Controller: TimelineController,
|
||||
Renderer: TimelineRenderer,
|
||||
Model: TimelineModel,
|
||||
jsLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js"],
|
||||
cssLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css"],
|
||||
type: "timeline",
|
||||
|
||||
props: (genericProps, view) => {
|
||||
const {arch, fields, resModel} = genericProps;
|
||||
const parser = new view.ArchParser();
|
||||
const archInfo = parser.parse(arch, fields);
|
||||
const modelParams = {
|
||||
...archInfo,
|
||||
resModel: resModel,
|
||||
fields: fields,
|
||||
};
|
||||
|
||||
return {
|
||||
...genericProps,
|
||||
modelParams,
|
||||
Model: view.Model,
|
||||
Renderer: view.Renderer,
|
||||
};
|
||||
},
|
||||
};
|
||||
viewRegistry.add("timeline", TimelineView);
|
||||
28
web_timeline/static/src/views/timeline/timeline_view.scss
Executable file
28
web_timeline/static/src/views/timeline/timeline_view.scss
Executable file
@@ -0,0 +1,28 @@
|
||||
$vis-hover-background-color: linen;
|
||||
$vis-weekend-background-color: #dcdcdc;
|
||||
$vis-item-content-padding: 0 3px !important;
|
||||
|
||||
.oe_timeline_view .vis-timeline {
|
||||
.vis-grid {
|
||||
&.vis-saturday,
|
||||
&.vis-sunday {
|
||||
background: $vis-weekend-background-color;
|
||||
}
|
||||
}
|
||||
|
||||
.vis-item {
|
||||
&:hover {
|
||||
background-color: $vis-hover-background-color !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.vis-item-overflow {
|
||||
overflow: visible;
|
||||
}
|
||||
.vis-item-content {
|
||||
padding: $vis-item-content-padding;
|
||||
&:hover {
|
||||
background-color: $vis-hover-background-color !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
web_timeline/static/tests/helpers.esm.js
Executable file
6
web_timeline/static/tests/helpers.esm.js
Executable file
@@ -0,0 +1,6 @@
|
||||
export const FAKE_ORDER_FIELDS = {
|
||||
display_name: {string: "Display Name", type: "char"},
|
||||
date_start: {string: "Date start", type: "date"},
|
||||
date_end: {string: "Date end", type: "date"},
|
||||
partner_id: {string: "Partner", type: "many2one", relation: "partner"},
|
||||
};
|
||||
139
web_timeline/static/tests/web_timeline_arch_parser_tests.esm.js
Executable file
139
web_timeline/static/tests/web_timeline_arch_parser_tests.esm.js
Executable file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
TimelineArchParser,
|
||||
TimelineParseArchError,
|
||||
} from "@web_timeline/views/timeline/timeline_arch_parser.esm";
|
||||
import {FAKE_ORDER_FIELDS} from "./helpers.esm";
|
||||
import {parseXML} from "@web/core/utils/xml";
|
||||
|
||||
function parseArch(arch) {
|
||||
const parser = new TimelineArchParser();
|
||||
const xmlDoc = parseXML(arch);
|
||||
return parser.parse(xmlDoc, FAKE_ORDER_FIELDS);
|
||||
}
|
||||
|
||||
function check(assert, paramName, paramValue, expectedName, expectedValue) {
|
||||
const arch = `<timeline date_start="start_date" default_group_by="partner_id" ${paramName}="${paramValue}" />`;
|
||||
const data = parseArch(arch);
|
||||
assert.strictEqual(data[expectedName], expectedValue);
|
||||
}
|
||||
|
||||
QUnit.module("TimelineView - ArchParser");
|
||||
|
||||
QUnit.test("throw if date_start is not set", (assert) => {
|
||||
assert.throws(
|
||||
() => parseArch(`<timeline default_group_by="partner_id"/>`),
|
||||
TimelineParseArchError
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("throw if default_group_by is not set", (assert) => {
|
||||
assert.throws(
|
||||
() => parseArch(`<timeline date_start="date_start"/>`),
|
||||
TimelineParseArchError
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("hasEditDialog", (assert) => {
|
||||
check(assert, "event_open_popup", "", "open_popup_action", false);
|
||||
check(assert, "event_open_popup", "true", "open_popup_action", true);
|
||||
check(assert, "event_open_popup", "True", "open_popup_action", true);
|
||||
check(assert, "event_open_popup", "1", "open_popup_action", true);
|
||||
check(assert, "event_open_popup", "false", "open_popup_action", false);
|
||||
check(assert, "event_open_popup", "False", "open_popup_action", false);
|
||||
check(assert, "event_open_popup", "0", "open_popup_action", false);
|
||||
});
|
||||
|
||||
QUnit.test("create", (assert) => {
|
||||
check(assert, "create", "", "canCreate", true);
|
||||
check(assert, "create", "true", "canCreate", true);
|
||||
check(assert, "create", "True", "canCreate", true);
|
||||
check(assert, "create", "1", "canCreate", true);
|
||||
check(assert, "create", "false", "canCreate", false);
|
||||
check(assert, "create", "False", "canCreate", false);
|
||||
check(assert, "create", "0", "canCreate", false);
|
||||
check(assert, "create", "12", "canCreate", true);
|
||||
});
|
||||
|
||||
QUnit.test("edit", (assert) => {
|
||||
check(assert, "edit", "", "canUpdate", true);
|
||||
check(assert, "edit", "true", "canUpdate", true);
|
||||
check(assert, "edit", "True", "canUpdate", true);
|
||||
check(assert, "edit", "1", "canUpdate", true);
|
||||
check(assert, "edit", "false", "canUpdate", false);
|
||||
check(assert, "edit", "False", "canUpdate", false);
|
||||
check(assert, "edit", "0", "canUpdate", false);
|
||||
check(assert, "edit", "12", "canUpdate", true);
|
||||
});
|
||||
|
||||
QUnit.test("delete", (assert) => {
|
||||
check(assert, "delete", "", "canDelete", true);
|
||||
check(assert, "delete", "true", "canDelete", true);
|
||||
check(assert, "delete", "True", "canDelete", true);
|
||||
check(assert, "delete", "1", "canDelete", true);
|
||||
check(assert, "delete", "false", "canDelete", false);
|
||||
check(assert, "delete", "False", "canDelete", false);
|
||||
check(assert, "delete", "0", "canDelete", false);
|
||||
check(assert, "delete", "12", "canDelete", true);
|
||||
});
|
||||
|
||||
QUnit.test("mode", (assert) => {
|
||||
check(assert, "mode", "day", "mode", "day");
|
||||
check(assert, "mode", "week", "mode", "week");
|
||||
check(assert, "mode", "month", "mode", "month");
|
||||
assert.throws(() => {
|
||||
parseArch(
|
||||
`<timeline date_start="start_date" default_group_by="partner_id" mode="other" />`
|
||||
);
|
||||
}, TimelineParseArchError);
|
||||
|
||||
assert.throws(() => {
|
||||
parseArch(
|
||||
`<timeline date_start="start_date" default_group_by="partner_id" mode="" />`
|
||||
);
|
||||
}, TimelineParseArchError);
|
||||
});
|
||||
|
||||
QUnit.test("colors", (assert) => {
|
||||
const archInfo = parseArch(`
|
||||
<timeline date_start="start_date" default_group_by="partner_id" colors="gray: state == 'cancel'; #ec7063: state == 'done'"/>
|
||||
`);
|
||||
assert.strictEqual(archInfo.colors.length, 2, "colors should be 2");
|
||||
assert.strictEqual(archInfo.colors[0].field, "state", "field should be state");
|
||||
assert.strictEqual(archInfo.colors[0].color, "gray", "color should be gray");
|
||||
assert.strictEqual(
|
||||
archInfo.colors[0].ast.left.value,
|
||||
"state",
|
||||
"ast left value should be state"
|
||||
);
|
||||
assert.strictEqual(archInfo.colors[0].ast.op, "==", "ast op value should be '=='");
|
||||
assert.strictEqual(
|
||||
archInfo.colors[0].ast.right.value,
|
||||
"cancel",
|
||||
"ast right value should be cancel"
|
||||
);
|
||||
assert.ok(
|
||||
archInfo.fieldNames.includes("state"),
|
||||
"fieldNames should include field state"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("templates", (assert) => {
|
||||
const archInfo = parseArch(`
|
||||
<timeline date_start="start_date" default_group_by="partner_id">
|
||||
<field name="other_field" />
|
||||
<templates>
|
||||
<t t-name="timeline-item">
|
||||
<span t-out="record.other_field" />
|
||||
</t>
|
||||
</templates>
|
||||
</timeline>
|
||||
`);
|
||||
assert.ok(
|
||||
archInfo.templateDocs.hasOwnProperty("timeline-item"),
|
||||
"template name should be timeline-item"
|
||||
);
|
||||
assert.ok(
|
||||
archInfo.fieldNames.includes("other_field"),
|
||||
"fieldNames should include field other_field"
|
||||
);
|
||||
});
|
||||
193
web_timeline/static/tests/web_timeline_view_tests.esm.js
Executable file
193
web_timeline/static/tests/web_timeline_view_tests.esm.js
Executable file
@@ -0,0 +1,193 @@
|
||||
import {click, getFixture} from "@web/../tests/helpers/utils";
|
||||
import {makeView, setupViewRegistries} from "@web/../tests/views/helpers";
|
||||
import {FAKE_ORDER_FIELDS} from "./helpers.esm";
|
||||
import {loadBundle} from "@web/core/assets";
|
||||
|
||||
let serverData = {};
|
||||
let target = null;
|
||||
|
||||
QUnit.module("Views", (hooks) => {
|
||||
loadBundle("web_timeline.vis-timeline_lib");
|
||||
hooks.beforeEach(async () => {
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
name: {string: "Name", type: "char"},
|
||||
},
|
||||
records: [
|
||||
{id: 1, name: "Partner 1"},
|
||||
{id: 2, name: "Partner 2"},
|
||||
{id: 3, name: "Partner 3"},
|
||||
],
|
||||
},
|
||||
order: {
|
||||
fields: FAKE_ORDER_FIELDS,
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "Record 1",
|
||||
date_start: "2024-01-01",
|
||||
date_end: "2024-01-02",
|
||||
partner_id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
display_name: "Record 2",
|
||||
date_start: "2024-01-03",
|
||||
date_end: "2024-02-05",
|
||||
partner_id: 1,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
display_name: "Record 3",
|
||||
date_start: "2024-01-10",
|
||||
date_end: "2024-01-15",
|
||||
partner_id: 2,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
display_name: "Record 4",
|
||||
date_start: "2024-01-15",
|
||||
date_end: "2024-02-01",
|
||||
partner_id: 3,
|
||||
},
|
||||
],
|
||||
methods: {
|
||||
check_access_rights() {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setupViewRegistries();
|
||||
target = getFixture();
|
||||
});
|
||||
|
||||
QUnit.module("TimelineView - View");
|
||||
|
||||
QUnit.test("Test basic timeline view", async (assert) => {
|
||||
await makeView({
|
||||
type: "timeline",
|
||||
resModel: "order",
|
||||
serverData,
|
||||
arch: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id"/>',
|
||||
});
|
||||
assert.containsOnce(target, ".oe_timeline_view");
|
||||
});
|
||||
|
||||
QUnit.test("click today slot", async (assert) => {
|
||||
await makeView({
|
||||
type: "timeline",
|
||||
resModel: "order",
|
||||
serverData,
|
||||
arch: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id"/>',
|
||||
});
|
||||
const $today = target.querySelector(".oe_timeline_button_today");
|
||||
const $day = target.querySelector(".oe_timeline_button_scale_day");
|
||||
const $week = target.querySelector(".oe_timeline_button_scale_week");
|
||||
const $month = target.querySelector(".oe_timeline_button_scale_month");
|
||||
const $year = target.querySelector(".oe_timeline_button_scale_year");
|
||||
await click($today);
|
||||
assert.hasClass(
|
||||
$today,
|
||||
"btn-primary",
|
||||
"today should have classnames btn-primary"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
$day,
|
||||
"btn-primary",
|
||||
"day should no have classnames btn-primary"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
$week,
|
||||
"btn-primary",
|
||||
"week should no have classnames btn-primary"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
$month,
|
||||
"btn-primary",
|
||||
"month should no have classnames btn-primary"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
$year,
|
||||
"btn-primary",
|
||||
"year should no have classnames btn-primary"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("click month slot", async (assert) => {
|
||||
await makeView({
|
||||
type: "timeline",
|
||||
resModel: "order",
|
||||
serverData,
|
||||
arch: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id"/>',
|
||||
});
|
||||
const $today = target.querySelector(".oe_timeline_button_today");
|
||||
const $day = target.querySelector(".oe_timeline_button_scale_day");
|
||||
const $week = target.querySelector(".oe_timeline_button_scale_week");
|
||||
const $month = target.querySelector(".oe_timeline_button_scale_month");
|
||||
const $year = target.querySelector(".oe_timeline_button_scale_year");
|
||||
await click($month);
|
||||
assert.hasClass(
|
||||
$month,
|
||||
"btn-primary",
|
||||
"month should have classnames btn-primary"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
$today,
|
||||
"btn-primary",
|
||||
"today should no have classnames btn-primary"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
$day,
|
||||
"btn-primary",
|
||||
"day should no have classnames btn-primary"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
$week,
|
||||
"btn-primary",
|
||||
"week should no have classnames btn-primary"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
$year,
|
||||
"btn-primary",
|
||||
"year should no have classnames btn-primary"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Check button delete", async (assert) => {
|
||||
await makeView({
|
||||
type: "timeline",
|
||||
resModel: "order",
|
||||
serverData,
|
||||
arch: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id"/>',
|
||||
});
|
||||
const $elements = [...target.querySelectorAll(".vis-item-content")];
|
||||
const $item_contents = $elements.filter((el) =>
|
||||
el.textContent.includes("Record 2")
|
||||
);
|
||||
assert.strictEqual($item_contents.length, 1, "items should be 1");
|
||||
const $item_content = $item_contents[0];
|
||||
await click($item_content);
|
||||
assert.containsOnce($item_content.parentElement, ".vis-delete");
|
||||
});
|
||||
|
||||
QUnit.test("Check button delete disabled", async (assert) => {
|
||||
await makeView({
|
||||
type: "timeline",
|
||||
resModel: "order",
|
||||
serverData,
|
||||
arch: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id" delete="0"/>',
|
||||
});
|
||||
const $elements = [...target.querySelectorAll(".vis-item-content")];
|
||||
const $item_contents = $elements.filter((el) =>
|
||||
el.textContent.includes("Record 2")
|
||||
);
|
||||
assert.strictEqual($item_contents.length, 1, "items should be 1");
|
||||
const $item_content = $item_contents[0];
|
||||
await click($item_content);
|
||||
assert.containsNone($item_content.parentElement, ".vis-delete");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user