diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..e36065822 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +htdocs/js/*.min.js binary +htdocs/css/selectize.bootstrap3.css binary diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index b2bb220fd..196a25151 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -578,6 +578,15 @@ paths: schema: title: '' type: string + - in: query + name: labels + description: | + List of labels assigned to queried patches, separated by ','. + If a label name begins with a '-', patches with that label won't + be included. + schema: + title: '' + type: string responses: '200': description: 'List of patches' @@ -2312,6 +2321,11 @@ components: type: array items: $ref: '#/components/schemas/PatchEmbedded' + labels: + title: Labels + type: array + items: + type: string PatchDetail: type: object title: Patches diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index f37d3213e..98e0845fe 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -596,6 +596,17 @@ paths: schema: title: '' type: string +{% endif %} +{% if version >= (1, 4) %} + - in: query + name: labels + description: | + List of labels assigned to queried patches, separated by ','. + If a label name begins with a '-', patches with that label won't + be included. + schema: + title: '' + type: string {% endif %} responses: '200': @@ -2397,6 +2408,13 @@ components: type: array items: $ref: '#/components/schemas/PatchEmbedded' +{% endif %} +{% if version >= (1, 4) %} + labels: + title: Labels + type: array + items: + type: string {% endif %} PatchDetail: type: object diff --git a/docs/api/schemas/v1.4/patchwork.yaml b/docs/api/schemas/v1.4/patchwork.yaml index 036fe15f1..a63f18fb5 100644 --- a/docs/api/schemas/v1.4/patchwork.yaml +++ b/docs/api/schemas/v1.4/patchwork.yaml @@ -578,6 +578,15 @@ paths: schema: title: '' type: string + - in: query + name: labels + description: | + List of labels assigned to queried patches, separated by ','. + If a label name begins with a '-', patches with that label won't + be included. + schema: + title: '' + type: string responses: '200': description: 'List of patches' @@ -2312,6 +2321,11 @@ components: type: array items: $ref: '#/components/schemas/PatchEmbedded' + labels: + title: Labels + type: array + items: + type: string PatchDetail: type: object title: Patches diff --git a/docs/deployment/management.rst b/docs/deployment/management.rst index dcee9ff51..dcbbf2194 100644 --- a/docs/deployment/management.rst +++ b/docs/deployment/management.rst @@ -192,3 +192,30 @@ patches for these new tags. .. option:: patch_id a patch ID number. If not supplied, all patches will be updated. + +.. _command-relabel: + +relabel +~~~~~~~ + +.. program:: manage.py relabel + +Relabel patches based on the subject line. + +.. code-block:: shell + + ./manage.py relabel [PROJECT [PROJECT...]] + +Patchwork extracts labels from square brackets at the beginning of the subject +line, eg. ``[label1, label2] Patch title``. + +Only labels created in the admin interface will be recognized. +The script prioritizes project-specific labels if both project-specific and +general labels with the same name exist. + +The script will not remove labels manually added to patches, but will re-add +labels manually removed from patches. + +.. option:: PROJECT + + list of IDs of projects to update. Relabels all projects if none specified. diff --git a/docs/usage/overview.rst b/docs/usage/overview.rst index 539de9ca3..b0bd0271c 100644 --- a/docs/usage/overview.rst +++ b/docs/usage/overview.rst @@ -162,6 +162,29 @@ or a cover letter or the web URL to a patch or a series: __ https://www.kernel.org/doc/html/latest/process/submitting-patches.html +Labels +~~~~~~ + +Labels are a way to organize and prioritize submissions. You can apply labels +to patches and cover letters to signify priority, category, or any other marker +you wish. + +Labels can be either global or tied to the project they are created in. Labels +can have an optional description attached, which will provide a little insight +into the purpose of the label. Labels are completely customizable and the +labels available will vary by instance. + +Labels are parsed out of the square brackets of the subject line. +For example, subject line ``[RFC, main] Add tests`` may cause the patch to be +labeled with ``RFC`` and ``main``, if such labels were created for the project +or instance. Labels can be created in the admin panel. + +.. note:: + + In order to refresh the list of labels for existing patches, for example + after updating Patchwork or adding a new label in the admin panel, + use the :ref:`command-relabel` management command. + Checks ~~~~~~ diff --git a/htdocs/README.rst b/htdocs/README.rst index 9a5211233..7f7eff94e 100644 --- a/htdocs/README.rst +++ b/htdocs/README.rst @@ -140,4 +140,4 @@ js :Website: https://selectize.github.io/selectize.js/ :GitHub: https://github.com/selectize/selectize.js - :Version: 0.13.5 + :Version: 0.15.2 diff --git a/htdocs/css/selectize.bootstrap3.css b/htdocs/css/selectize.bootstrap3.css index 3165727e7..ca53d550b 100644 --- a/htdocs/css/selectize.bootstrap3.css +++ b/htdocs/css/selectize.bootstrap3.css @@ -1,6 +1,20 @@ -.selectize-control.plugin-drag_drop.multi>.selectize-input.dragging{overflow:visible}.selectize-control.plugin-drag_drop.multi>.selectize-input>div.ui-sortable-placeholder{visibility:visible !important;background:#f2f2f2 !important;background:rgba(0,0,0,.06) !important;border:0 none !important;box-shadow:inset 0 0 12px 4px #fff}.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after{content:"!";visibility:hidden}.selectize-control.plugin-drag_drop .ui-sortable-helper{box-shadow:0 2px 5px rgba(0,0,0,.2)}.selectize-control .dropdown-header{position:relative;padding:6px 12px;border-bottom:1px solid #d0d0d0;background:#f8f8f8;border-radius:4px 4px 0 0}.selectize-control .dropdown-header-close{position:absolute;right:12px;top:50%;color:#333;opacity:.4;margin-top:-12px;line-height:20px;font-size:20px !important}.selectize-control .dropdown-header-close:hover{color:#000}.selectize-dropdown.plugin-optgroup_columns .selectize-dropdown-content{display:flex}.selectize-dropdown.plugin-optgroup_columns .optgroup{border-right:1px solid #f2f2f2;border-top:0 none;flex-grow:1;flex-basis:0;min-width:0}.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child{border-right:0 none}.selectize-dropdown.plugin-optgroup_columns .optgroup:before{display:none}.selectize-dropdown.plugin-optgroup_columns .optgroup-header{border-top:0 none}.selectize-control.plugin-remove_button .item{display:inline-flex;align-items:center;padding-right:0 !important}.selectize-control.plugin-remove_button .item .remove{color:inherit;text-decoration:none;vertical-align:middle;display:inline-block;padding:1px 5px;border-left:1px solid rgba(0,0,0,0);border-radius:0 2px 2px 0;box-sizing:border-box;margin-left:5px}.selectize-control.plugin-remove_button .item .remove:hover{background:rgba(0,0,0,.05)}.selectize-control.plugin-remove_button .item.active .remove{border-left-color:rgba(0,0,0,0)}.selectize-control.plugin-remove_button .disabled .item .remove:hover{background:none}.selectize-control.plugin-remove_button .disabled .item .remove{border-left-color:rgba(77,77,77,0)}.selectize-control.plugin-remove_button .remove-single{position:absolute;right:0;top:0;font-size:23px}.selectize-control{position:relative}.selectize-dropdown,.selectize-input,.selectize-input input{color:#333;font-family:inherit;font-size:inherit;line-height:20px;font-smoothing:inherit}.selectize-input,.selectize-control.single .selectize-input.input-active{background:#fff;cursor:text;display:inline-block}.selectize-input{border:1px solid #ccc;padding:6px 12px;display:inline-block;width:100%;position:relative;z-index:1;box-sizing:border-box;box-shadow:none;border-radius:4px}.selectize-control.multi .selectize-input.has-items{padding:calc( - 6px - 1px - 0 - ) 12px calc( - 6px - 1px - 3px - - 0 - )}.selectize-input.full{background-color:#fff}.selectize-input.disabled,.selectize-input.disabled *{cursor:default !important}.selectize-input.focus{box-shadow:inset 0 1px 2px rgba(0,0,0,.15)}.selectize-input.dropdown-active{border-radius:4px 4px 0 0}.selectize-input>*{vertical-align:baseline;display:inline-block;zoom:1}.selectize-control.multi .selectize-input>div{cursor:pointer;margin:0 3px 3px 0;padding:1px 5px;background:#efefef;color:#333;border:0 solid rgba(0,0,0,0)}.selectize-control.multi .selectize-input>div.active{background:#337ab7;color:#fff;border:0 solid rgba(0,0,0,0)}.selectize-control.multi .selectize-input.disabled>div,.selectize-control.multi .selectize-input.disabled>div.active{color:gray;background:#fff;border:0 solid rgba(77,77,77,0)}.selectize-input>input{display:inline-block !important;padding:0 !important;min-height:0 !important;max-height:none !important;max-width:100% !important;margin:0 !important;text-indent:0 !important;border:0 none !important;background:none !important;line-height:inherit !important;user-select:auto !important;box-shadow:none !important}.selectize-input>input::-ms-clear{display:none}.selectize-input>input:focus{outline:none !important}.selectize-input>input[placeholder]{box-sizing:initial}.selectize-input.has-items>input{margin:0 0px !important}.selectize-input::after{content:" ";display:block;clear:left}.selectize-input.dropdown-active::before{content:" ";display:block;position:absolute;background:#fff;height:1px;bottom:0;left:0;right:0}.selectize-dropdown{position:absolute;top:100%;left:0;width:100%;z-index:10;border:1px solid #d0d0d0;background:#fff;margin:-1px 0 0 0;border-top:0 none;box-sizing:border-box;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:0 0 4px 4px}.selectize-dropdown [data-selectable]{cursor:pointer;overflow:hidden}.selectize-dropdown [data-selectable] .highlight{background:rgba(255,237,40,.4);border-radius:1px}.selectize-dropdown .option,.selectize-dropdown .optgroup-header,.selectize-dropdown .no-results,.selectize-dropdown .create{padding:3px 12px}.selectize-dropdown .option,.selectize-dropdown [data-disabled],.selectize-dropdown [data-disabled] [data-selectable].option{cursor:inherit;opacity:.5}.selectize-dropdown [data-selectable].option{opacity:1;cursor:pointer}.selectize-dropdown .optgroup:first-child .optgroup-header{border-top:0 none}.selectize-dropdown .optgroup-header{color:#777;background:#fff;cursor:default}.selectize-dropdown .active{background-color:#f5f5f5;color:#262626}.selectize-dropdown .active.create{color:#262626}.selectize-dropdown .create{color:rgba(51,51,51,.5)}.selectize-dropdown-content{overflow-y:auto;overflow-x:hidden;max-height:200px;overflow-scrolling:touch}.selectize-dropdown-emptyoptionlabel{text-align:center}.selectize-dropdown .spinner{display:inline-block;width:30px;height:30px;margin:3px 12px}.selectize-dropdown .spinner:after{content:" ";display:block;width:24px;height:24px;margin:3px;border-radius:50%;border:5px solid #d0d0d0;border-color:#d0d0d0 rgba(0,0,0,0) #d0d0d0 rgba(0,0,0,0);animation:lds-dual-ring 1.2s linear infinite}@keyframes lds-dual-ring{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.selectize-control.single .selectize-input,.selectize-control.single .selectize-input input{cursor:pointer}.selectize-control.single .selectize-input.input-active,.selectize-control.single .selectize-input.input-active input{cursor:text}.selectize-control.single .selectize-input:after{content:" ";display:block;position:absolute;top:50%;right:17px;margin-top:-3px;width:0;height:0;border-style:solid;border-width:5px 5px 0 5px;border-color:#333 rgba(0,0,0,0) rgba(0,0,0,0) rgba(0,0,0,0)}.selectize-control.single .selectize-input.dropdown-active:after{margin-top:-4px;border-width:0 5px 5px 5px;border-color:rgba(0,0,0,0) rgba(0,0,0,0) #333 rgba(0,0,0,0)}.selectize-control.rtl{text-align:right}.selectize-control.rtl.single .selectize-input:after{left:17px;right:auto}.selectize-control.rtl .selectize-input>input{margin:0 4px 0 -2px !important}.selectize-control .selectize-input.disabled{opacity:.5;background-color:#fff}.selectize-dropdown,.selectize-dropdown.form-control{height:auto;padding:0;margin:2px 0 0 0;z-index:1000;background:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;box-shadow:0 6px 12px rgba(0,0,0,.175)}.selectize-dropdown .optgroup-header{font-size:12px;line-height:1.428571429}.selectize-dropdown .optgroup:first-child:before{display:none}.selectize-dropdown .optgroup:before{content:" ";display:block;height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5;margin-left:-12px;margin-right:-12px}.selectize-dropdown-content{padding:5px 0}.selectize-dropdown-emptyoptionlabel{text-align:center}.selectize-input{min-height:34px}.selectize-input.dropdown-active{border-radius:4px}.selectize-input.dropdown-active::before{display:none}.selectize-input.focus{border-color:#66afe9;outline:0;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.has-error .selectize-input{border-color:#a94442;box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .selectize-input:focus{border-color:#843534;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.selectize-control.multi .selectize-input.has-items{padding-left:7px;padding-right:7px}.selectize-control.multi .selectize-input>div{border-radius:3px}.form-control.selectize-control{padding:0;height:auto;border:none;background:none;box-shadow:none;border-radius:0} \ No newline at end of file +/** + * Selectize (v0.15.2) + * https://selectize.dev + * + * Copyright (c) 2013-2015 Brian Reavis & contributors + * Copyright (c) 2020-2022 Selectize Team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this + * file except in compliance with the License. You may obtain a copy of the License at: + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + * + * @author Brian Reavis + * @author Ris Adams + */ +.selectize-control.plugin-drag_drop.multi>.selectize-input.dragging{overflow:visible}.selectize-control.plugin-drag_drop.multi>.selectize-input>div.ui-sortable-placeholder{visibility:visible !important;background:#f2f2f2 !important;background:rgba(0,0,0,0.06) !important;border:0 none !important;box-shadow:inset 0 0 12px 4px #fff}.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after{content:"!";visibility:hidden}.selectize-control.plugin-drag_drop .ui-sortable-helper{box-shadow:0 2px 5px rgba(0,0,0,0.2)}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header{position:relative;padding:6px 12px;border-bottom:1px solid #d0d0d0;background:#f8f8f8;border-radius:4px 4px 0 0}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header-close{position:absolute;right:12px;top:50%;color:#333;opacity:.4;margin-top:-12px;line-height:20px;font-size:20px !important;text-decoration:none}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header-close:hover{color:black}.selectize-dropdown.plugin-optgroup_columns .selectize-dropdown-content{display:flex}.selectize-dropdown.plugin-optgroup_columns .optgroup{border-right:1px solid #f2f2f2;border-top:0 none;flex-grow:1;flex-basis:0;min-width:0}.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child{border-right:0 none}.selectize-dropdown.plugin-optgroup_columns .optgroup:before{display:none}.selectize-dropdown.plugin-optgroup_columns .optgroup-header{border-top:0 none}.selectize-control.plugin-remove_button .item{display:inline-flex;align-items:center;padding-right:0 !important}.selectize-control.plugin-remove_button .item .remove{color:inherit;text-decoration:none;vertical-align:middle;display:inline-block;padding:1px 5px;border-left:1px solid rgba(0,0,0,0);border-radius:0 2px 2px 0;box-sizing:border-box;margin-left:5px}.selectize-control.plugin-remove_button .item .remove:hover{background:rgba(0,0,0,0.05)}.selectize-control.plugin-remove_button .item.active .remove{border-left-color:rgba(0,0,0,0)}.selectize-control.plugin-remove_button .disabled .item .remove:hover{background:0}.selectize-control.plugin-remove_button .disabled .item .remove{border-left-color:rgba(77,77,77,0)}.selectize-control.plugin-clear_button .clear{text-decoration:none;display:flex;position:absolute;height:100%;width:25px;top:0;right:calc(12px - 5px);color:#000;opacity:.4;font-weight:bold;border:0;cursor:pointer;z-index:1;font-size:21px;justify-content:center;align-items:center}.selectize-control.plugin-clear_button .clear:hover{opacity:1}.selectize-control.plugin-clear_button.single .clear{right:calc(12px - 5px + 1.5rem)}.selectize-dropdown.plugin-auto_position.selectize-position-top{border-top:1px solid #d0d0d0;border-bottom:0 none;border-radius:3px 3px 0 0;box-shadow:0 -6px 12px rgba(0,0,0,0.18)}.selectize-control.plugin-auto_position .selectize-input.selectize-position-top.dropdown-active{border-radius:0 0 3px 3px;border-top:0 none}.selectize-control.plugin-auto_position .selectize-input.selectize-position-top.dropdown-active::before{top:0;bottom:unset}.selectize-control{position:relative}.selectize-dropdown,.selectize-input,.selectize-input input{color:#333;font-family:inherit;font-size:inherit;line-height:20px;font-smoothing:inherit}.selectize-input,.selectize-control.single .selectize-input.input-active{background:#fff;cursor:text;display:inline-block}.selectize-input{border:1px solid #ccc;padding:6px 12px;display:inline-block;width:100%;position:relative;z-index:1;box-sizing:border-box;box-shadow:none;border-radius:4px}.selectize-control.multi .selectize-input.has-items{padding:calc(6px - 1px - 0px) 12px calc(6px - 1px - 3px - 0px)}.selectize-input.full{background-color:#fff}.selectize-input.disabled,.selectize-input.disabled *{cursor:default !important}.selectize-input.focus{box-shadow:inset 0 1px 2px rgba(0,0,0,0.15)}.selectize-input.dropdown-active{border-radius:4px 4px 0 0}.selectize-input>*{vertical-align:baseline;display:inline-block;zoom:1}.selectize-control.multi .selectize-input>div{cursor:pointer;margin:0 3px 3px 0;padding:1px 5px;background:#efefef;color:#333;border:0 solid rgba(0,0,0,0)}.selectize-control.multi .selectize-input>div.active{background:#337ab7;color:#fff;border:0 solid rgba(0,0,0,0)}.selectize-control.multi .selectize-input.disabled>div,.selectize-control.multi .selectize-input.disabled>div.active{color:gray;background:white;border:0 solid rgba(77,77,77,0)}.selectize-input>input{display:inline-block !important;padding:0 !important;min-height:0 !important;max-height:none !important;max-width:100% !important;margin:0 !important;text-indent:0 !important;border:0 none !important;background:none !important;line-height:inherit !important;user-select:auto !important;box-shadow:none !important}.selectize-input>input::-ms-clear{display:none}.selectize-input>input:focus{outline:none !important}.selectize-input>input[placeholder]{box-sizing:initial}.selectize-input.has-items>input{margin:0 0 !important}.selectize-input::after{content:" ";display:block;clear:left}.selectize-input.dropdown-active::before{content:" ";display:block;position:absolute;background:#d6d6d6;height:1px;bottom:0;left:0;right:0}.selectize-dropdown{position:absolute;top:100%;left:0;width:100%;z-index:10;border:1px solid #d0d0d0;background:#fff;margin:-1px 0 0 0;border-top:0 none;box-sizing:border-box;box-shadow:0 1px 3px rgba(0,0,0,0.1);border-radius:0 0 4px 4px}.selectize-dropdown [data-selectable]{cursor:pointer;overflow:hidden}.selectize-dropdown [data-selectable] .highlight{background:rgba(255,237,40,0.4);border-radius:1px}.selectize-dropdown .option,.selectize-dropdown .optgroup-header,.selectize-dropdown .no-results,.selectize-dropdown .create{padding:3px 12px}.selectize-dropdown .option,.selectize-dropdown [data-disabled],.selectize-dropdown [data-disabled] [data-selectable].option{cursor:inherit;opacity:.5}.selectize-dropdown [data-selectable].option{opacity:1;cursor:pointer}.selectize-dropdown .optgroup:first-child .optgroup-header{border-top:0 none}.selectize-dropdown .optgroup-header{color:#777;background:#fff;cursor:default}.selectize-dropdown .active{background-color:#337ab7;color:#fff}.selectize-dropdown .active.create{color:#262626}.selectize-dropdown .selected{background-color:#337ab7;color:#fff}.selectize-dropdown .create{color:rgba(51,51,51,0.5)}.selectize-dropdown .active:not(.selected){background:#f5f5f5;color:#262626}.selectize-dropdown-content{overflow-y:auto;overflow-x:hidden;max-height:200px;overflow-scrolling:touch}.selectize-dropdown-emptyoptionlabel{text-align:center}.selectize-dropdown .spinner{display:inline-block;width:30px;height:30px;margin:3px 12px}.selectize-dropdown .spinner:after{content:" ";display:block;width:24px;height:24px;margin:3px;border-radius:50%;border:5px solid #d0d0d0;border-color:#d0d0d0 transparent #d0d0d0 transparent;animation:lds-dual-ring 1.2s linear infinite}@keyframes lds-dual-ring{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}.selectize-control.single .selectize-input,.selectize-control.single .selectize-input input{cursor:pointer}.selectize-control.single .selectize-input.input-active,.selectize-control.single .selectize-input.input-active input:not(:read-only){cursor:text}.selectize-control.single .selectize-input:after{content:" ";display:block;position:absolute;top:50%;right:17px;margin-top:-3px;width:0;height:0;border-style:solid;border-width:5px 5px 0 5px;border-color:#333 transparent transparent transparent}.selectize-control.single .selectize-input.dropdown-active:after{margin-top:-4px;border-width:0 5px 5px 5px;border-color:transparent transparent #333 transparent}.selectize-control.rtl{text-align:right}.selectize-control.rtl.single .selectize-input:after{left:17px;right:auto}.selectize-control.rtl .selectize-input>input{margin:0 4px 0 -2px !important}.selectize-control .selectize-input.disabled{opacity:.5;background-color:#fff}.selectize-dropdown,.selectize-dropdown.form-control{height:auto;padding:0;margin:2px 0 0 0;z-index:1000;background:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.15);border-radius:4px;box-shadow:0 6px 12px rgba(0,0,0,0.175)}.selectize-dropdown .optgroup-header{font-size:12px;line-height:1.428571429}.selectize-dropdown .optgroup:first-child:before{display:none}.selectize-dropdown .optgroup:before{content:" ";display:block;height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5;margin-left:-12px;margin-right:-12px}.selectize-dropdown-content{padding:5px 0}.selectize-dropdown-emptyoptionlabel{text-align:center}.selectize-input{min-height:34px}.selectize-input.dropdown-active{border-radius:4px}.selectize-input.dropdown-active::before{display:none}.selectize-input.focus{border-color:#66afe9;outline:0;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6)}.has-error .selectize-input{border-color:#a94442;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .selectize-input:focus{border-color:#843534;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483}.selectize-control.multi .selectize-input.has-items{padding-left:7px;padding-right:7px}.selectize-control.multi .selectize-input>div{border-radius:3px}.form-control.selectize-control{padding:0;height:auto;border:0;background:0;box-shadow:none;border-radius:0}.selectize-dropdown.plugin-auto_position.selectize-position-top{border-top:1px solid #d0d0d0;border-bottom:0 none;border-radius:3px 3px 0 0;box-shadow:0 -6px 12px rgba(0,0,0,0.18)}.selectize-control.plugin-auto_position .selectize-input.selectize-position-top.dropdown-active{border-radius:0 0 3px 3px;border-top:0 none}.selectize-control.plugin-auto_position .selectize-input.selectize-position-top.dropdown-active::before{top:0;bottom:unset}.selectize-control.plugin-clear_button .clear{text-decoration:none;display:flex;position:absolute;height:100%;width:25px;top:0;right:calc(12px - 5px);color:#000;opacity:.4;font-weight:bold;border:0;cursor:pointer;z-index:1;font-size:21px;justify-content:center;align-items:center}.selectize-control.plugin-clear_button .clear:hover{opacity:1}.selectize-control.plugin-clear_button.single .clear{right:calc(12px - 5px + 1.5rem)}.selectize-control.plugin-drag_drop.multi>.selectize-input.dragging{overflow:visible}.selectize-control.plugin-drag_drop.multi>.selectize-input>div.ui-sortable-placeholder{visibility:visible !important;background:#f2f2f2 !important;background:rgba(0,0,0,0.06) !important;border:0 none !important;box-shadow:inset 0 0 12px 4px #fff}.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after{content:"!";visibility:hidden}.selectize-control.plugin-drag_drop .ui-sortable-helper{box-shadow:0 2px 5px rgba(0,0,0,0.2)}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header{position:relative;padding:6px 12px;border-bottom:1px solid #d0d0d0;background:#f8f8f8;border-radius:4px 4px 0 0}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header-close{position:absolute;right:12px;top:50%;color:#333;opacity:.4;margin-top:-12px;line-height:20px;font-size:20px !important;text-decoration:none}.selectize-dropdown.plugin-dropdown_header .selectize-dropdown-header-close:hover{color:black}.selectize-dropdown.plugin-optgroup_columns .selectize-dropdown-content{display:flex}.selectize-dropdown.plugin-optgroup_columns .optgroup{border-right:1px solid #f2f2f2;border-top:0 none;flex-grow:1;flex-basis:0;min-width:0}.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child{border-right:0 none}.selectize-dropdown.plugin-optgroup_columns .optgroup:before{display:none}.selectize-dropdown.plugin-optgroup_columns .optgroup-header{border-top:0 none}.selectize-control.plugin-remove_button .item{display:inline-flex;align-items:center;padding-right:0 !important}.selectize-control.plugin-remove_button .item .remove{color:inherit;text-decoration:none;vertical-align:middle;display:inline-block;padding:1px 5px;border-left:1px solid rgba(0,0,0,0);border-radius:0 2px 2px 0;box-sizing:border-box;margin-left:5px}.selectize-control.plugin-remove_button .item .remove:hover{background:rgba(0,0,0,0.05)}.selectize-control.plugin-remove_button .item.active .remove{border-left-color:rgba(0,0,0,0)}.selectize-control.plugin-remove_button .disabled .item .remove:hover{background:0}.selectize-control.plugin-remove_button .disabled .item .remove{border-left-color:rgba(77,77,77,0)} \ No newline at end of file diff --git a/htdocs/css/style.css b/htdocs/css/style.css index 268a8c372..db3a066cf 100644 --- a/htdocs/css/style.css +++ b/htdocs/css/style.css @@ -471,6 +471,14 @@ select[class^=change-property-], .archive-patch-select, .add-bundle { margin: 0px 4px 0px 4px; } +.labels-field { + padding: 4px; + margin-right: 8px; + box-sizing: border-box; + border-radius: 4px; + min-width: 200px; +} + .patch-form-submit { font-weight: bold; padding: 4px; diff --git a/htdocs/js/selectize.min.js b/htdocs/js/selectize.min.js index 0a5384be6..4fb14c096 100644 --- a/htdocs/js/selectize.min.js +++ b/htdocs/js/selectize.min.js @@ -1,2 +1,32 @@ -/*! selectize.js - v0.13.5 | https://github.com/selectize/selectize.js | Apache License (v2) */ -var highlight=function($element,pattern){var regex,highlight;if("string"!=typeof pattern||pattern.length)return regex="string"==typeof pattern?new RegExp(pattern,"i"):pattern,highlight=function(node){var skip=0;if(3===node.nodeType){var spannode,match,pos=node.data.search(regex);0<=pos&&0/g,">").replace(/"/g,""")},escape_replace=function(str){return(str+"").replace(/\$/g,"$$$$")},hook={before:function(self,method,fn){var original=self[method];self[method]=function(){return fn.apply(self,arguments),original.apply(self,arguments)}},after:function(self,method,fn){var original=self[method];self[method]=function(){var result=original.apply(self,arguments);return fn.apply(self,arguments),result}}},once=function(fn){var called=!1;return function(){called||(called=!0,fn.apply(this,arguments))}},debounce=function(fn,delay){var timeout;return function(){var self=this,args=arguments;window.clearTimeout(timeout),timeout=window.setTimeout(function(){fn.apply(self,args)},delay)}},debounce_events=function(self,types,fn){var type,trigger=self.trigger,event_args={};for(type in self.trigger=function(){var type=arguments[0];if(-1===types.indexOf(type))return trigger.apply(self,arguments);event_args[type]=arguments},fn.apply(self,[]),self.trigger=trigger,event_args)event_args.hasOwnProperty(type)&&trigger.apply(self,event_args[type])},watchChildEvent=function($parent,event,selector,fn){$parent.on(event,selector,function(e){for(var child=e.target;child&&child.parentNode!==$parent[0];)child=child.parentNode;return e.currentTarget=child,fn.apply(this,[e])})},getSelection=function(input){var sel,selLen,result={};return void 0===input?console.warn("WARN getSelection cannot locate input control"):"selectionStart"in input?(result.start=input.selectionStart,result.length=input.selectionEnd-result.start):document.selection&&(input.focus(),sel=document.selection.createRange(),selLen=document.selection.createRange().text.length,sel.moveStart("character",-input.value.length),result.start=sel.text.length-selLen,result.length=selLen),result},transferStyles=function($from,$to,properties){var i,n,styles={};if(properties)for(i=0,n=properties.length;i").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre"}),$("
").css({position:"absolute",width:0,height:0,overflow:"hidden"}).append(Selectize.$testInput).appendTo("body")),Selectize.$testInput.text(str),transferStyles($parent,Selectize.$testInput,["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"]),Selectize.$testInput.width()):0},autoGrow=function($input){function update(e,options){var printable,keyCode,selection;options=options||{},(e=e||window.event||{}).metaKey||e.altKey||!options.force&&!1===$input.data("grow")||(options=$input.val(),e.type&&"keydown"===e.type.toLowerCase()&&(printable=48<=(keyCode=e.keyCode)&&keyCode<=57||65<=keyCode&&keyCode<=90||96<=keyCode&&keyCode<=111||186<=keyCode&&keyCode<=222||32===keyCode,keyCode===KEY_DELETE||keyCode===KEY_BACKSPACE?(selection=getSelection($input[0])).length?options=options.substring(0,selection.start)+options.substring(selection.start+selection.length):keyCode===KEY_BACKSPACE&&selection.start?options=options.substring(0,selection.start-1)+options.substring(selection.start+1):keyCode===KEY_DELETE&&void 0!==selection.start&&(options=options.substring(0,selection.start)+options.substring(selection.start+1)):printable&&(keyCode=e.shiftKey,selection=String.fromCharCode(e.keyCode),options+=selection=keyCode?selection.toUpperCase():selection.toLowerCase())),e=(printable=$input.attr("placeholder"))?measureString(printable,$input)+4:0,(keyCode=Math.max(measureString(options,$input),e)+4)!==currentWidth&&(currentWidth=keyCode,$input.width(keyCode),$input.triggerHandler("resize")))}var currentWidth=null;$input.on("keydown keyup update blur",update),update()},domToString=function(d){var tmp=document.createElement("div");return tmp.appendChild(d.cloneNode(!0)),tmp.innerHTML},logError=function(message,options){options=options||{};console.error("Selectize: "+message),options.explanation&&(console.group&&console.group(),console.error(options.explanation),console.group&&console.groupEnd())},Selectize=function($input,settings){var i,n,input=$input[0],computedStyle=(input.selectize=this,window.getComputedStyle&&window.getComputedStyle(input,null));if(computedStyle=(computedStyle?computedStyle.getPropertyValue("direction"):input.currentStyle&&input.currentStyle.direction)||$input.parents("[dir]:first").attr("dir")||"",$.extend(this,{order:0,settings:settings,$input:$input,tabIndex:$input.attr("tabindex")||"",tagType:"select"===input.tagName.toLowerCase()?TAG_SELECT:TAG_INPUT,rtl:/rtl/i.test(computedStyle),eventNS:".selectize"+ ++Selectize.count,highlightedValue:null,isBlurring:!1,isOpen:!1,isDisabled:!1,isRequired:$input.is("[required]"),isInvalid:!1,isLocked:!1,isFocused:!1,isInputHidden:!1,isSetup:!1,isShiftDown:!1,isCmdDown:!1,isCtrlDown:!1,ignoreFocus:!1,ignoreBlur:!1,ignoreHover:!1,hasOptions:!1,currentResults:null,lastValue:"",lastValidValue:"",caretPos:0,loading:0,loadedSearches:{},$activeOption:null,$activeItems:[],optgroups:{},options:{},userOptions:{},items:[],renderCache:{},onSearchChange:null===settings.loadThrottle?this.onSearchChange:debounce(this.onSearchChange,settings.loadThrottle)}),this.sifter=new Sifter(this.options,{diacritics:settings.diacritics}),this.settings.options){for(i=0,n=this.settings.options.length;i").addClass(settings.wrapperClass).addClass(classes).addClass(inputMode),$control=$("
").addClass(settings.inputClass).addClass("items").appendTo($wrapper),$control_input=$('').appendTo($control).attr("tabindex",$input.is(":disabled")?"-1":self.tabIndex),$dropdown_parent=$(settings.dropdownParent||$wrapper),inputMode=$("
").addClass(settings.dropdownClass).addClass(inputMode).hide().appendTo($dropdown_parent),$dropdown_parent=$("
").addClass(settings.dropdownContentClass).attr("tabindex","-1").appendTo(inputMode);(inputId=$input.attr("id"))&&($control_input.attr("id",inputId+"-selectized"),$("label[for='"+inputId+"']").attr("for",inputId+"-selectized")),self.settings.copyClassesToDropdown&&inputMode.addClass(classes),$wrapper.css({width:$input[0].style.width}),self.plugins.names.length&&(inputId="plugin-"+self.plugins.names.join(" plugin-"),$wrapper.addClass(inputId),inputMode.addClass(inputId)),(null===settings.maxItems||1[data-selectable]",function(e){e.stopImmediatePropagation()}),inputMode.on("mouseenter","[data-selectable]",function(){return self.onOptionHover.apply(self,arguments)}),inputMode.on("mousedown click","[data-selectable]",function(){return self.onOptionSelect.apply(self,arguments)}),watchChildEvent($control,"mousedown","*:not(input)",function(){return self.onItemSelect.apply(self,arguments)}),autoGrow($control_input),$control.on({mousedown:function(){return self.onMouseDown.apply(self,arguments)},click:function(){return self.onClick.apply(self,arguments)}}),$control_input.on({mousedown:function(e){e.stopPropagation()},keydown:function(){return self.onKeyDown.apply(self,arguments)},keypress:function(){return self.onKeyPress.apply(self,arguments)},input:function(){return self.onInput.apply(self,arguments)},resize:function(){self.positionDropdown.apply(self,[])},blur:function(){return self.onBlur.apply(self,arguments)},focus:function(){return self.ignoreBlur=!1,self.onFocus.apply(self,arguments)},paste:function(){return self.onPaste.apply(self,arguments)}}),$document.on("keydown"+eventNS,function(e){self.isCmdDown=e[IS_MAC?"metaKey":"ctrlKey"],self.isCtrlDown=e[IS_MAC?"altKey":"ctrlKey"],self.isShiftDown=e.shiftKey}),$document.on("keyup"+eventNS,function(e){e.keyCode===KEY_CTRL&&(self.isCtrlDown=!1),e.keyCode===KEY_SHIFT&&(self.isShiftDown=!1),e.keyCode===KEY_CMD&&(self.isCmdDown=!1)}),$document.on("mousedown"+eventNS,function(e){if(self.isFocused){if(e.target===self.$dropdown[0]||e.target.parentNode===self.$dropdown[0])return!1;self.$control.has(e.target).length||e.target===self.$control[0]||self.blur(e.target)}}),$window.on(["scroll"+eventNS,"resize"+eventNS].join(" "),function(){self.isOpen&&self.positionDropdown.apply(self,arguments)}),$window.on("mousemove"+eventNS,function(){self.ignoreHover=!1}),this.revertSettings={$children:$input.children().detach(),tabindex:$input.attr("tabindex")},$input.attr("tabindex",-1).hide().after(self.$wrapper),Array.isArray(settings.items)&&(self.lastValidValue=settings.items,self.setValue(settings.items),delete settings.items),SUPPORTS_VALIDITY_API&&$input.on("invalid"+eventNS,function(e){e.preventDefault(),self.isInvalid=!0,self.refreshState()}),self.updateOriginalInput(),self.refreshItems(),self.refreshState(),self.updatePlaceholder(),self.isSetup=!0,$input.is(":disabled")&&self.disable(),self.on("change",this.onChange),$input.data("selectize",self),$input.addClass("selectized"),self.trigger("initialize"),!0===settings.preload&&self.onSearchChange("")},setupTemplates:function(){var field_label=this.settings.labelField,field_value=this.settings.valueField,field_optgroup=this.settings.optgroupLabelField;this.settings.render=$.extend({},{optgroup:function(data){return'
'+data.html+"
"},optgroup_header:function(data,escape){return'
'+escape(data[field_optgroup])+"
"},option:function(data,escape){return'
'+escape(data[field_label])+"
"},item:function(data,escape){return'
'+escape(data[field_label])+"
"},option_create:function(data,escape){return'
Add '+escape(data.input)+"
"}},this.settings.render)},setupCallbacks:function(){var key,fn,callbacks={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur",dropdown_item_activate:"onDropdownItemActivate",dropdown_item_deactivate:"onDropdownItemDeactivate"};for(key in callbacks)callbacks.hasOwnProperty(key)&&(fn=this.settings[callbacks[key]])&&this.on(key,fn)},onClick:function(e){this.isFocused&&this.isOpen||(this.focus(),e.preventDefault())},onMouseDown:function(e){var self=this,defaultPrevented=e.isDefaultPrevented();$(e.target);if(self.isFocused){if(e.target!==self.$control_input[0])return"single"===self.settings.mode?self.isOpen?self.close():self.open():defaultPrevented||self.setActiveItem(null),!1}else defaultPrevented||window.setTimeout(function(){self.focus()},0)},onChange:function(){""!==this.getValue()&&(this.lastValidValue=this.getValue()),this.$input.trigger("input"),this.$input.trigger("change")},onPaste:function(e){var self=this;self.isFull()||self.isInputHidden||self.isLocked?e.preventDefault():self.settings.splitOn&&setTimeout(function(){var pastedText=self.$control_input.val();if(pastedText.match(self.settings.splitOn))for(var splitInput=pastedText.trim().split(self.settings.splitOn),i=0,n=splitInput.length;i=this.settings.maxItems},updateOriginalInput:function(opts){var i,n,options,label;if(opts=opts||{},this.tagType===TAG_SELECT){for(options=[],i=0,n=this.items.length;i'+escape_html(label)+"");options.length||this.$input.attr("multiple")||options.push(''),this.$input.html(options.join(""))}else this.$input.val(this.getValue()),this.$input.attr("value",this.$input.val());this.isSetup&&!opts.silent&&this.trigger("change",this.$input.val())},updatePlaceholder:function(){var $input;this.settings.placeholder&&($input=this.$control_input,this.items.length?$input.removeAttr("placeholder"):$input.attr("placeholder",this.settings.placeholder),$input.triggerHandler("update",{force:!0}))},open:function(){this.isLocked||this.isOpen||"multi"===this.settings.mode&&this.isFull()||(this.focus(),this.isOpen=!0,this.refreshState(),this.$dropdown.css({visibility:"hidden",display:"block"}),this.positionDropdown(),this.$dropdown.css({visibility:"visible"}),this.trigger("dropdown_open",this.$dropdown))},close:function(){var trigger=this.isOpen;"single"===this.settings.mode&&this.items.length&&(this.hideInput(),this.isBlurring&&this.$control_input.blur()),this.isOpen=!1,this.$dropdown.hide(),this.setActiveOption(null),this.refreshState(),trigger&&this.trigger("dropdown_close",this.$dropdown)},positionDropdown:function(){var $control=this.$control,offset="body"===this.settings.dropdownParent?$control.offset():$control.position();offset.top+=$control.outerHeight(!0),this.$dropdown.css({width:$control[0].getBoundingClientRect().width,top:offset.top,left:offset.left})},clear:function(silent){this.items.length&&(this.$control.children(":not(input)").remove(),this.items=[],this.lastQuery=null,this.setCaret(0),this.setActiveItem(null),this.updatePlaceholder(),this.updateOriginalInput({silent:silent}),this.refreshState(),this.showInput(),this.trigger("clear"))},insertAtCaret:function($el){var caret=Math.min(this.caretPos,this.items.length),$el=$el[0],target=this.buffer||this.$control[0];0===caret?target.insertBefore($el,target.firstChild):target.insertBefore($el,target.childNodes[caret]),this.setCaret(caret+1)},deleteSelection:function(e){var i,n,values,option_select,$tail,direction=e&&e.keyCode===KEY_BACKSPACE?-1:1,selection=getSelection(this.$control_input[0]);if(this.$activeOption&&!this.settings.hideSelected&&(option_select=("string"==typeof this.settings.deselectBehavior&&"top"===this.settings.deselectBehavior?this.getFirstOption():this.getAdjacentOption(this.$activeOption,-1)).attr("data-value")),values=[],this.$activeItems.length){for($tail=this.$control.children(".active:"+(0'+settings.emptyOptionLabel+""+input_html)),("select"===tag_name?init_select:function($input,settings_element){var i,n,values,option,data_raw=$input.attr(attr_data);if(data_raw)for(settings_element.options=JSON.parse(data_raw),i=0,n=settings_element.options.length;iwindow.innerHeight?POSITION.top:POSITION.bottom,styles={width:$control.outerWidth(),left:offset.left};dropdownHeight===POSITION.top?(Object.assign(styles,{bottom:offset.top,top:"unset",margin:"0 0 5px 0"}),this.$dropdown.addClass("selectize-position-top")):(Object.assign(styles,{top:offset.top,bottom:"unset",margin:"5px 0 0 0"}),this.$dropdown.removeClass("selectize-position-top")),this.$dropdown.css(styles)}}),Selectize.define("auto_select_on_type",function(options){var originalBlur,self=this;self.onBlur=(originalBlur=self.onBlur,function(e){var $matchedItem=self.getFirstItemMatchedByTextContent(self.lastValue,!0);return void 0!==$matchedItem.attr("data-value")&&self.getValue()!==$matchedItem.attr("data-value")&&self.setValue($matchedItem.attr("data-value")),originalBlur.apply(this,arguments)})}),Selectize.define("autofill_disable",function(options){var original,self=this;self.setup=(original=self.setup,function(){original.apply(self,arguments),self.$control_input.attr({autocomplete:"new-password",autofill:"no"})})}),Selectize.define("drag_drop",function(options){if(!$.fn.sortable)throw new Error('The "drag_drop" plugin requires jQuery UI "sortable".');var self,original;"multi"===this.settings.mode&&((self=this).lock=(original=self.lock,function(){var sortable=self.$control.data("sortable");return sortable&&sortable.disable(),original.apply(self,arguments)}),self.unlock=function(){var original=self.unlock;return function(){var sortable=self.$control.data("sortable");return sortable&&sortable.enable(),original.apply(self,arguments)}}(),self.setup=function(){var original=self.setup;return function(){original.apply(this,arguments);var $control=self.$control.sortable({items:"[data-value]",forcePlaceholderSize:!0,disabled:self.isLocked,start:function(e,ui){ui.placeholder.css("width",ui.helper.css("width")),$control.addClass("dragging")},stop:function(){$control.removeClass("dragging");var active=self.$activeItems?self.$activeItems.slice():null,values=[];$control.children("[data-value]").each(function(){values.push($(this).attr("data-value"))}),self.isFocused=!1,self.setValue(values),self.isFocused=!0,self.setActiveItem(active),self.positionDropdown()}})}}())}),Selectize.define("dropdown_header",function(options){var original,self=this;options=$.extend({title:"Untitled",headerClass:"selectize-dropdown-header",titleRowClass:"selectize-dropdown-header-title",labelClass:"selectize-dropdown-header-label",closeClass:"selectize-dropdown-header-close",html:function(data){return'
'+data.title+'×
'}},options),self.setup=(original=self.setup,function(){original.apply(self,arguments),self.$dropdown_header=$(options.html(options)),self.$dropdown.prepend(self.$dropdown_header)})}),Selectize.define("optgroup_columns",function(options){function equalizeSizes(){var i,height_max,width,width_parent,$optgroups=$("[data-group]",self.$dropdown_content),n=$optgroups.length;if(n&&self.$dropdown_content.width()){if(options.equalizeHeight){for(i=height_max=0;i
',div=div.firstChild,doc.body.appendChild(div),width=getScrollbarWidth.width=div.offsetWidth-div.clientWidth,doc.body.removeChild(div)),width});(options.equalizeHeight||options.equalizeWidth)&&(hook.after(this,"positionDropdown",equalizeSizes),hook.after(this,"refreshOptions",equalizeSizes))}),Selectize.define("remove_button",function(options){options=$.extend({label:"×",title:"Remove",className:"remove",append:!0},options),("single"===this.settings.mode?function(thisRef,options){options.className="remove-single";var original,self=thisRef,html=''+options.label+"";thisRef.setup=(original=self.setup,function(){var id,render_item;options.append&&(id=$(self.$input.context).attr("id"),$("#"+id),render_item=self.settings.render.item,self.settings.render.item=function(data){return html_container=render_item.apply(thisRef,arguments),html_element=html,$("").append(html_container).append(html_element);var html_container,html_element}),original.apply(thisRef,arguments),thisRef.$control.on("click","."+options.className,function(e){e.preventDefault(),self.isLocked||self.clear()})})}:function(thisRef,options){var original,self=thisRef,html=''+options.label+"";thisRef.setup=(original=self.setup,function(){var render_item;options.append&&(render_item=self.settings.render.item,self.settings.render.item=function(data){return html_container=render_item.apply(thisRef,arguments),html_element=html,pos=html_container.search(/(<\/[^>]+>\s*)$/),html_container.substring(0,pos)+html_element+html_container.substring(pos);var html_container,html_element,pos}),original.apply(thisRef,arguments),thisRef.$control.on("click","."+options.className,function(e){if(e.preventDefault(),!self.isLocked)return e=$(e.currentTarget).parent(),self.setActiveItem(e),self.deleteSelection()&&self.setCaret(self.items.length),!1})})})(this,options)}),Selectize.define("restore_on_backspace",function(options){var original,self=this;options.text=options.text||function(option){return option[this.settings.labelField]},this.onKeyDown=(original=self.onKeyDown,function(e){var index;return e.keyCode===KEY_BACKSPACE&&""===this.$control_input.val()&&!this.$activeItems.length&&0<=(index=this.caretPos-1)&&index"+($items.length-limit)+""))}}}(),this.onFocus=function(){const original=self.onFocus;return function(e){if(original.apply(this,e),e){const $control=this.$control,$items=$control.find(".item");$items.show(),$control.find("span").remove()}}}()}); \ No newline at end of file +/** + * Selectize (v0.15.2) + * https://selectize.dev + * + * Copyright (c) 2013-2015 Brian Reavis & contributors + * Copyright (c) 2020-2022 Selectize Team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this + * file except in compliance with the License. You may obtain a copy of the License at: + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + * + * @author Brian Reavis + * @author Ris Adams + */ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + define(['jquery'], factory); + } else if (typeof module === 'object' && typeof module.exports === 'object') { + module.exports = factory(require('jquery')); + } else { + root.Selectize = factory(root.jQuery); + } +}(this, function ($) { + 'use strict'; +var highlight=function(t,e){var r,a;if("string"!=typeof e||e.length)return r="string"==typeof e?new RegExp(e,"i"):e,a=function(t){var e=0;if(3===t.nodeType){var n,i,o=t.data.search(r);0<=o&&0/g,">").replace(/"/g,""")},escape_replace=function(t){return(t+"").replace(/\$/g,"$$$$")},hook={before:function(t,e,n){var i=t[e];t[e]=function(){return n.apply(t,arguments),i.apply(t,arguments)}},after:function(e,t,n){var i=e[t];e[t]=function(){var t=i.apply(e,arguments);return n.apply(e,arguments),t}}},once=function(t){var e=!1;return function(){e||(e=!0,t.apply(this,arguments))}},debounce=function(n,i){var o;return function(){var t=this,e=arguments;window.clearTimeout(o),o=window.setTimeout(function(){n.apply(t,e)},i)}},debounce_events=function(e,n,t){var i,o=e.trigger,s={};for(i in e.trigger=function(){var t=arguments[0];if(-1===n.indexOf(t))return o.apply(e,arguments);s[t]=arguments},t.apply(e,[]),e.trigger=o,s)s.hasOwnProperty(i)&&o.apply(e,s[i])},watchChildEvent=function(n,t,e,i){n.on(t,e,function(t){for(var e=t.target;e&&e.parentNode!==n[0];)e=e.parentNode;return t.currentTarget=e,i.apply(this,[t])})},getInputSelection=function(t){var e,n,i={};return void 0===t?console.warn("WARN getInputSelection cannot locate input control"):"selectionStart"in t?(i.start=t.selectionStart,i.length=t.selectionEnd-i.start):document.selection&&(t.focus(),e=document.selection.createRange(),n=document.selection.createRange().text.length,e.moveStart("character",-t.value.length),i.start=e.text.length-n,i.length=n),i},transferStyles=function(t,e,n){var i,o,s={};if(n)for(i=0,o=n.length;i").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre"}),$("
").css({position:"absolute",width:0,height:0,overflow:"hidden"}).append(Selectize.$testInput).appendTo("body")),Selectize.$testInput.text(t),transferStyles(e,Selectize.$testInput,["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"]),Selectize.$testInput.width()):0},autoGrow=function(s){function t(t,e){var n,i,o;e=e||{},(t=t||window.event||{}).metaKey||t.altKey||!e.force&&!1===s.data("grow")||(e=s.val(),t.type&&"keydown"===t.type.toLowerCase()&&(n=48<=(i=t.keyCode)&&i<=57||65<=i&&i<=90||96<=i&&i<=111||186<=i&&i<=222||32===i,i===KEY_DELETE||i===KEY_BACKSPACE?(o=getInputSelection(s[0])).length?e=e.substring(0,o.start)+e.substring(o.start+o.length):i===KEY_BACKSPACE&&o.start?e=e.substring(0,o.start-1)+e.substring(o.start+1):i===KEY_DELETE&&void 0!==o.start&&(e=e.substring(0,o.start)+e.substring(o.start+1)):n&&(i=t.shiftKey,o=String.fromCharCode(t.keyCode),e+=o=i?o.toUpperCase():o.toLowerCase())),t=(n=s.attr("placeholder"))?measureString(n,s)+4:0,(i=Math.max(measureString(e,s),t)+4)===r)||(r=i,s.width(i),s.triggerHandler("resize"))}var r=null;s.on("keydown keyup update blur",t),t()},domToString=function(t){var e=document.createElement("div");return e.appendChild(t.cloneNode(!0)),e.innerHTML},logError=function(t,e){e=e||{};console.error("Selectize: "+t),e.explanation&&(console.group&&console.group(),console.error(e.explanation),console.group)&&console.groupEnd()},isJSON=function(t){try{JSON.parse(str)}catch(t){return!1}return!0},Selectize=function(t,e){var n,i,o=this,s=t[0],r=(s.selectize=o,window.getComputedStyle&&window.getComputedStyle(s,null));if(r=(r?r.getPropertyValue("direction"):s.currentStyle&&s.currentStyle.direction)||t.parents("[dir]:first").attr("dir")||"",$.extend(o,{order:0,settings:e,$input:t,tabIndex:t.attr("tabindex")||"",tagType:"select"===s.tagName.toLowerCase()?TAG_SELECT:TAG_INPUT,rtl:/rtl/i.test(r),eventNS:".selectize"+ ++Selectize.count,highlightedValue:null,isBlurring:!1,isOpen:!1,isDisabled:!1,isRequired:t.is("[required]"),isInvalid:!1,isLocked:!1,isFocused:!1,isInputHidden:!1,isSetup:!1,isShiftDown:!1,isCmdDown:!1,isCtrlDown:!1,ignoreFocus:!1,ignoreBlur:!1,ignoreHover:!1,hasOptions:!1,currentResults:null,lastValue:"",lastValidValue:"",lastOpenTarget:!1,caretPos:0,loading:0,loadedSearches:{},isDropdownClosing:!1,$activeOption:null,$activeItems:[],optgroups:{},options:{},userOptions:{},items:[],renderCache:{},onSearchChange:null===e.loadThrottle?o.onSearchChange:debounce(o.onSearchChange,e.loadThrottle)}),o.sifter=new Sifter(this.options,{diacritics:e.diacritics}),o.settings.options){for(n=0,i=o.settings.options.length;n").addClass(t.wrapperClass).addClass(a+" selectize-control").addClass(r),p=$("
").addClass(t.inputClass+" selectize-input items").appendTo(l),c=$('').appendTo(p).attr("tabindex",s.is(":disabled")?"-1":e.tabIndex),u=$(t.dropdownParent||l),r=$("
").addClass(t.dropdownClass).addClass(r+" selectize-dropdown").hide().appendTo(u),u=$("
").addClass(t.dropdownContentClass+" selectize-dropdown-content").attr("tabindex","-1").appendTo(r),d=((d=s.attr("id"))&&(c.attr("id",d+"-selectized"),$("label[for='"+d+"']").attr("for",d+"-selectized")),e.settings.copyClassesToDropdown&&r.addClass(a),l.css({width:s[0].style.width}),e.plugins.names.length&&(d="plugin-"+e.plugins.names.join(" plugin-"),l.addClass(d),r.addClass(d)),(null===t.maxItems||1[data-selectable]",function(t){t.stopImmediatePropagation()}),r.on("mouseenter","[data-selectable]",function(){return e.onOptionHover.apply(e,arguments)}),r.on("mouseup click","[data-selectable]",function(){return e.onOptionSelect.apply(e,arguments)}),watchChildEvent(p,"mouseup","*:not(input)",function(){return e.onItemSelect.apply(e,arguments)}),autoGrow(c),p.on({mousedown:function(){return e.onMouseDown.apply(e,arguments)},click:function(){return e.onClick.apply(e,arguments)}}),c.on({mousedown:function(t){""===e.$control_input.val()&&!e.settings.openOnFocus||t.stopPropagation()},keydown:function(){return e.onKeyDown.apply(e,arguments)},keypress:function(){return e.onKeyPress.apply(e,arguments)},input:function(){return e.onInput.apply(e,arguments)},resize:function(){e.positionDropdown.apply(e,[])},focus:function(){return e.ignoreBlur=!1,e.onFocus.apply(e,arguments)},paste:function(){return e.onPaste.apply(e,arguments)}}),o.on("keydown"+n,function(t){e.isCmdDown=t[IS_MAC?"metaKey":"ctrlKey"],e.isCtrlDown=t[IS_MAC?"altKey":"ctrlKey"],e.isShiftDown=t.shiftKey}),o.on("keyup"+n,function(t){t.keyCode===KEY_CTRL&&(e.isCtrlDown=!1),t.keyCode===KEY_SHIFT&&(e.isShiftDown=!1),t.keyCode===KEY_CMD&&(e.isCmdDown=!1)}),o.on("mousedown"+n,function(t){if(e.isFocused){if(t.target===e.$dropdown[0]||t.target.parentNode===e.$dropdown[0])return!1;e.$dropdown.has(t.target).length||t.target===e.$control[0]||e.blur(t.target)}}),i.on(["scroll"+n,"resize"+n].join(" "),function(){e.isOpen&&e.positionDropdown.apply(e,arguments)}),i.on("mousemove"+n,function(){e.ignoreHover=e.settings.ignoreHover}),$("
")),a=s.children().detach();s.replaceWith(d),d.replaceWith(s),this.revertSettings={$children:a,tabindex:s.attr("tabindex")},s.attr("tabindex",-1).hide().after(e.$wrapper),Array.isArray(t.items)&&(e.lastValidValue=t.items,e.setValue(t.items),delete t.items),SUPPORTS_VALIDITY_API&&s.on("invalid"+n,function(t){t.preventDefault(),e.isInvalid=!0,e.refreshState()}),e.updateOriginalInput(),e.refreshItems(),e.refreshState(),e.updatePlaceholder(),e.isSetup=!0,s.is(":disabled")&&e.disable(),e.on("change",this.onChange),s.data("selectize",e),s.addClass("selectized"),e.trigger("initialize"),!0===t.preload&&e.onSearchChange("")},setupTemplates:function(){var t=this,i=t.settings.labelField,o=t.settings.valueField,n=t.settings.optgroupLabelField;t.settings.render=$.extend({},{optgroup:function(t){return'
'+t.html+"
"},optgroup_header:function(t,e){return'
'+e(t[n])+"
"},option:function(t,e){var n=t.classes?" "+t.classes:"";return n+=""===t[o]?" selectize-dropdown-emptyoptionlabel":"","'+e(t[i])+"
"},item:function(t,e){return'
'+e(t[i])+"
"},option_create:function(t,e){return'
Add '+e(t.input)+"
"}},t.settings.render)},setupCallbacks:function(){var t,e,n={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur",dropdown_item_activate:"onDropdownItemActivate",dropdown_item_deactivate:"onDropdownItemDeactivate"};for(t in n)n.hasOwnProperty(t)&&(e=this.settings[n[t]])&&this.on(t,e)},onClick:function(t){this.isDropdownClosing||this.isFocused&&this.isOpen||(this.focus(),t.preventDefault())},onMouseDown:function(t){var e=this,n=t.isDefaultPrevented();$(t.target);if(e.isFocused||n||window.setTimeout(function(){e.focus()},0),t.target!==e.$control_input[0]||""===e.$control_input.val())return"single"===e.settings.mode?e.isOpen?e.close():e.open():(n||e.setActiveItem(null),e.settings.openOnFocus||(e.isOpen&&t.target===e.lastOpenTarget?(e.close(),e.lastOpenTarget=!1):(e.isOpen||(e.refreshOptions(),e.open()),e.lastOpenTarget=t.target))),!1},onChange:function(){""!==this.getValue()&&(this.lastValidValue=this.getValue()),this.$input.trigger("input"),this.$input.trigger("change")},onPaste:function(t){var o=this;o.isFull()||o.isInputHidden||o.isLocked?t.preventDefault():o.settings.splitOn&&setTimeout(function(){var t=o.$control_input.val();if(t.match(o.settings.splitOn))for(var e=t.trim().split(o.settings.splitOn),n=0,i=e.length;n=this.settings.maxItems},updateOriginalInput:function(t){var e,n,i,o,s,r,a=this;t=t||{},a.tagType===TAG_SELECT?(o=a.$input.find("option"),e=[],n=[],i=[],r=[],o.get().forEach(function(t){e.push(t.value)}),a.items.forEach(function(t){s=a.options[t][a.settings.labelField]||"",r.push(t),-1==e.indexOf(t)&&n.push('")}),i=e.filter(function(t){return r.indexOf(t)<0}).map(function(t){return'option[value="'+t+'"]'}),e.length-i.length+n.length!==0||a.$input.attr("multiple")||n.push(''),a.$input.find(i.join(", ")).remove(),a.$input.append(n.join(""))):(a.$input.val(a.getValue()),a.$input.attr("value",a.$input.val())),a.isSetup&&!t.silent&&a.trigger("change",a.$input.val())},updatePlaceholder:function(){var t;this.settings.placeholder&&(t=this.$control_input,this.items.length?t.removeAttr("placeholder"):t.attr("placeholder",this.settings.placeholder),t.triggerHandler("update",{force:!0}))},open:function(){var t=this;t.isLocked||t.isOpen||"multi"===t.settings.mode&&t.isFull()||(t.focus(),t.isOpen=!0,t.refreshState(),t.$dropdown.css({visibility:"hidden",display:"block"}),t.setupDropdownHeight(),t.positionDropdown(),t.$dropdown.css({visibility:"visible"}),t.trigger("dropdown_open",t.$dropdown))},close:function(){var t=this,e=t.isOpen;"single"===t.settings.mode&&t.items.length&&(t.hideInput(),t.isBlurring)&&t.$control_input[0].blur(),t.isOpen=!1,t.$dropdown.hide(),t.setActiveOption(null),t.refreshState(),e&&t.trigger("dropdown_close",t.$dropdown)},positionDropdown:function(){var t=this.$control,e="body"===this.settings.dropdownParent?t.offset():t.position(),t=(e.top+=t.outerHeight(!0),t[0].getBoundingClientRect().width);this.settings.minWidth&&this.settings.minWidth>t&&(t=this.settings.minWidth),this.$dropdown.css({width:t,top:e.top,left:e.left})},setupDropdownHeight:function(){if("object"==typeof this.settings.dropdownSize&&"auto"!==this.settings.dropdownSize.sizeType){var t=this.settings.dropdownSize.sizeValue;if("numberItems"===this.settings.dropdownSize.sizeType){for(var e=this.$dropdown_content.find("*").not(".optgroup, .highlight").not(this.settings.ignoreOnDropwdownHeight),n=0,i=0,o=0,s=0,r=0;r'+i+""+l)),{placeholder:n,options:[],optgroups:[],items:[]});if("select"===e)u(t,i);else{var o,s,r,a,l=t,p=i,n=l.attr(m);if(n)for(p.options=JSON.parse(n),o=0,s=p.options.length;owindow.innerHeight?o.top:o.bottom,i={width:t.outerWidth(),left:e.left};n===o.top?(n={bottom:e.top,top:"unset"},"body"===this.settings.dropdownParent&&(n.top=e.top-this.$dropdown.outerHeight(!0)-t.outerHeight(!0),n.bottom="unset"),Object.assign(i,n),this.$dropdown.addClass("selectize-position-top"),this.$control.addClass("selectize-position-top")):(Object.assign(i,{top:e.top,bottom:"unset"}),this.$dropdown.removeClass("selectize-position-top"),this.$control.removeClass("selectize-position-top")),this.$dropdown.css(i)}}),Selectize.define("auto_select_on_type",function(t){var n,i=this;i.onBlur=(n=i.onBlur,function(t){var e=i.getFirstItemMatchedByTextContent(i.lastValue,!0);return void 0!==e.attr("data-value")&&i.getValue()!==e.attr("data-value")&&i.setValue(e.attr("data-value")),n.apply(this,arguments)})}),Selectize.define("autofill_disable",function(t){var e,n=this;n.setup=(e=n.setup,function(){e.apply(n,arguments),n.$control_input.attr({autocomplete:"new-password",autofill:"no"})})}),Selectize.define("clear_button",function(e){var t,n=this;e=$.extend({title:"Clear",className:"clear",label:"×",html:function(t){return' '+t.label+""}},e),n.setup=(t=n.setup,function(){t.apply(n,arguments),n.$button_clear=$(e.html(e)),"single"===n.settings.mode&&n.$wrapper.addClass("single"),n.$wrapper.append(n.$button_clear),""!==n.getValue()&&0!==n.getValue().length||n.$wrapper.find("."+e.className).css("display","none"),n.on("change",function(){""===n.getValue()||0===n.getValue().length?n.$wrapper.find("."+e.className).css("display","none"):n.$wrapper.find("."+e.className).css("display","")}),n.$wrapper.on("click","."+e.className,function(t){t.preventDefault(),t.stopImmediatePropagation(),t.stopPropagation(),n.isLocked||(n.clear(),n.$wrapper.find("."+e.className).css("display","none"))})})}),Selectize.define("drag_drop",function(t){if(!$.fn.sortable)throw new Error('The "drag_drop" plugin requires jQuery UI "sortable".');var i,e,n,o;"multi"===this.settings.mode&&((i=this).lock=(e=i.lock,function(){var t=i.$control.data("sortable");return t&&t.disable(),e.apply(i,arguments)}),i.unlock=(n=i.unlock,function(){var t=i.$control.data("sortable");return t&&t.enable(),n.apply(i,arguments)}),i.setup=(o=i.setup,function(){o.apply(this,arguments);var n=i.$control.sortable({items:"[data-value]",forcePlaceholderSize:!0,disabled:i.isLocked,start:function(t,e){e.placeholder.css("width",e.helper.css("width")),n.addClass("dragging")},stop:function(){n.removeClass("dragging");var t=i.$activeItems?i.$activeItems.slice():null,e=[];n.children("[data-value]").each(function(){e.push($(this).attr("data-value"))}),i.isFocused=!1,i.setValue(e),i.isFocused=!0,i.setActiveItem(t),i.positionDropdown()}})}))}),Selectize.define("dropdown_header",function(t){var e,n=this;t=$.extend({title:"Untitled",headerClass:"selectize-dropdown-header",titleRowClass:"selectize-dropdown-header-title",labelClass:"selectize-dropdown-header-label",closeClass:"selectize-dropdown-header-close",html:function(t){return'
'+t.title+'×
'}},t),n.setup=(e=n.setup,function(){e.apply(n,arguments),n.$dropdown_header=$(t.html(t)),n.$dropdown.prepend(n.$dropdown_header),n.$dropdown_header.find("."+t.closeClass).on("click",function(){n.close()})})}),Selectize.define("optgroup_columns",function(r){function t(){var t,e,n,i,o=$("[data-group]",a.$dropdown_content),s=o.length;if(s&&a.$dropdown_content.width()){if(r.equalizeHeight){for(t=e=0;t
',t=t.firstChild,n.body.appendChild(t),e=l.width=t.offsetWidth-t.clientWidth,n.body.removeChild(t)),e});(r.equalizeHeight||r.equalizeWidth)&&(hook.after(this,"positionDropdown",t),hook.after(this,"refreshOptions",t))}),Selectize.define("remove_button",function(t){var s,e,n,i,r;"single"!==this.settings.mode&&(t=$.extend({label:"×",title:"Remove",className:"remove",append:!0},t),i=s=this,r=''+e.label+"",s.setup=(n=i.setup,function(){var o;e.append&&(o=i.settings.render.item,i.settings.render.item=function(t){return e=o.apply(s,arguments),n=r,i=e.search(/(<\/[^>]+>\s*)$/),e.substring(0,i)+n+e.substring(i);var e,n,i}),n.apply(s,arguments),s.$control.on("click","."+e.className,function(t){if(t.preventDefault(),!i.isLocked)return t=$(t.currentTarget).parent(),i.setActiveItem(t),i.deleteSelection()&&i.setCaret(i.items.length),!1})}))}),Selectize.define("restore_on_backspace",function(n){var i,t=this;n.text=n.text||function(t){return t[this.settings.labelField]},this.onKeyDown=(i=t.onKeyDown,function(t){var e;if(!(t.keyCode===KEY_BACKSPACE&&""===this.$control_input.val()&&!this.$activeItems.length&&0<=(e=this.caretPos-1)&&e"+(e.length-n)+""))}}}(),this.onFocus=function(){const e=t.onFocus;return function(t){e.apply(this,t),t&&((t=this.$control).find(".item").show(),t.find("span").remove())}}()}); + return Selectize; +})); diff --git a/patchwork/admin.py b/patchwork/admin.py index d3bdae1bb..267a3820f 100644 --- a/patchwork/admin.py +++ b/patchwork/admin.py @@ -13,6 +13,7 @@ from patchwork.models import Cover from patchwork.models import CoverComment from patchwork.models import DelegationRule +from patchwork.models import Label from patchwork.models import Patch from patchwork.models import PatchComment from patchwork.models import PatchRelation @@ -44,11 +45,18 @@ class DelegationRuleInline(admin.TabularInline): fields = ('path', 'user', 'priority') +class LabelInline(admin.TabularInline): + model = Label + fields = ('name', 'description', 'color') + extra = 0 + + @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): list_display = ('name', 'linkname', 'listid', 'listemail') inlines = [ DelegationRuleInline, + LabelInline, ] @@ -91,6 +99,7 @@ class PatchAdmin(admin.ModelAdmin): 'is_pull_request', ) list_filter = ('project', 'submitter', 'state', 'archived') + readonly_fields = ('labels',) list_select_related = ('submitter', 'project', 'state') search_fields = ('name', 'submitter__name', 'submitter__email') date_hierarchy = 'date' @@ -194,3 +203,12 @@ class TagAdmin(admin.ModelAdmin): @admin.register(PatchRelation) class PatchRelationAdmin(admin.ModelAdmin): model = PatchRelation + + +@admin.register(Label) +class LabelAdmin(admin.ModelAdmin): + list_display = ('name', 'color') + + def get_queryset(self, request): + qs = super(LabelAdmin, self).get_queryset(request) + return qs.filter(project=None) diff --git a/patchwork/api/cover.py b/patchwork/api/cover.py index ee6b301c4..20ed81f5d 100644 --- a/patchwork/api/cover.py +++ b/patchwork/api/cover.py @@ -9,6 +9,7 @@ from rest_framework.generics import RetrieveAPIView from rest_framework.reverse import reverse from rest_framework.serializers import SerializerMethodField +from rest_framework.serializers import StringRelatedField from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.filters import CoverFilterSet @@ -25,6 +26,7 @@ class CoverListSerializer(BaseHyperlinkedModelSerializer): mbox = SerializerMethodField() series = SeriesSerializer(read_only=True) comments = SerializerMethodField() + labels = StringRelatedField(many=True) def get_web_url(self, instance): request = self.context.get('request') @@ -62,11 +64,13 @@ class Meta: 'mbox', 'series', 'comments', + 'labels', ) read_only_fields = fields versioned_fields = { '1.1': ('web_url', 'mbox', 'comments'), '1.2': ('list_archive_url',), + '1.4': ('labels',), } extra_kwargs = { 'url': {'view_name': 'api-cover-detail'}, @@ -110,7 +114,10 @@ class CoverList(ListAPIView): def get_queryset(self): return ( Cover.objects.all() - .prefetch_related('series__project') + .prefetch_related( + 'series__project', + 'labels', + ) .select_related('project', 'submitter', 'series') .defer('content', 'headers') ) diff --git a/patchwork/api/filters.py b/patchwork/api/filters.py index e332c5316..9bd42254f 100644 --- a/patchwork/api/filters.py +++ b/patchwork/api/filters.py @@ -26,6 +26,9 @@ from patchwork.models import Series from patchwork.models import State +from patchwork.models import exclude_submissions_by_labels +from patchwork.models import filter_submissions_by_labels + # custom backend @@ -175,6 +178,21 @@ def msgid_filter(queryset, name, value): return queryset.filter(**{name: '<' + value + '>'}) +def labels_filter(queryset, _, value): + label_names = value.split(',') + + labels_pos, labels_neg = [], [] + for label in label_names: + if not label.startswith('-'): + labels_pos.append(label) + else: + labels_neg.append(label[1:]) + + queryset = exclude_submissions_by_labels(queryset, labels_neg) + queryset = filter_submissions_by_labels(queryset, labels_pos) + return queryset + + class CoverFilterSet(TimestampMixin, BaseFilterSet): project = ProjectFilter(queryset=Project.objects.all(), distinct=False) # NOTE(stephenfin): We disable the select-based HTML widgets for these @@ -206,6 +224,7 @@ class PatchFilterSet(TimestampMixin, BaseFilterSet): state = StateFilter(queryset=State.objects.all(), distinct=False) hash = CharFilter(lookup_expr='iexact') msgid = CharFilter(method=msgid_filter) + labels = CharFilter(method=labels_filter) class Meta: model = Patch @@ -225,6 +244,7 @@ class Meta: ) versioned_fields = { '1.2': ('hash', 'msgid'), + '1.4': ('labels'), } diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py index 443c38226..31d1dadf5 100644 --- a/patchwork/api/patch.py +++ b/patchwork/api/patch.py @@ -18,6 +18,7 @@ from rest_framework.reverse import reverse from rest_framework.serializers import SerializerMethodField from rest_framework import status +from rest_framework.serializers import StringRelatedField from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import PatchworkPermission @@ -94,6 +95,7 @@ class PatchListSerializer(BaseHyperlinkedModelSerializer): default=[], style={'base_template': 'input.html'}, ) + labels = StringRelatedField(many=True) def get_web_url(self, instance): request = self.context.get('request') @@ -177,6 +179,7 @@ class Meta: 'checks', 'tags', 'related', + 'labels', ) read_only_fields = ( 'url', @@ -194,6 +197,7 @@ class Meta: 'check', 'checks', 'tags', + 'labels', ) versioned_fields = { '1.1': ('comments', 'web_url'), @@ -201,6 +205,7 @@ class Meta: 'list_archive_url', 'related', ), + '1.4': ('labels',), } extra_kwargs = { 'url': {'view_name': 'api-patch-detail'}, @@ -367,6 +372,7 @@ def get_queryset(self): 'project', 'series__project', 'related__patches__project', + 'labels', ) .select_related('state', 'submitter', 'series') .defer('content', 'diff', 'headers') @@ -391,7 +397,9 @@ class PatchDetail(RetrieveUpdateAPIView): def get_queryset(self): return ( Patch.objects.all() - .prefetch_related('check_set', 'related__patches__project') + .prefetch_related( + 'check_set', 'related__patches__project', 'labels' + ) .select_related( 'project', 'state', 'submitter', 'delegate', 'series' ) diff --git a/patchwork/fields.py b/patchwork/fields.py index 5cdebcd55..dc5f92ba7 100644 --- a/patchwork/fields.py +++ b/patchwork/fields.py @@ -27,3 +27,27 @@ def from_db_value(self, value, *args, **kwargs): def db_type(self, connection=None): return 'char(%d)' % self.n_bytes + + +class ColorField(models.Field): + description = 'Hex color code' + + def get_internal_type(self): + return 'PositiveIntegerField' + + def to_python(self, value): + if isinstance(value, str) or value is None: + return value + return '#%06x' % value + + def from_db_value(self, value, *args, **kwargs): + return self.to_python(value) + + def get_prep_value(self, value): + return int(value.lstrip('#'), 16) + + def formfield(self, *args, **kwargs): + from patchwork import forms # noqa + + kwargs['form_class'] = forms.ColorField + return super(ColorField, self).formfield(*args, **kwargs) diff --git a/patchwork/filters.py b/patchwork/filters.py index 99aad8c30..9195cbce3 100644 --- a/patchwork/filters.py +++ b/patchwork/filters.py @@ -13,6 +13,8 @@ from patchwork.models import Person from patchwork.models import Series from patchwork.models import State +from patchwork.models import exclude_submissions_by_labels +from patchwork.models import filter_submissions_by_labels class Filter(object): @@ -511,6 +513,62 @@ def set_status(self, *args, **kwargs): self.forced = True +class LabelsFilter(Filter): + name = 'Labels' + param = 'labels' + + def __init__(self, filters): + super(LabelsFilter, self).__init__(filters) + self.labels = None + + @property + def condition(self): + return self.labels + + @property + def key(self): + return self.labels + + @key.setter + def key(self, key): + key = key.strip() + if not key: + return + + self.labels = key + self.applied = True + + @property + def kwargs(self): + return None + + def q(self, queryset): + label_names = self.labels.split(' ') + if len(label_names) == 0: + return queryset + + labels_pos, labels_neg = [], [] + for label in label_names: + if not label.startswith('-'): + labels_pos.append(label) + else: + labels_neg.append(label[1:]) + + queryset = exclude_submissions_by_labels(queryset, labels_neg) + queryset = filter_submissions_by_labels(queryset, labels_pos) + return queryset + + @property + def form(self): + value = '' + if self.labels: + value = escape(self.labels) + return mark_safe( + '' + % (self.param, value) + ) + + FILTERS = [ SeriesFilter, SubmitterFilter, @@ -518,6 +576,7 @@ def set_status(self, *args, **kwargs): SearchFilter, ArchiveFilter, DelegateFilter, + LabelsFilter, ] @@ -547,14 +606,22 @@ def available_filters(self): def apply(self, queryset): kwargs = collections.OrderedDict() + q_filters = [] for f in self._filters: if f.applied: - kwargs.update(f.kwargs) + if f.kwargs is not None: + kwargs.update(f.kwargs) + else: + q_filters.append(f) - if not kwargs: + if not kwargs and not q_filters: return queryset - return queryset.filter(**kwargs) + queryset = queryset.filter(**kwargs) + for f in q_filters: + queryset = f.q(queryset) + + return queryset def querystring(self, remove=None): params = self.params diff --git a/patchwork/fixtures/default_labels.xml b/patchwork/fixtures/default_labels.xml new file mode 100644 index 000000000..1e1aa705b --- /dev/null +++ b/patchwork/fixtures/default_labels.xml @@ -0,0 +1,9 @@ + + + + + RFC + "RFC" stands for "Request for Comment", which tells maintainers that they should review your patch thoroughly and provide feedback. RFC is typically used when sending feature patches for the first time, or anytime the patch is more than just a simple bug fix. + #EDEDED + + diff --git a/patchwork/forms.py b/patchwork/forms.py index efedfa976..394812835 100644 --- a/patchwork/forms.py +++ b/patchwork/forms.py @@ -3,15 +3,18 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +import re + from django.contrib.auth.models import User from django import forms -from django.forms import renderers +from django.forms import widgets, renderers from django.db.models import Q from django.db.utils import ProgrammingError from django.template.backends import django as django_template_backend from patchwork.models import Bundle from patchwork.models import Patch +from patchwork.models import Label from patchwork.models import State from patchwork.models import UserProfile @@ -142,10 +145,19 @@ def __init__(self, instance=None, project=None, *args, **kwargs): widget=forms.Select(attrs={'class': 'change-property-delegate'}), required=False, ) + self.fields['labels'] = forms.ModelMultipleChoiceField( + queryset=Label.objects.filter( + Q(project=project) | Q(project=None) + ), + required=False, + widget=forms.SelectMultiple( + attrs={'class': 'labels-field', 'placeholder': 'Labels'} + ), + ) class Meta: model = Patch - fields = ['state', 'archived', 'delegate'] + fields = ['state', 'archived', 'delegate', 'labels'] widgets = { 'state': forms.Select(attrs={'class': 'change-property-state'}), 'archived': forms.CheckboxInput( @@ -221,6 +233,15 @@ def __init__(self, project, *args, **kwargs): label='Delegate to', required=False, ) + self.fields['labels'] = forms.ModelMultipleChoiceField( + queryset=Label.objects.filter( + Q(project=project) | Q(project=None) + ), + widget=forms.SelectMultiple( + attrs={'class': 'labels-field', 'placeholder': 'Labels to add'} + ), + required=False, + ) self.fields['state'] = OptionalModelChoiceField( queryset=State.objects.all(), placeholder='Change state', @@ -252,4 +273,36 @@ def save(self, instance, commit=True): if commit: instance.save() + + for f in opts.many_to_many: + if f.name not in data: + continue + + getattr(instance, f.name).add(*data[f.name]) + return instance + + +class ColorInput(widgets.Input): + input_type = 'color' + + +class ColorField(forms.CharField): + widget = ColorInput + default_error_messages = { + 'invalid': 'Enter a valid colour value: e.g. "#ff0022"', + } + + def clean(self, value): + if not re.match('^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$', value): + raise forms.ValidationError(self.error_messages['invalid']) + + value = int(value.lstrip('#'), 16) + super(ColorField, self).clean(value) + + return value + + def widget_attrs(self, widget): + attrs = super().widget_attrs(widget) + attrs['maxlength'] = 7 + return attrs diff --git a/patchwork/management/commands/relabel.py b/patchwork/management/commands/relabel.py new file mode 100644 index 000000000..954f2cd3c --- /dev/null +++ b/patchwork/management/commands/relabel.py @@ -0,0 +1,75 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2026, The Linux Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from django.core.management.base import BaseCommand + +from email.parser import HeaderParser + +from patchwork.models import Label, Patch +from patchwork.parser import clean_subject + + +class Command(BaseCommand): + help = ( + 'Update labels for existing patches based on the subject line. ' + 'NOTE: Labels have to be created in the admin interface first.' + ) + + def add_arguments(self, parser): + parser.add_argument( + 'projects', + metavar='PROJECT', + nargs='*', + help='list of listIDs of projects to be relabeld', + ) + + def handle(self, *args, **options): + query = Patch.objects.all().only('id', 'headers', 'project_id') + labels = { + (label.name, label.project_id): label + for label in Label.objects.all() + } + labels_to_add = [] + + if options['projects']: + query = query.filter(project__listid__in=options['projects']) + + count = query.count() + + for i, patch in enumerate(query.iterator(chunk_size=1000)): + parser = HeaderParser() + headers = parser.parsestr(patch.headers) + subject = headers['Subject'] + if subject is None: + continue + + _, prefixes = clean_subject(subject) + + for prefix in set(prefixes): + label = labels.get((prefix, patch.project_id)) + + if label is None: + label = labels.get((prefix, None)) + + if label is not None: + labels_to_add.append( + Patch.labels.through( + patch_id=patch.id, label_id=label.id + ) + ) + + if (i % 100) == 0: + self.stdout.write('%06d/%06d\r' % (i, count), ending='') + self.stdout.flush() + + if len(labels_to_add) >= 1000: + Patch.labels.through.objects.bulk_create(labels_to_add) + labels_to_add = [] + + if len(labels_to_add) > 0: + Patch.labels.through.objects.bulk_create(labels_to_add) + labels_to_add = [] + + self.stdout.write('\ndone') diff --git a/patchwork/migrations/0049_add_patch_labels.py b/patchwork/migrations/0049_add_patch_labels.py new file mode 100644 index 000000000..565aab9cd --- /dev/null +++ b/patchwork/migrations/0049_add_patch_labels.py @@ -0,0 +1,74 @@ +# Generated by Django 6.0.7 on 2026-07-31 21:26 + +import django.db.models.deletion +import patchwork.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('patchwork', '0048_series_dependencies'), + ] + + operations = [ + migrations.CreateModel( + name='Label', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField( + help_text='The label value.', max_length=25 + ), + ), + ( + 'description', + models.TextField( + blank=True, + help_text='A description of what the label is and when it should be applied.', + null=True, + ), + ), + ( + 'color', + patchwork.fields.ColorField( + help_text='The color code to use in the UI.' + ), + ), + ( + 'project', + models.ForeignKey( + blank=True, + help_text='The project this label is associated with. If unset, this label is global to the instance', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='labels', + related_query_name='label', + to='patchwork.project', + ), + ), + ], + options={ + 'ordering': ['name'], + 'unique_together': {('project', 'name')}, + }, + ), + migrations.AddField( + model_name='cover', + name='labels', + field=models.ManyToManyField(to='patchwork.label'), + ), + migrations.AddField( + model_name='patch', + name='labels', + field=models.ManyToManyField(to='patchwork.label'), + ), + ] diff --git a/patchwork/models.py b/patchwork/models.py index d5cb31de9..28bfe6805 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -18,7 +18,7 @@ from django.utils.functional import cached_property from django.utils import timezone as tz_utils -from patchwork.fields import HashField +from patchwork.fields import HashField, ColorField from patchwork.hasher import hash_diff if settings.ENABLE_REST_API: @@ -256,6 +256,43 @@ class Meta: ordering = ['ordering'] +class Label(models.Model): + """Labels for patches. + + Labels are arbitrary bits of metadata attached to a patch. They can + be used to signify priority, category, or other similar information. + They can also be used to filter patches and identify the ones most + interesting to a given user. + """ + + project = models.ForeignKey( + Project, + related_name='labels', + related_query_name='label', + null=True, + blank=True, + on_delete=models.CASCADE, + help_text='The project this label is associated with. If unset, this ' + 'label is global to the instance', + ) + + name = models.CharField(max_length=25, help_text='The label value.') + description = models.TextField( + null=True, + blank=True, + help_text='A description of what the label is and when it should be ' + 'applied.', + ) + color = ColorField(help_text='The color code to use in the UI.') + + def __str__(self): + return self.name + + class Meta: + ordering = ['name'] + unique_together = [('project', 'name')] + + class Tag(models.Model): name = models.CharField(max_length=20) pattern = models.CharField( @@ -435,6 +472,7 @@ class SubmissionMixin(FilenameMixin, EmailMixin, models.Model): # submission metadata name = models.CharField(max_length=255) + labels = models.ManyToManyField(Label) @cached_property def list_archive_url(self): @@ -460,6 +498,24 @@ class Meta: abstract = True +def exclude_submissions_by_labels(queryset, labels): + if len(labels) > 0: + queryset = queryset.exclude(labels__name__in=labels) + + return queryset + + +def filter_submissions_by_labels(queryset, labels): + if len(labels) > 0: + queryset = ( + queryset.filter(labels__name__in=labels) + .annotate(num_labels=models.Count('labels', distinct=True)) + .filter(num_labels__gte=len(labels)) + ) + + return queryset + + class Cover(SubmissionMixin): def get_absolute_url(self): return reverse( diff --git a/patchwork/parser.py b/patchwork/parser.py index 703d07c14..661509eb3 100644 --- a/patchwork/parser.py +++ b/patchwork/parser.py @@ -17,6 +17,7 @@ from urllib.parse import urlparse, parse_qs from django.contrib.auth.models import User +from django.db.models import Q from django.db.utils import IntegrityError from django.db import transaction from django.utils import timezone as tz_utils @@ -26,6 +27,7 @@ from patchwork.models import CoverComment from patchwork.models import DelegationRule from patchwork.models import get_default_initial_patch_state +from patchwork.models import Label from patchwork.models import Patch from patchwork.models import PatchComment from patchwork.models import Person @@ -627,6 +629,22 @@ def parse_version(subject, subject_prefixes): return 1 +def parse_labels(subject_prefixes, project): + """Extract labels from subject. + + Args: + subject_prefixes: List of subject prefixes to extract tags from + """ + labels = Label.objects.filter( + Q(project=project) | Q(project=None), name__in=subject_prefixes + ) + for label in labels: + if label.name in subject_prefixes: + subject_prefixes.remove(label.name) + + return labels + + def _find_content(mail): """Extract the payload(s) from a mail. @@ -1270,6 +1288,8 @@ def parse_mail(mail, list_id=None): if Patch.objects.filter(project=project, msgid=msgid): raise DuplicateMailError(msgid=msgid) + labels = parse_labels(prefixes, project) + patch = Patch.objects.create( msgid=msgid, project=project, @@ -1283,6 +1303,9 @@ def parse_mail(mail, list_id=None): delegate=delegate, state=find_state(mail), ) + if labels: + patch.labels.set(labels) + logger.debug('Patch saved') for attempt in range(1, 11): # arbitrary retry count diff --git a/patchwork/templates/patchwork/partials/filters.html b/patchwork/templates/patchwork/partials/filters.html index 1175af147..80b615562 100644 --- a/patchwork/templates/patchwork/partials/filters.html +++ b/patchwork/templates/patchwork/partials/filters.html @@ -35,11 +35,44 @@ })(); }); +// Submit form on shift+enter or ctrl+enter +Selectize.define('mod_enter_key_submit', function (options) { + var self = this; + + this.onKeyDown = (function (e) { + var original = self.onKeyDown; + + return function (e) { + var wasOpened = this.isOpen; + + if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey)) { + self.trigger('submit'); + return; + } + + original.apply(this, arguments); + }; + })(); +}); + $(document).ready(function() { $('#submitter_input').selectize({ plugins: ['enter_key_submit'], maxItems: 1, persist: false, + // Allows creating new options. Useful for partial search + create: true, + // Creates current input as option after control loses focus + createOnBlur: true, + // Custom option will be the default selection + addPrecedence: true, + render: { + // Custom rendering for "current input" option - removes potentially + // confusing "Add" prefix. + option_create: function(input, esc) { + return '
' + esc(input.input) + '
'; + }, + }, onInitialize: function() { this.on('submit', function() { if (!this.items.length) @@ -76,6 +109,30 @@ }); } }); + + $('#labels_input').selectize({ + plugins: ['mod_enter_key_submit'], + maxItems: null, + persist: false, + hideSelected: true, + // Search for options that begin with a minus sign + respect_word_boundaries: false, + delimiter: ' ', + options: [ +{% for label in project_labels %} + { + value: "{{ label.name }}", + text: "{{ label.name }}", + }, +{% endfor %} +{% for label in project_labels %} + { + value: "-{{ label.name }}", + text: "-{{ label.name }}", + }, +{% endfor %} + ], + }); }); diff --git a/patchwork/templates/patchwork/partials/patch-forms.html b/patchwork/templates/patchwork/partials/patch-forms.html index 80f828153..1722a9316 100644 --- a/patchwork/templates/patchwork/partials/patch-forms.html +++ b/patchwork/templates/patchwork/partials/patch-forms.html @@ -1,3 +1,16 @@ +{% load static %} + + +
{% if patch_form %}
@@ -13,6 +26,10 @@ {{ patch_form.archived.errors }} {{ patch_form.archived.label_tag }} {{ patch_form.archived }}
+
+ {{ patch_form.labels.errors }} + {{ patch_form.labels }} +
diff --git a/patchwork/templates/patchwork/partials/patch-list.html b/patchwork/templates/patchwork/partials/patch-list.html index 981ceee58..97f541f74 100644 --- a/patchwork/templates/patchwork/partials/patch-list.html +++ b/patchwork/templates/patchwork/partials/patch-list.html @@ -172,6 +172,7 @@ {{ patch.name|default:"[no subject]"|truncatechars:100 }} + {{ patch|patch_labels }} {% if patch.series %} diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html index cd74491c0..f65480abb 100644 --- a/patchwork/templates/patchwork/submission.html +++ b/patchwork/templates/patchwork/submission.html @@ -130,6 +130,12 @@

{{ submission.name }}

{% endif %} + + Labels + + {{ submission|patch_labels }} + +
diff --git a/patchwork/templatetags/patch.py b/patchwork/templatetags/patch.py index c22dfa336..a9902d49d 100644 --- a/patchwork/templatetags/patch.py +++ b/patchwork/templatetags/patch.py @@ -70,3 +70,31 @@ def patch_commit_display(patch): return mark_safe( '%s' % (escape(fmt.format(commit)), escape(commit)) ) + + +@register.filter(name='patch_labels') +def patch_labels(patch): + + def text_color(hex_color): + """Generate the ideal text color given a background color. + + From https://www.w3.org/TR/AERT/#color-contrast + """ + red, green, blue = [ + int(hex_color.lstrip('#')[i : i + 2], 16) for i in (0, 2, 4) + ] + brightness = (red * 299 + green * 587 + blue * 114) / 1000 + + return '#000' if brightness >= 123 else '#fff' + + output = [] + for label in patch.labels.all(): + style = 'background-color: %s; color: %s' % ( + label.color, + text_color(label.color), + ) + output.append( + '%s' % (style, label.name) + ) + + return mark_safe(''.join(output)) diff --git a/patchwork/tests/unit/api/test_cover.py b/patchwork/tests/unit/api/test_cover.py index 643806d92..092a2a137 100644 --- a/patchwork/tests/unit/api/test_cover.py +++ b/patchwork/tests/unit/api/test_cover.py @@ -40,6 +40,11 @@ def assertSerialized(self, cover_obj, cover_json): self.assertIn(cover_obj.get_absolute_url(), cover_json['web_url']) self.assertIn('comments', cover_json) + # list fields + + for label in cover_obj.labels.all(): + self.assertIn(label.name, cover_json['labels']) + # nested fields self.assertEqual(cover_obj.submitter.id, cover_json['submitter']['id']) @@ -130,13 +135,25 @@ def test_list_version_1_0(self): self.assertIn('url', resp.data[0]) self.assertNotIn('mbox', resp.data[0]) self.assertNotIn('web_url', resp.data[0]) + self.assertNotIn('labels', resp.data[0]) + + def test_list_version_1_1(self): + create_cover() + + resp = self.client.get(self.api_url(version='1.1')) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, len(resp.data)) + self.assertIn('url', resp.data[0]) + self.assertIn('mbox', resp.data[0]) + self.assertIn('web_url', resp.data[0]) + self.assertNotIn('labels', resp.data[0]) def test_list_bug_335(self): """Ensure we retrieve the embedded series project once.""" series = create_series() create_covers(5, series=series) - with self.assertNumQueries(3): + with self.assertNumQueries(4): self.client.get(self.api_url()) @utils.store_samples('cover-detail') @@ -177,6 +194,15 @@ def test_detail_invalid(self): with self.assertRaises(NoReverseMatch): self.client.get(self.api_url('foo')) + def test_detail_version_1_1(self): + cover = create_cover() + + resp = self.client.get(self.api_url(cover.id, version='1.1')) + self.assertIn('url', resp.data) + self.assertIn('web_url', resp.data) + self.assertIn('comments', resp.data) + self.assertNotIn('labels', resp.data) + def test_create_update_delete(self): user = create_maintainer() user.is_superuser = True diff --git a/patchwork/tests/unit/api/test_patch.py b/patchwork/tests/unit/api/test_patch.py index b2890c224..1facf3118 100644 --- a/patchwork/tests/unit/api/test_patch.py +++ b/patchwork/tests/unit/api/test_patch.py @@ -13,6 +13,7 @@ from patchwork.models import Patch from patchwork.tests.unit.api import utils +from patchwork.tests.utils import create_label from patchwork.tests.utils import create_maintainer from patchwork.tests.utils import create_patch from patchwork.tests.utils import create_patches @@ -54,6 +55,11 @@ def assertSerialized(self, patch_obj, patch_json): self.assertIn(patch_obj.get_absolute_url(), patch_json['web_url']) self.assertIn('comments', patch_json) + # list fields + + for label in patch_obj.labels.all(): + self.assertIn(label.name, patch_json['labels']) + # nested fields self.assertEqual(patch_obj.submitter.id, patch_json['submitter']['id']) @@ -222,6 +228,40 @@ def test_list_filter_msgid(self): resp = self.client.get(self.api_url(), {'msgid': 'fishfish@fish.fish'}) self.assertEqual(0, len(resp.data)) + def test_list_filter_labels(self): + """Filter patches by labels.""" + label1 = create_label() + label2 = create_label() + label3 = create_label() + + person_obj = create_person(email='test@example.com') + project_obj = create_project(linkname='myproject') + state_obj = create_state(name='Under Review') + patch_kwargs = { + 'state': state_obj, + 'project': project_obj, + 'submitter': person_obj, + } + patch_no_labels = create_patch(**patch_kwargs) + patch1 = create_patch(**patch_kwargs, labels=[label1]) + patch2 = create_patch(**patch_kwargs, labels=[label1, label2]) + + resp = self.client.get(self.api_url(), {'labels': label1.name}) + self.assertEqual([patch1.id, patch2.id], [x['id'] for x in resp.data]) + + resp = self.client.get(self.api_url(), {'labels': label3.name}) + self.assertEqual(0, len(resp.data)) + + resp = self.client.get(self.api_url(), {'labels': '-' + label2.name}) + self.assertEqual( + [patch_no_labels.id, patch1.id], [x['id'] for x in resp.data] + ) + + resp = self.client.get( + self.api_url(), {'labels': label1.name + ',' + label2.name} + ) + self.assertEqual([patch2.id], [x['id'] for x in resp.data]) + @utils.store_samples('patch-list-1-0') def test_list_version_1_0(self): """List patches using API v1.0.""" @@ -232,13 +272,23 @@ def test_list_version_1_0(self): self.assertEqual(1, len(resp.data)) self.assertIn('url', resp.data[0]) self.assertNotIn('web_url', resp.data[0]) + self.assertNotIn('labels', resp.data[0]) + + def test_list_version_1_1(self): + create_patch() + + resp = self.client.get(self.api_url(version='1.1')) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, len(resp.data)) + self.assertIn('web_url', resp.data[0]) + self.assertNotIn('labels', resp.data[0]) def test_list_bug_335(self): """Ensure we retrieve the embedded series project in O(1).""" series = create_series() create_patches(5, series=series) - with self.assertNumQueries(5): + with self.assertNumQueries(6): self.client.get(self.api_url()) @utils.store_samples('patch-detail') @@ -271,6 +321,23 @@ def test_detail_version_1_0(self): self.assertIn('url', resp.data) self.assertNotIn('web_url', resp.data) self.assertNotIn('comments', resp.data) + self.assertNotIn('labels', resp.data) + + def test_detail_version_1_1(self): + patch = create_patch() + + resp = self.client.get(self.api_url(item=patch.id, version='1.1')) + self.assertIn('url', resp.data) + self.assertIn('web_url', resp.data) + self.assertIn('comments', resp.data) + self.assertNotIn('labels', resp.data) + + def test_detail_version_1_4(self): + label = create_label() + patch = create_patch(labels=[label]) + + resp = self.client.get(self.api_url(item=patch.id, version='1.4')) + self.assertEqual(resp.data['labels'], [label.name]) def test_detail_non_existent(self): """Ensure we get a 404 for a non-existent patch.""" @@ -282,6 +349,15 @@ def test_detail_invalid(self): with self.assertRaises(NoReverseMatch): self.client.get(self.api_url('foo')) + def test_detail_labels(self): + patch = create_patch() + + resp = self.client.get(self.api_url(item=patch.id, version='1.4')) + self.assertIn('url', resp.data) + self.assertIn('web_url', resp.data) + self.assertIn('comments', resp.data) + self.assertIn('labels', resp.data) + def test_create(self): """Ensure creations are rejected.""" project = create_project() diff --git a/patchwork/tests/unit/test_parser.py b/patchwork/tests/unit/test_parser.py index 1411ef0bc..018daf941 100644 --- a/patchwork/tests/unit/test_parser.py +++ b/patchwork/tests/unit/test_parser.py @@ -28,6 +28,7 @@ from patchwork.parser import find_comment_content from patchwork.parser import find_project from patchwork.parser import find_series +from patchwork.parser import parse_labels from patchwork.parser import parse_mail as _parse_mail from patchwork.parser import parse_pull_request from patchwork.parser import parse_series_marker @@ -41,6 +42,7 @@ from patchwork.tests.utils import create_cover_comment from patchwork.tests.utils import create_patch from patchwork.tests.utils import create_patch_comment +from patchwork.tests.utils import create_label from patchwork.tests.utils import create_project from patchwork.tests.utils import create_series from patchwork.tests.utils import create_series_reference @@ -1199,6 +1201,28 @@ def test_version(self): self.assertEqual(parse_version('Hello, world (v2)', []), 2) self.assertEqual(parse_version('Hello, world (V6)', []), 6) + def test_labels(self): + label = create_label(name='RFC') + + prefixes = ['RFC'] + self.assertEqual(list(parse_labels(prefixes, label.project)), [label]) + self.assertEqual(prefixes, []) + + prefixes = ['rfcx'] + self.assertEqual(list(parse_labels(prefixes, label.project)), []) + self.assertEqual(prefixes, ['rfcx']) + + project = create_project() + prefixes = ['RFC'] + self.assertEqual(list(parse_labels(prefixes, project)), []) + self.assertEqual(prefixes, ['RFC']) + + label = create_label(name='stuff', project=None) + + prefixes = ['stuff'] + self.assertEqual(list(parse_labels(prefixes, project)), [label]) + self.assertEqual(prefixes, []) + class SubjectMatchTest(TestCase): def setUp(self): diff --git a/patchwork/tests/unit/views/test_patch.py b/patchwork/tests/unit/views/test_patch.py index 133bae366..983cf85b6 100644 --- a/patchwork/tests/unit/views/test_patch.py +++ b/patchwork/tests/unit/views/test_patch.py @@ -23,6 +23,7 @@ from patchwork.tests.utils import create_patches from patchwork.tests.utils import create_person from patchwork.tests.utils import create_project +from patchwork.tests.utils import create_label from patchwork.tests.utils import create_state from patchwork.tests.utils import create_user from patchwork.tests.utils import read_patch @@ -203,6 +204,128 @@ def test_utf8_handling(self): self.assertEqual(response.status_code, 200) +class PatchListLabelFilteringTest(TestCase): + def setUp(self): + self.project = create_project() + self.label1 = create_label(project=self.project) + self.label2 = create_label(project=self.project) + self.global_label = create_label(project=None) + + person = create_person(name='test', email='test@example.com') + + self.patches = [ + create_patch(submitter=person, project=self.project, labels=[]), + create_patch( + submitter=person, project=self.project, labels=[self.label1] + ), + create_patch( + submitter=person, + project=self.project, + labels=[self.label1, self.label2], + ), + create_patch( + submitter=person, + project=self.project, + labels=[self.label1, self.global_label], + ), + ] + + def _extract_patch_ids(self, response): + id_re = re.compile(r'