Mercurial > p > roundup > code
changeset 8285:2bf0c4e7795e
fix: issue2551390 - Replace text input/calendar popup with native date input
Docs, code and test changes for the changeover to a native date
element.
See issue for details.
line wrap: on
line diff
--- a/CHANGES.txt Sat Jan 18 11:20:20 2025 -0500 +++ b/CHANGES.txt Sat Jan 18 12:23:23 2025 -0500 @@ -77,6 +77,10 @@ - issue2551391, partial fix for issue1513369. input fields were not getting id's assigned. Fixed automatic id assignment to input fields. Thinko in the code. (John Rouillard) +- issue2551390 - Replace text input/calendar popup with native + date input. Also add double-click and exit keyboard handlers to + allow copy/paste/editing the text version of the date. (John + Rouillard) Features:
--- a/doc/customizing.txt Sat Jan 18 11:20:20 2025 -0500 +++ b/doc/customizing.txt Sat Jan 18 12:23:23 2025 -0500 @@ -90,11 +90,11 @@ <td tal:content="structure context/due_date/field" /> </tr> - If you want to show only the date part of due_date then do this instead:: + If you want to show the date and time for due_date then do this instead:: <tr> <th>Due Date</th> - <td tal:content="structure python:context.due_date.field(format='%Y-%m-%d')" /> + <td tal:content="structure context/due_date/field_time" /> </tr> 3. Add the property to the ``issue.index.html`` page::
--- a/doc/reference.txt Sat Jan 18 11:20:20 2025 -0500 +++ b/doc/reference.txt Sat Jan 18 12:23:23 2025 -0500 @@ -3070,17 +3070,28 @@ tri-state yes/no/neither selection. This method may take some arguments: - size + size (default 30) Sets the width in characters of the edit field format (Date properties only) - Sets the format of the date in the field - uses the same - format string argument as supplied to the ``pretty`` method - below. + Sets the format of the date in the field - uses the + same format string argument as supplied to the + ``pretty`` method below. If you use this, it will + prevent the use of browser native date inputs. It is + useful if you want partial dates. For example using + ``format="%Y-%m"`` with ``type="text"`` will display a + text edit box with the year and month part of your + date. + + type (depends on property type) + Sets the type property of the input. To change a date + property field from a native date input to a text + input you would use ``type="text"``. popcal (Date properties only) - Include the JavaScript-based popup calendar for date - selection. Defaults to on. + Include a link to the JavaScript-based popup calendar + for date selection. Defaults to off/False since native + date inputs supply popup calendars. y_label, n_label, u_label (Boolean properties only) Set the labels for the true/false/undefined @@ -3104,6 +3115,10 @@ attribute without a value. This is useful for boolean properties like ``required``. + field_time (Date properties only) + Create a browser native input for editing date and time. + The field method creates an input for editing + month/day/year (without time). rst only on String properties - render the value of the property as ReStructuredText (requires the :ref:`Docutils @@ -3164,7 +3179,11 @@ is returned if the value is ``None`` otherwise it is converted to a string. - popcal Generate a link to a popup calendar which may be used to + popcal This is deprecated with Roundup 2.5 which uses the + native HTML5 date input. The native date input + includes a calendar popup on modern broswers. + + Generate a link to a popup calendar which may be used to edit the date field, for example:: <span tal:replace="structure context/due/popcal" /> @@ -4027,6 +4046,17 @@ comma-separated value content (i.e. something to load into a spreadsheet or database). +CSS for the web interface +------------------------- + +The web interface can be completely redesigned by the admin, However +some parts of Roundup use classes or set attributes that can be +selected by css to change the look of the element. + +The ``datecopy.js`` module used to allow editing a date value with a +text input assigns the ``mode_textdate`` class to the input when it is +in text mode. The class is removed when it is not in text mode. + 8-bit character set support in Web interface --------------------------------------------
--- a/doc/upgrading.txt Sat Jan 18 11:20:20 2025 -0500 +++ b/doc/upgrading.txt Sat Jan 18 12:23:23 2025 -0500 @@ -224,6 +224,148 @@ .. _defusedxml: https://pypi.org/project/defusedxml/ +Use native date inputs (optional) +--------------------------------- + +Roundup now uses native date or datetime-local inputs for Date() +properties. These inputs take the place of the text input and +calendar popup from earlier Roundup versions. Modern browsers +come with a built-in calendar for date selection, so the +``(cal)`` calendar link is no longer needed. These native inputs +show the date based on the browser's locale and translate terms +into the local language. + +If you do nothing, simple uses of the field() method will +generate date inputs to allow selection of a date. Input fields +for Date() properties will not have the ``(cal)`` link +anymore. Complex uses will not be upgraded and will operate like +earlier Roundup versions. + +To upgrade all date properties, there are four changes to make: + + 1. Replace ``field`` calls with ``field_time`` where needed. + + 2. Remove the format argument from field() calls on Date() + properties. + + 3. Remove popcal() calls. + + 4. Include datecopy.js in page.html. + +Use field_time() where needed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The format used by ``field`` does not include hours, minutes or +seconds. If your users need to enter times, you should change +these calls to use ``field_time``. The arguments are the same as +for field. + +Remove format argument +~~~~~~~~~~~~~~~~~~~~~~ + +Speaking of arguments, avoid setting the date format if you want +to use native date inputs. The date value needs a specific format +for date or datetime-local inputs. If you include the `format` +argument in the `field` method, it should be removed. + +The `field` method uses the format ``%Y-%m-%d``. The +``field_time`` method uses the format ``%Y-%m-%dT%H:%M:%S``. If +you use these exact formats, Roundup will accept them and use a +native date input. + +.. highlight:: text + +If you use an format that doesn't match, you will see a text +input and a logged warning message like:: + + Format '%Y-%m' prevents use of modern date input. + Remove format from field() call in template test.item. + Using text input. + +.. highlight:: default + +The log message will appear if your logging level is set to +WARNING or lower. (Refer to your tracker's :ref:`config.ini +logging section <config-ini-section-logging>` for details on +logging levels.) + +If you want to use a text input for a specific date format, you +can add ``type="text"`` to the field() argument list to suppress +the warning. By default using a format argument will show the +popup calendar link. You can disable the link by setting +``popcal=False`` in the field() call. If you have:: + + tal:content="structure python:context.duedate.field( + placeholder='YYYY-MM, format='%Y-%m')" + +changing it to:: + + tal:content="structure python:context.duedate.field( + type='text', + placeholder='YYYY-MM, format='%Y-%m', + popcal=False)" + +will generate the input as in Roundup 2.4 or earlier without a +popcal link. + +If you are using a path expression like:: + + tal:content="context/duedate/field" + +change it to:: + + tal:content="structure python:context.duedate.field( + type='text')" + +to get the input from before Roundup 2.5 with a popcal link. + +Remove popcal +~~~~~~~~~~~~~ + +If you use the ``popcal()`` method directly in your templates, you +can remove them. The browser's native date selection calendar can +be used instead. + +Add copy/paste/edit on double-click using datecopy.js +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There is no way to copy/paste using a native datetime-local or +date input. With the datecopy.js file installed, double-clicking +on the input turns it into a normal text input with the ability +to copy, paste, or manually edit the date. + +To set this up, take either ``datecopy.js`` or the smaller +version, ``datecopy.min.js``, from the ``html`` folder of the +classic tracker template. Put the file in the ``html`` folder of +your tracker home. + +After you install the datecopy file, you can add the script +directly to a page using:: + + <script tal:attributes="nonce request/client/client_nonce" + tal:content="structure python:utils.readfile('datecopy.min.js')"> + </script> + +or get the file in a separate download using a regular script +tag:: + + <script type="text/javascript" src="@@file/datecopy.js"> + </script> + +You can place these at the end of ``page.html`` just before the +close body ``</body>`` tag. This is the method used in the +classic template. This forces the file to be run for every page +even those that don't have any date inputs. However, it is cached +after the first download. + +Alternatively you can inline or link to it using a script tag +only on pages that will have a date input. For example +``issue.item.html``. + +There is no support for activating text mode using the +keyboard. Tablet/touch support is mixed. Chrome supports +double-tap to activate text mode input. Firefox does not. + Change in REST response for invalid CORS requests (info) --------------------------------------------------------
--- a/doc/user_guide.txt Sat Jan 18 11:20:20 2025 -0500 +++ b/doc/user_guide.txt Sat Jan 18 12:23:23 2025 -0500 @@ -177,6 +177,14 @@ Date properties ~~~~~~~~~~~~~~~ +Date properties are usually shown using a native HTML date +element. This provides a calendar button for choosing the +date. The date is shown in the normal format for your location. + +Native date inputs do not allow the use of partial forms as +defined below. For this reason, you may edit a date/time +stamp directly. + Date-and-time stamps are specified with the date in international standard format (``yyyy-mm-dd``) joined to the time (``hh:mm:ss``) by a period ``.``. Dates in this form can be easily @@ -191,7 +199,7 @@ interpreted in the user's local time zone. The Date constructor takes care of these conversions. In the following examples, suppose that ``yyyy`` is the current year, ``mm`` is the current month, and ``dd`` is -the current day of the month. +the current day of the month and the local timezone is GMT-5. - "2000-04-17" means <Date 2000-04-17.00:00:00> - "01-25" means <Date yyyy-01-25.00:00:00> @@ -202,6 +210,30 @@ - "8:47:11" means <Date yyyy-mm-dd.13:47:11> - the special date "." means "right now" +The native date input doesn't allow copy or paste. Roundup +enhances the native date field. If you double-click on a native +date field, it changes to a text input mode with the date already +selected. You can use control-C to copy the date or control-V to +paste into the field. Double-clicking also lets you add seconds +in a date-time value if you need to. + +It will switch back to a date input and save the value when: + +- you move to another field using the mouse or the Tab key. +- you press enter/return (press return again if you want to + submit the form). + +If you press Escape, it will restore the original value and +change back to a date input. + +When using native date elements in text input mode, the date +looks like a full data with ``T`` replacing ``.``. If the ``T`` +is missing, the native date elements will not recognize the value +as a date. + +There is no support for activating text mode using the +keyboard. Tablet/touch support is mixed. Chrome supports +double-tap to activate text mode input. Firefox does not. When searching, a plain date entered as a search field will match that date exactly in the database. We may also accept ranges of dates. You can
--- a/roundup/cgi/templating.py Sat Jan 18 11:20:20 2025 -0500 +++ b/roundup/cgi/templating.py Sat Jan 18 12:23:23 2025 -0500 @@ -2252,7 +2252,16 @@ return DateHTMLProperty(self._client, self._classname, self._nodeid, self._prop, self._formname, ret) - def field(self, size=30, default=None, format=_marker, popcal=True, + + def field_time(self, size=30, default=None, format=_marker, popcal=None, + **kwargs): + + kwargs.setdefault("type", "datetime-local") + field = self.field(size=size, default=default, format=format, + popcal=popcal, **kwargs) + return field + + def field(self, size=30, default=None, format=_marker, popcal=None, **kwargs): """Render a form edit field for the property @@ -2269,6 +2278,47 @@ else: return self.pretty(format) + kwargs.setdefault("type", "date") + + if kwargs["type"] in ["date", "datetime-local"]: + acceptable_formats = { + "date": "%Y-%m-%d", + "datetime-local": "%Y-%m-%dT%H:%M:%S" + } + + if format is not self._marker: # user set format + if format != acceptable_formats[kwargs["type"]]: + # format is incompatible with date type + kwargs['type'] = "text" + if popcal is not False: + popcal = True + logger.warning(self._( + "Format '%(format)s' prevents use of modern " + "date input. Remove format from field() call in " + "template %(class)s.%(template)s. " + "Using text input.") % { + "format": format, + "class": self._client.classname, + "template": self._client.template + }) + + """ + raise ValueError(self._( + "When using input type of '%(field_type)s', the " + "format must not be set, or must be " + "'%(format_string)s' to match RFC3339 date " + "or date-time. Current format is '%(format)s'.") % { + "field_type": kwargs["type"], + "format_string": + acceptable_formats[kwargs["type"]], + "format": format, + })""" + else: + # https://developer.mozilla.org/en-US/docs/Web/HTML/Date_and_time_formats#local_date_and_time_strings + # match date-time format in + # https://www.rfc-editor.org/rfc/rfc3339 + format = acceptable_formats[kwargs['type']] + value = self._value if value is None:
--- a/roundup/date.py Sat Jan 18 11:20:20 2025 -0500 +++ b/roundup/date.py Sat Jan 18 12:23:23 2025 -0500 @@ -46,7 +46,7 @@ date_re = re.compile(r'''^ ((?P<y>\d\d\d\d)([/-](?P<m>\d\d?)([/-](?P<d>\d\d?))?)? # yyyy[-mm[-dd]] |(?P<a>\d\d?)[/-](?P<b>\d\d?))? # or mm-dd - (?P<n>\.)? # . + (?P<n>[.T])? # . or T (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d?(\.\d+)?))?)? # hh:mm:ss (?:(?P<tz>\s?[+-]\d{4})|(?P<o>[\d\smywd\-+]+))? # time-zone offset, offset $''', re.VERBOSE)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/classic/html/datecopy.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,116 @@ +/* Capture double-click on a date element. Turn element in text element + and select date for copying. Return to date element saving change on + focusout or Enter. Return to date element with original value on Escape. + Derived from + https://stackoverflow.com/questions/49981660/enable-copy-paste-on-html5-date-field +*/ + +/* TODO: keyboard support should be added to allow entering text mode. */ + +// use iife to encapsulate handleModeExitKeys +(function () { + "use strict"; + + // Define named function so it can be added/removed as event handler + // in different scopes of the code + function handleModeExitKeys (event) { + if (event.key !== "Escape" && event.key !== "Enter") return; + event.preventDefault(); + if (event.key === "Escape") { + event.target.value = event.target.original_value; + } + let focusout = new Event("focusout"); + event.target.dispatchEvent(focusout); + } + + + document.querySelector("body").addEventListener("dblclick", (evt) => { + if (evt.target.tagName !== "INPUT") return; + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value.toLowerCase())) return; + + // we have a date type input + let target = evt.target; + let original_type = target.attributes.type.value; + target.type = "text"; + + target.original_value = target.value; + + // allow admin to set CSS to change input + target.classList.add("mode_textdate"); + // After changing input type with JS .select() won't + // work as usual + // Needs timeout fn() to make it work + setTimeout(() => { + target.select(); + }); + + // register the focusout event to reset the input back + // to a date input field. Once it triggers the handler + // is deleted to be added on the next doubleclick. + // This also should end the closure of original_type. + target.addEventListener("focusout", () => { + target.type = original_type; + delete event.target.original_value; + + target.classList.remove("mode_textdate"); + + target.removeEventListener("keydown", handleModeExitKeys); + }, {once: true}); + + // called on every keypress including editing the field, + // so can not be set with "once" like "focusout". + target.addEventListener("keydown", handleModeExitKeys); + }); +})() + +/* Some failed experiments that I would have liked to have work */ +/* With the date element focused, type ^c or ^v to copy/paste + evt.target always seems to be inconsistent. Sometimes I get the + input but usually I get the body. + + I can find the date element using document.activeElement, but + this seems like a kludge. + */ +/* +body.addEventListener("copy", (evt) => { + // target = document.activeElement; + target = evt.target; + if (target.tagName != "INPUT") { + //alert("copy received non-date" + target.tagName); + return; + } + + if (! ["date", "datetime-local"].includes( + target.attributes.type.value)) { + //alert("copy received non-date"); + return; + } + + evt.clipboardData.setData("text/plain", + target.value); + // default behaviour is to copy any selected text + // overwriting what we set + evt.preventDefault(); + //alert("copy received date"); +}) + +body.addEventListener("paste", (evt) => { + if (evt.target.tagName != "INPUT") { + //alert("paste received non-date"); + return; + } + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value)) { + //alert("paste received non-date"); + return; + } + + data = evt.clipboardData.getData("text/plain"); + evt.preventDefault(); + evt.target.value = data; + //alert("paste received date " + data); +}) +*/
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/classic/html/datecopy.min.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,1 @@ +(function(){"use strict";function handleModeExitKeys(event){if(event.key!=="Escape"&&event.key!=="Enter")return;event.preventDefault();if(event.key==="Escape"){event.target.value=event.target.original_value;};let focusout=new Event("focusout");event.target.dispatchEvent(focusout);};document.querySelector("body").addEventListener("dblclick",(evt)=>{if(evt.target.tagName!=="INPUT")return;if(!["date","datetime-local"].includes(evt.target.attributes.type.value.toLowerCase()))return;let target=evt.target;let original_type=target.attributes.type.value;target.type="text";target.original_value=target.value;target.classList.add("mode_textdate");setTimeout(()=>{target.select();});target.addEventListener("focusout",()=>{target.type=original_type;delete event.target.original_value;target.classList.remove("mode_textdate");target.removeEventListener("keydown",handleModeExitKeys);},{once:true});target.addEventListener("keydown",handleModeExitKeys);});})() \ No newline at end of file
--- a/share/roundup/templates/classic/html/page.html Sat Jan 18 11:20:20 2025 -0500 +++ b/share/roundup/templates/classic/html/page.html Sat Jan 18 12:23:23 2025 -0500 @@ -200,6 +200,7 @@ <pre tal:condition="request/form/debug | nothing" tal:content="request"> </pre> +<script type="text/javascript" src='@@file/datecopy.min.js'></script> </body> </html> </tal:block>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/devel/html/datecopy.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,116 @@ +/* Capture double-click on a date element. Turn element in text element + and select date for copying. Return to date element saving change on + focusout or Enter. Return to date element with original value on Escape. + Derived from + https://stackoverflow.com/questions/49981660/enable-copy-paste-on-html5-date-field +*/ + +/* TODO: keyboard support should be added to allow entering text mode. */ + +// use iife to encapsulate handleModeExitKeys +(function () { + "use strict"; + + // Define named function so it can be added/removed as event handler + // in different scopes of the code + function handleModeExitKeys (event) { + if (event.key !== "Escape" && event.key !== "Enter") return; + event.preventDefault(); + if (event.key === "Escape") { + event.target.value = event.target.original_value; + } + let focusout = new Event("focusout"); + event.target.dispatchEvent(focusout); + } + + + document.querySelector("body").addEventListener("dblclick", (evt) => { + if (evt.target.tagName !== "INPUT") return; + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value.toLowerCase())) return; + + // we have a date type input + let target = evt.target; + let original_type = target.attributes.type.value; + target.type = "text"; + + target.original_value = target.value; + + // allow admin to set CSS to change input + target.classList.add("mode_textdate"); + // After changing input type with JS .select() won't + // work as usual + // Needs timeout fn() to make it work + setTimeout(() => { + target.select(); + }); + + // register the focusout event to reset the input back + // to a date input field. Once it triggers the handler + // is deleted to be added on the next doubleclick. + // This also should end the closure of original_type. + target.addEventListener("focusout", () => { + target.type = original_type; + delete event.target.original_value; + + target.classList.remove("mode_textdate"); + + target.removeEventListener("keydown", handleModeExitKeys); + }, {once: true}); + + // called on every keypress including editing the field, + // so can not be set with "once" like "focusout". + target.addEventListener("keydown", handleModeExitKeys); + }); +})() + +/* Some failed experiments that I would have liked to have work */ +/* With the date element focused, type ^c or ^v to copy/paste + evt.target always seems to be inconsistent. Sometimes I get the + input but usually I get the body. + + I can find the date element using document.activeElement, but + this seems like a kludge. + */ +/* +body.addEventListener("copy", (evt) => { + // target = document.activeElement; + target = evt.target; + if (target.tagName != "INPUT") { + //alert("copy received non-date" + target.tagName); + return; + } + + if (! ["date", "datetime-local"].includes( + target.attributes.type.value)) { + //alert("copy received non-date"); + return; + } + + evt.clipboardData.setData("text/plain", + target.value); + // default behaviour is to copy any selected text + // overwriting what we set + evt.preventDefault(); + //alert("copy received date"); +}) + +body.addEventListener("paste", (evt) => { + if (evt.target.tagName != "INPUT") { + //alert("paste received non-date"); + return; + } + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value)) { + //alert("paste received non-date"); + return; + } + + data = evt.clipboardData.getData("text/plain"); + evt.preventDefault(); + evt.target.value = data; + //alert("paste received date " + data); +}) +*/
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/devel/html/datecopy.min.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,1 @@ +(function(){"use strict";function handleModeExitKeys(event){if(event.key!=="Escape"&&event.key!=="Enter")return;event.preventDefault();if(event.key==="Escape"){event.target.value=event.target.original_value;};let focusout=new Event("focusout");event.target.dispatchEvent(focusout);};document.querySelector("body").addEventListener("dblclick",(evt)=>{if(evt.target.tagName!=="INPUT")return;if(!["date","datetime-local"].includes(evt.target.attributes.type.value.toLowerCase()))return;let target=evt.target;let original_type=target.attributes.type.value;target.type="text";target.original_value=target.value;target.classList.add("mode_textdate");setTimeout(()=>{target.select();});target.addEventListener("focusout",()=>{target.type=original_type;delete event.target.original_value;target.classList.remove("mode_textdate");target.removeEventListener("keydown",handleModeExitKeys);},{once:true});target.addEventListener("keydown",handleModeExitKeys);});})() \ No newline at end of file
--- a/share/roundup/templates/devel/html/page.html Sat Jan 18 11:20:20 2025 -0500 +++ b/share/roundup/templates/devel/html/page.html Sat Jan 18 12:23:23 2025 -0500 @@ -263,6 +263,7 @@ <!-- hhmts end --> </div> <!-- footer --> <pre tal:condition="request/form/deissue | nothing" tal:content="request"></pre> + <script type="text/javascript" src='@@file/datecopy.min.js'></script> </body> </html> </tal:block>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/jinja2/html/datecopy.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,116 @@ +/* Capture double-click on a date element. Turn element in text element + and select date for copying. Return to date element saving change on + focusout or Enter. Return to date element with original value on Escape. + Derived from + https://stackoverflow.com/questions/49981660/enable-copy-paste-on-html5-date-field +*/ + +/* TODO: keyboard support should be added to allow entering text mode. */ + +// use iife to encapsulate handleModeExitKeys +(function () { + "use strict"; + + // Define named function so it can be added/removed as event handler + // in different scopes of the code + function handleModeExitKeys (event) { + if (event.key !== "Escape" && event.key !== "Enter") return; + event.preventDefault(); + if (event.key === "Escape") { + event.target.value = event.target.original_value; + } + let focusout = new Event("focusout"); + event.target.dispatchEvent(focusout); + } + + + document.querySelector("body").addEventListener("dblclick", (evt) => { + if (evt.target.tagName !== "INPUT") return; + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value.toLowerCase())) return; + + // we have a date type input + let target = evt.target; + let original_type = target.attributes.type.value; + target.type = "text"; + + target.original_value = target.value; + + // allow admin to set CSS to change input + target.classList.add("mode_textdate"); + // After changing input type with JS .select() won't + // work as usual + // Needs timeout fn() to make it work + setTimeout(() => { + target.select(); + }); + + // register the focusout event to reset the input back + // to a date input field. Once it triggers the handler + // is deleted to be added on the next doubleclick. + // This also should end the closure of original_type. + target.addEventListener("focusout", () => { + target.type = original_type; + delete event.target.original_value; + + target.classList.remove("mode_textdate"); + + target.removeEventListener("keydown", handleModeExitKeys); + }, {once: true}); + + // called on every keypress including editing the field, + // so can not be set with "once" like "focusout". + target.addEventListener("keydown", handleModeExitKeys); + }); +})() + +/* Some failed experiments that I would have liked to have work */ +/* With the date element focused, type ^c or ^v to copy/paste + evt.target always seems to be inconsistent. Sometimes I get the + input but usually I get the body. + + I can find the date element using document.activeElement, but + this seems like a kludge. + */ +/* +body.addEventListener("copy", (evt) => { + // target = document.activeElement; + target = evt.target; + if (target.tagName != "INPUT") { + //alert("copy received non-date" + target.tagName); + return; + } + + if (! ["date", "datetime-local"].includes( + target.attributes.type.value)) { + //alert("copy received non-date"); + return; + } + + evt.clipboardData.setData("text/plain", + target.value); + // default behaviour is to copy any selected text + // overwriting what we set + evt.preventDefault(); + //alert("copy received date"); +}) + +body.addEventListener("paste", (evt) => { + if (evt.target.tagName != "INPUT") { + //alert("paste received non-date"); + return; + } + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value)) { + //alert("paste received non-date"); + return; + } + + data = evt.clipboardData.getData("text/plain"); + evt.preventDefault(); + evt.target.value = data; + //alert("paste received date " + data); +}) +*/
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/jinja2/html/datecopy.min.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,1 @@ +(function(){"use strict";function handleModeExitKeys(event){if(event.key!=="Escape"&&event.key!=="Enter")return;event.preventDefault();if(event.key==="Escape"){event.target.value=event.target.original_value;};let focusout=new Event("focusout");event.target.dispatchEvent(focusout);};document.querySelector("body").addEventListener("dblclick",(evt)=>{if(evt.target.tagName!=="INPUT")return;if(!["date","datetime-local"].includes(evt.target.attributes.type.value.toLowerCase()))return;let target=evt.target;let original_type=target.attributes.type.value;target.type="text";target.original_value=target.value;target.classList.add("mode_textdate");setTimeout(()=>{target.select();});target.addEventListener("focusout",()=>{target.type=original_type;delete event.target.original_value;target.classList.remove("mode_textdate");target.removeEventListener("keydown",handleModeExitKeys);},{once:true});target.addEventListener("keydown",handleModeExitKeys);});})() \ No newline at end of file
--- a/share/roundup/templates/jinja2/html/layout/page.html Sat Jan 18 11:20:20 2025 -0500 +++ b/share/roundup/templates/jinja2/html/layout/page.html Sat Jan 18 12:23:23 2025 -0500 @@ -68,6 +68,7 @@ <script src='@@file/jquery-1.9.0.min.js'></script> <script src='@@file/bootstrap.min.js'></script> + <script src='@@file/datecopy.min.js'></script> {% block extrajs %} {% endblock %} </body> </html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/minimal/html/datecopy.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,116 @@ +/* Capture double-click on a date element. Turn element in text element + and select date for copying. Return to date element saving change on + focusout or Enter. Return to date element with original value on Escape. + Derived from + https://stackoverflow.com/questions/49981660/enable-copy-paste-on-html5-date-field +*/ + +/* TODO: keyboard support should be added to allow entering text mode. */ + +// use iife to encapsulate handleModeExitKeys +(function () { + "use strict"; + + // Define named function so it can be added/removed as event handler + // in different scopes of the code + function handleModeExitKeys (event) { + if (event.key !== "Escape" && event.key !== "Enter") return; + event.preventDefault(); + if (event.key === "Escape") { + event.target.value = event.target.original_value; + } + let focusout = new Event("focusout"); + event.target.dispatchEvent(focusout); + } + + + document.querySelector("body").addEventListener("dblclick", (evt) => { + if (evt.target.tagName !== "INPUT") return; + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value.toLowerCase())) return; + + // we have a date type input + let target = evt.target; + let original_type = target.attributes.type.value; + target.type = "text"; + + target.original_value = target.value; + + // allow admin to set CSS to change input + target.classList.add("mode_textdate"); + // After changing input type with JS .select() won't + // work as usual + // Needs timeout fn() to make it work + setTimeout(() => { + target.select(); + }); + + // register the focusout event to reset the input back + // to a date input field. Once it triggers the handler + // is deleted to be added on the next doubleclick. + // This also should end the closure of original_type. + target.addEventListener("focusout", () => { + target.type = original_type; + delete event.target.original_value; + + target.classList.remove("mode_textdate"); + + target.removeEventListener("keydown", handleModeExitKeys); + }, {once: true}); + + // called on every keypress including editing the field, + // so can not be set with "once" like "focusout". + target.addEventListener("keydown", handleModeExitKeys); + }); +})() + +/* Some failed experiments that I would have liked to have work */ +/* With the date element focused, type ^c or ^v to copy/paste + evt.target always seems to be inconsistent. Sometimes I get the + input but usually I get the body. + + I can find the date element using document.activeElement, but + this seems like a kludge. + */ +/* +body.addEventListener("copy", (evt) => { + // target = document.activeElement; + target = evt.target; + if (target.tagName != "INPUT") { + //alert("copy received non-date" + target.tagName); + return; + } + + if (! ["date", "datetime-local"].includes( + target.attributes.type.value)) { + //alert("copy received non-date"); + return; + } + + evt.clipboardData.setData("text/plain", + target.value); + // default behaviour is to copy any selected text + // overwriting what we set + evt.preventDefault(); + //alert("copy received date"); +}) + +body.addEventListener("paste", (evt) => { + if (evt.target.tagName != "INPUT") { + //alert("paste received non-date"); + return; + } + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value)) { + //alert("paste received non-date"); + return; + } + + data = evt.clipboardData.getData("text/plain"); + evt.preventDefault(); + evt.target.value = data; + //alert("paste received date " + data); +}) +*/
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/minimal/html/datecopy.min.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,1 @@ +(function(){"use strict";function handleModeExitKeys(event){if(event.key!=="Escape"&&event.key!=="Enter")return;event.preventDefault();if(event.key==="Escape"){event.target.value=event.target.original_value;};let focusout=new Event("focusout");event.target.dispatchEvent(focusout);};document.querySelector("body").addEventListener("dblclick",(evt)=>{if(evt.target.tagName!=="INPUT")return;if(!["date","datetime-local"].includes(evt.target.attributes.type.value.toLowerCase()))return;let target=evt.target;let original_type=target.attributes.type.value;target.type="text";target.original_value=target.value;target.classList.add("mode_textdate");setTimeout(()=>{target.select();});target.addEventListener("focusout",()=>{target.type=original_type;delete event.target.original_value;target.classList.remove("mode_textdate");target.removeEventListener("keydown",handleModeExitKeys);},{once:true});target.addEventListener("keydown",handleModeExitKeys);});})() \ No newline at end of file
--- a/share/roundup/templates/minimal/html/page.html Sat Jan 18 11:20:20 2025 -0500 +++ b/share/roundup/templates/minimal/html/page.html Sat Jan 18 12:23:23 2025 -0500 @@ -186,6 +186,7 @@ <pre tal:condition="request/form/debug | nothing" tal:content="request"> </pre> +<script type="text/javascript" src='@@file/datecopy.min.js'></script> </body> </html> </tal:block>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/responsive/html/datecopy.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,116 @@ +/* Capture double-click on a date element. Turn element in text element + and select date for copying. Return to date element saving change on + focusout or Enter. Return to date element with original value on Escape. + Derived from + https://stackoverflow.com/questions/49981660/enable-copy-paste-on-html5-date-field +*/ + +/* TODO: keyboard support should be added to allow entering text mode. */ + +// use iife to encapsulate handleModeExitKeys +(function () { + "use strict"; + + // Define named function so it can be added/removed as event handler + // in different scopes of the code + function handleModeExitKeys (event) { + if (event.key !== "Escape" && event.key !== "Enter") return; + event.preventDefault(); + if (event.key === "Escape") { + event.target.value = event.target.original_value; + } + let focusout = new Event("focusout"); + event.target.dispatchEvent(focusout); + } + + + document.querySelector("body").addEventListener("dblclick", (evt) => { + if (evt.target.tagName !== "INPUT") return; + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value.toLowerCase())) return; + + // we have a date type input + let target = evt.target; + let original_type = target.attributes.type.value; + target.type = "text"; + + target.original_value = target.value; + + // allow admin to set CSS to change input + target.classList.add("mode_textdate"); + // After changing input type with JS .select() won't + // work as usual + // Needs timeout fn() to make it work + setTimeout(() => { + target.select(); + }); + + // register the focusout event to reset the input back + // to a date input field. Once it triggers the handler + // is deleted to be added on the next doubleclick. + // This also should end the closure of original_type. + target.addEventListener("focusout", () => { + target.type = original_type; + delete event.target.original_value; + + target.classList.remove("mode_textdate"); + + target.removeEventListener("keydown", handleModeExitKeys); + }, {once: true}); + + // called on every keypress including editing the field, + // so can not be set with "once" like "focusout". + target.addEventListener("keydown", handleModeExitKeys); + }); +})() + +/* Some failed experiments that I would have liked to have work */ +/* With the date element focused, type ^c or ^v to copy/paste + evt.target always seems to be inconsistent. Sometimes I get the + input but usually I get the body. + + I can find the date element using document.activeElement, but + this seems like a kludge. + */ +/* +body.addEventListener("copy", (evt) => { + // target = document.activeElement; + target = evt.target; + if (target.tagName != "INPUT") { + //alert("copy received non-date" + target.tagName); + return; + } + + if (! ["date", "datetime-local"].includes( + target.attributes.type.value)) { + //alert("copy received non-date"); + return; + } + + evt.clipboardData.setData("text/plain", + target.value); + // default behaviour is to copy any selected text + // overwriting what we set + evt.preventDefault(); + //alert("copy received date"); +}) + +body.addEventListener("paste", (evt) => { + if (evt.target.tagName != "INPUT") { + //alert("paste received non-date"); + return; + } + + if (! ["date", "datetime-local"].includes( + evt.target.attributes.type.value)) { + //alert("paste received non-date"); + return; + } + + data = evt.clipboardData.getData("text/plain"); + evt.preventDefault(); + evt.target.value = data; + //alert("paste received date " + data); +}) +*/
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/share/roundup/templates/responsive/html/datecopy.min.js Sat Jan 18 12:23:23 2025 -0500 @@ -0,0 +1,1 @@ +(function(){"use strict";function handleModeExitKeys(event){if(event.key!=="Escape"&&event.key!=="Enter")return;event.preventDefault();if(event.key==="Escape"){event.target.value=event.target.original_value;};let focusout=new Event("focusout");event.target.dispatchEvent(focusout);};document.querySelector("body").addEventListener("dblclick",(evt)=>{if(evt.target.tagName!=="INPUT")return;if(!["date","datetime-local"].includes(evt.target.attributes.type.value.toLowerCase()))return;let target=evt.target;let original_type=target.attributes.type.value;target.type="text";target.original_value=target.value;target.classList.add("mode_textdate");setTimeout(()=>{target.select();});target.addEventListener("focusout",()=>{target.type=original_type;delete event.target.original_value;target.classList.remove("mode_textdate");target.removeEventListener("keydown",handleModeExitKeys);},{once:true});target.addEventListener("keydown",handleModeExitKeys);});})() \ No newline at end of file
--- a/share/roundup/templates/responsive/html/page.html Sat Jan 18 11:20:20 2025 -0500 +++ b/share/roundup/templates/responsive/html/page.html Sat Jan 18 12:23:23 2025 -0500 @@ -278,6 +278,7 @@ </footer> </div> <!-- container --> <pre tal:condition="request/form/deissue | nothing" tal:content="request"></pre> + <script type="text/javascript" src='@@file/datecopy.min.js'></script> </body> </html> </tal:block>
--- a/test/test_dates.py Sat Jan 18 11:20:20 2025 -0500 +++ b/test/test_dates.py Sat Jan 18 12:23:23 2025 -0500 @@ -483,6 +483,11 @@ toomuch = datetime.MAXYEAR + 1 self.assertRaises(ValueError, Date, (toomuch, 1, 1, 0, 0, 0, 0, 1, -1)) + def testRfc3339Form(self): + ae = self.assertEqual + d = Date('2003-11-01T00:00:00') + self.assertEqual(str(d), '2003-11-01.00:00:00') + def testSimpleTZ(self): ae = self.assertEqual # local to utc
--- a/test/test_templating.py Sat Jan 18 11:20:20 2025 -0500 +++ b/test/test_templating.py Sat Jan 18 12:23:23 2025 -0500 @@ -5,6 +5,7 @@ from roundup.anypy.cgi_ import FieldStorage, MiniFieldStorage from roundup.cgi.templating import * from roundup.cgi.ZTUtils.Iterator import Iterator +from roundup.test import memorydb from .test_actions import MockNull, true from .html_norm import NormalizingHtmlParser @@ -716,7 +717,11 @@ def setUp(self): self.form = FieldStorage() self.client = MockNull() - self.client.db = db = MockDatabase() + self.client.db = db = memorydb.create('admin') + db.tx_Source = "web" + + db.issue.addprop(tx_Source=hyperdb.String()) + db.security.hasPermission = lambda *args, **kw: True self.client.form = self.form @@ -725,8 +730,102 @@ self.client.session_api = MockNull(_sid="1234567890") self.client.db.getuid = lambda : 10 + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + class DateHTMLPropertyTestCase(HTMLPropertyTestClass): + def setUp(self): + super(DateHTMLPropertyTestCase, self).setUp() + + db = self.client.db + db.issue.addprop(deadline=hyperdb.Date()) + + self.test_datestring = "2021-01-01.11:22:10" + + self.client.db.issue.create(title="title", + deadline=date.Date(self.test_datestring)) + self.client.db.getUserTimezone = lambda: "2" + + def tearDown(self): + self.client.db.close() + memorydb.db_nuke('') + + def test_DateHTMLWithDate(self): + """Test methods when DateHTMLProperty._value is a hyperdb.Date() + """ + test_datestring = self.test_datestring + test_Date = self.client.db.issue.get("1", 'deadline') + test_hyperdbDate = self.client.db.issue.getprops("1")['deadline'] + + self.client.classname = "issue" + self.client.template = "item" + + # client, classname, nodeid, prop, name, value, + # anonymous=0, offset=None + d = DateHTMLProperty(self.client, 'issue', '1', test_hyperdbDate, + 'deadline', test_Date) + self.assertIsInstance(d._value, date.Date) + self.assertEqual(d.pretty(), " 1 January 2021") + self.assertEqual(d.pretty(format="%Y-%m"), "2021-01") + self.assertEqual(d.plain(), "2021-01-01.13:22:10") + self.assertEqual(d.local("-4").plain(), "2021-01-01.07:22:10") + input_expected = """<input id="issue1@deadline" name="issue1@deadline" size="30" type="date" value="2021-01-01">""" + self.assertEqual(d.field(), input_expected) + self.assertEqual(d.field_time(type="date"), input_expected) + + input_expected = """<input id="issue1@deadline" name="issue1@deadline" size="30" type="datetime-local" value="2021-01-01T13:22:10">""" + self.assertEqual(d.field(type="datetime-local"), input_expected) + self.assertEqual(d.field_time(), input_expected) + + input_expected = """<input id="issue1@deadline" name="issue1@deadline" size="30" type="text" value="2021-01-01.13:22:10">""" + self.assertEqual(d.field(type="text"), input_expected) + self.assertEqual(d.field_time(type="text"), input_expected) + + # test with format + input_expected = """<input id="issue1@deadline" name="issue1@deadline" size="30" type="text" value="2021-01"><a class="classhelp" data-calurl="issue?@template=calendar&amp;property=deadline&amp;form=itemSynopsis&date=2021-01-01.11:22:10" data-height="200" data-width="300" href="javascript:help_window(\'issue?@template=calendar&property=deadline&form=itemSynopsis&date=2021-01-01.11:22:10\', 300, 200)">(cal)</a>""" + + self._caplog.clear() + with self._caplog.at_level(logging.WARNING, + logger="roundup"): + input = d.field(format="%Y-%m") + self.assertEqual(input_expected, input) + + # name used for logging + log_expected = """Format '%Y-%m' prevents use of modern date input. Remove format from field() call in template issue.item. Using text input.""" + self.assertEqual(self._caplog.record_tuples[0][2], log_expected) + # severity ERROR = 40 + self.assertEqual(self._caplog.record_tuples[0][1], 30, + msg="logging level != 30 (WARNING)") + + # test with format and popcal=None + input_expected = """<input id="issue1@deadline" name="issue1@deadline" size="30" type="text" value="2021-01">""" + + self._caplog.clear() + with self._caplog.at_level(logging.WARNING, + logger="roundup"): + input = d.field(format="%Y-%m", popcal=False) + self.assertEqual(input_expected, input) + + # name used for logging + log_expected = """Format '%Y-%m' prevents use of modern date input. Remove format from field() call in template issue.item. Using text input.""" + self.assertEqual(self._caplog.record_tuples[0][2], log_expected) + # severity ERROR = 40 + self.assertEqual(self._caplog.record_tuples[0][1], 30, + msg="logging level != 30 (WARNING)") + + # test with format, type=text and popcal=None + input_expected = """<input id="issue1@deadline" name="issue1@deadline" size="30" type="text" value="2021-01">""" + + self._caplog.clear() + with self._caplog.at_level(logging.WARNING, + logger="roundup"): + input = d.field(type="text", format="%Y-%m", popcal=False ) + self.assertEqual(input_expected, input) + + self.assertEqual(self._caplog.records, []) + def test_DateHTMLWithText(self): """Test methods when DateHTMLProperty._value is a string rather than a hyperdb.Date() @@ -741,6 +840,9 @@ ( test = MockNull(getprops = lambda : test_date) ) + self.client.classname = "test" + self.client.template = "item" + # client, classname, nodeid, prop, name, value, # anonymous=0, offset=None d = DateHTMLProperty(self.client, 'test', '1', self.client._props, @@ -748,9 +850,62 @@ self.assertIs(type(d._value), str) self.assertEqual(d.pretty(), "2021-01-01 11:22:10") self.assertEqual(d.plain(), "2021-01-01 11:22:10") - input = """<input id="test1@test" name="test1@test" size="30" type="text" value="2021-01-01 11:22:10"><a class="classhelp" data-calurl="test?@template=calendar&amp;property=test&amp;form=itemSynopsis&date=2021-01-01 11:22:10" data-height="200" data-width="300" href="javascript:help_window('test?@template=calendar&property=test&form=itemSynopsis&date=2021-01-01 11:22:10', 300, 200)">(cal)</a>""" + input = """<input id="test1@test" name="test1@test" size="30" type="date" value="2021-01-01 11:22:10">""" self.assertEqual(d.field(), input) + + input_expected = """<input id="test1@test" name="test1@test" size="40" type="date" value="2021-01-01 11:22:10">""" + self.assertEqual(d.field(size=40), input_expected) + + input_expected = """<input id="test1@test" name="test1@test" size="30" type="text" value="2021-01-01 11:22:10"><a class="classhelp" data-calurl="test?@template=calendar&amp;property=test&amp;form=itemSynopsis&date=2021-01-01 11:22:10" data-height="200" data-width="300" href="javascript:help_window(\'test?@template=calendar&property=test&form=itemSynopsis&date=2021-01-01 11:22:10\', 300, 200)">(cal)</a>""" + with self._caplog.at_level(logging.WARNING, + logger="roundup"): + input = d.field(format="%Y-%m") + self.assertEqual(input_expected, input) + + # name used for logging + log_expected = """Format '%Y-%m' prevents use of modern date input. Remove format from field() call in template test.item. Using text input.""" + self.assertEqual(self._caplog.record_tuples[0][2], log_expected) + # severity ERROR = 40 + self.assertEqual(self._caplog.record_tuples[0][1], 30, + msg="logging level != 30 (WARNING)") + + """with self.assertRaises(ValueError) as e: + d.field(format="%Y-%m") + self.assertIn("'%Y-%m'", e.exception.args[0]) + self.assertIn("'date'", e.exception.args[0])""" + + + # format matches rfc format, so this should pass + result = d.field(format="%Y-%m-%d") + input_expected = """<input id="test1@test" name="test1@test" size="30" type="date" value="2021-01-01 11:22:10">""" + self.assertEqual(result, input_expected) + + input_expected = """<input id="test1@test" name="test1@test" size="30" type="text" value="2021-01-01 11:22:10"><a class="classhelp" data-calurl="test?@template=calendar&amp;property=test&amp;form=itemSynopsis&date=2021-01-01 11:22:10" data-height="200" data-width="300" href="javascript:help_window(\'test?@template=calendar&property=test&form=itemSynopsis&date=2021-01-01 11:22:10\', 300, 200)">(cal)</a>""" + with self._caplog.at_level(logging.WARNING, + logger="roundup"): + input = d.field(format="%Y-%m", type="datetime-local") + self.assertEqual(input_expected, input) + + # name used for logging + log_expected = """Format '%Y-%m' prevents use of modern date input. Remove format from field() call in template test.item. Using text input.""" + self.assertEqual(self._caplog.record_tuples[0][2], log_expected) + # severity ERROR = 40 + self.assertEqual(self._caplog.record_tuples[0][1], 30, + msg="logging level != 30 (WARNING)") + + """ + with self.assertRaises(ValueError) as e: + d.field(format="%Y-%m", type="datetime-local") + self.assertIn("'%Y-%m'", e.exception.args[0]) + self.assertIn("'datetime-local'", e.exception.args[0]) + """ + + # format matches rfc, so this should pass + result = d.field(type="datetime-local", format="%Y-%m-%dT%H:%M:%S") + input_expected = """<input id="test1@test" name="test1@test" size="30" type="datetime-local" value="2021-01-01 11:22:10">""" + self.assertEqual(result, input_expected) + # common markdown test cases class MarkdownTests: def mangleMarkdown2(self, s):
