Merge branch 'release/16.6' into dev
@@ -11,18 +11,25 @@ keywords: work package workflows
|
||||
|
||||
A **workflow** in OpenProject is defined as the allowed transitions between status for a role and a type, i.e. which status changes can a certain role implement depending on the work package type.
|
||||
|
||||
This means, a certain type of work package, e.g. a Task, can have the following workflows: News -> In Progress -> Closed -> On Hold -> Rejected -> Closed. This workflow can be different depending on the [role in a project](../../users-permissions/roles-permissions).
|
||||
This means, a certain type of work package, e.g. a Task, can have the following workflows: News → In Progress → Closed → On Hold → Rejected → Closed. This workflow can be different depending on the [role in a project](../../users-permissions/roles-permissions).
|
||||
|
||||
## Edit workflows
|
||||
|
||||
To edit a workflow:
|
||||
To edit a workflow, first decide if you want to edit default transitions that apply to all users (depending only on the role) or for the specific cases where a user is the author or the assignee. Three tabs on top of the screen allow you to choose this:
|
||||
|
||||

|
||||
|
||||
Once you are in the right tab:
|
||||
|
||||
1. Select the **role** from the dropdown menu for which you want to edit the workflow.
|
||||
2. Select the **work package type** from the dropdown menu for which you want to edit the workflow.
|
||||
3. Check if you **only want the statuses that are used by this type** to be displayed (this option is disabled per default, but you can always activate it).
|
||||
**Note**: If you have created a [new status](../work-package-status) and want to add it to a workflow of a certain work package type, you need to deselect this option. Only this way also status that are not (yet) used by this type will appear in the list and can be added to a workflow.
|
||||
4. Click the **Edit** button.
|
||||
|
||||
> [!NOTE]
|
||||
> If you have created a [new status](../work-package-status) and want to add it to a workflow of a certain work package type, you need to deselect this option. Only this way also status that are not (yet) used by this type will appear in the list and can be added to a workflow.
|
||||
|
||||
|
||||

|
||||
|
||||
You will be able to adapt the following:
|
||||
|
||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -2,34 +2,72 @@
|
||||
sidebar_navigation:
|
||||
title: Documents
|
||||
priority: 770
|
||||
description: Upload documents in OpenProject.
|
||||
description: Create documents and attach files in OpenProject.
|
||||
keywords: documents
|
||||
---
|
||||
|
||||
# Documents
|
||||
|
||||
This module allows uploading documents directly to the project under project menu item *Documents* and categorizing documents.
|
||||
The Documents module allows you to write or upload documents directly to the project.
|
||||
|
||||
> Please note that this module only allows to manually upload documents directly into projects. For more advanced
|
||||
> functionalities, please take a look at the [file storages integrations](../file-management).
|
||||
> [!NOTE]
|
||||
> Please note that this module only allows you to manually write or upload documents directly to a project. For more advanced functionalities, please take a look at the [file storages integrations](../file-management).
|
||||
|
||||
## Document index
|
||||
|
||||
To use the Documents module, make sure it is enabled in the Project settings of your project (Project settings → Modules).
|
||||
|
||||
Once it is enabled, you can navigate to the *Documents* module in the sidebar of your project to get to the Documents index that lists all available documents:
|
||||
|
||||

|
||||
|
||||
The Documents index page lets you:
|
||||
|
||||
1. View all documents
|
||||
2. Filter by document type
|
||||
3. Quick-filter the list of documents based on the document title
|
||||
4. Add a new document
|
||||
5. View a list of all available documents, including their type and the date they were last edited
|
||||
|
||||
A document in OpenProject can be:
|
||||
|
||||
- a text written directly in the editor of a document
|
||||
- a file uploaded and attached to a document
|
||||
- both a file uploaded and attached to a document, with a text note that describes it
|
||||
|
||||
## View a document
|
||||
|
||||
To view a document, simply click on the name of a document in the index. You will then see the document:
|
||||
|
||||

|
||||
|
||||
A document has:
|
||||
|
||||
1. A title, a category and creation date
|
||||
2. Edit and delete buttons
|
||||
3. The description of the document (or the document text itself)
|
||||
4. Attachments
|
||||
|
||||
## Add a new document to the project
|
||||
|
||||
To upload a document select Documents from the project menu and click *New Document*. You can select the document category from the ones that you have created under project administration settings. See in the Admin Guide how to create a new document category.
|
||||
To create a new document, click on the *+ Document* button.
|
||||
|
||||
Name the document and add a short description. After you have uploaded the file, do not forget to click Save.
|
||||

|
||||
|
||||

|
||||
In the form that appears, select the document category, give it a title and an optional description. You can optionally also attach a file to the document.
|
||||
|
||||
The uploaded documents are visible to all project members who have the necessary permission.
|
||||
Please note that that these document categories are created by the administrator of your instance.
|
||||
|
||||
> **Note**: There is no versioning of documents. It is simply and upload of documents to the respective project.
|
||||
The uploaded documents are visible to all project members who have the necessary permissions.
|
||||
|
||||
> [!NOTE]
|
||||
> There is no versioning of documents. An edit of any field or the contents of the document is visible to all members.
|
||||
|
||||
## Edit or delete a project document
|
||||
|
||||
You can edit or delete documents anytime. To do that, navigate to the Documents overview and select the document you want to edit. By selecting *Edit* or *Delete* respectively you can either adjust the document file and related information or remove the file permanently. You can add the file again at a later point.
|
||||
You can edit or delete documents anytime. To do that, click on the *Edit* button when viewing a document. This will take you to edit view:
|
||||
|
||||

|
||||

|
||||
|
||||
## Frequently asked questions (FAQ)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 605 KiB |
@@ -128,9 +128,9 @@ If you select the **Work package** option, you can link a work package by enteri
|
||||
|
||||
#### Edit a meeting agenda
|
||||
|
||||
After you have finalized the agenda, you can always edit the agenda items, add notes, move an item up or down or delete it. Clicking on the **More** (three dots) menu icon on the right edge of each agenda item will display a menu of available options, including editing, copying link to clipboard, moving the agenda item within the agenda or deleting it.
|
||||
After you have finalized the agenda, you can always edit the agenda items, add notes, move an item up or down or delete it. Clicking on the **More** (three dots) menu icon on the right edge of each agenda item will display a menu of available options, including editing, copying link to clipboard, moving the agenda item within the agenda or to the backlog, or deleting it.
|
||||
|
||||

|
||||

|
||||
|
||||
You may also re-order agenda items by clicking on the drag handle (the icon with six dots) on the left edge of each agenda item and dragging that item above or below.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 129 KiB |
@@ -126,11 +126,9 @@ Within the same menu you also have the following options:
|
||||
|
||||
### Move an agenda item to next meeting
|
||||
|
||||
In addition to all the options available for [editing one-time meetings](../one-time-meetings), within a single meeting of a recurring meeting series you can move an agenda item to next meeting.
|
||||
In addition to all the options available when clicking on the three-dot **More** (⋯) menu for an agenda item in when [editing one-time meetings](../one-time-meetings), you will see one additional option to move the agenda item to the next meeting occurrence in the series.
|
||||
|
||||
To do that click the **More** (three dots) icon next to an agenda item and select **Move to next meeting**.
|
||||
|
||||

|
||||

|
||||
|
||||
## Meeting backlogs for recurring meetings
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 319 KiB |
@@ -27,7 +27,7 @@
|
||||
//++
|
||||
|
||||
import { setupServerResponse } from 'core-app/core/setup/globals/global-listeners/setup-server-response';
|
||||
import { openExternalLinksInNewTab, performAnchorHijacking } from './global-listeners/link-hijacking';
|
||||
import { performAnchorHijacking } from './global-listeners/link-hijacking';
|
||||
|
||||
/**
|
||||
* A set of listeners that are relevant on every page to set sensible defaults
|
||||
@@ -56,22 +56,11 @@ export function initializeGlobalListeners():void {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbacks = [
|
||||
openExternalLinksInNewTab,
|
||||
performAnchorHijacking,
|
||||
];
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const fn of callbacks) {
|
||||
if (fn.call(linkElement, evt, linkElement)) {
|
||||
evt.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent angular handling clicks on href="#..." links from other libraries
|
||||
// (especially jquery-ui and its datepicker) from routing to <base url>/#
|
||||
performAnchorHijacking(evt, linkElement);
|
||||
if (performAnchorHijacking(evt, linkElement)) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for 'zenModeToggled' event to toggle Zen Mode styling on the body.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/link-handling';
|
||||
|
||||
/**
|
||||
* Our application is still a hybrid one, meaning most routes are still
|
||||
* handled by Rails. As such, we disable the default link-hijacking that
|
||||
@@ -24,35 +22,3 @@ export function performAnchorHijacking(evt:MouseEvent, linkElement:HTMLAnchorEle
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the origin of a clicked link
|
||||
* @param evt
|
||||
* @param linkElement
|
||||
*/
|
||||
export function openExternalLinksInNewTab(evt:MouseEvent, linkElement:HTMLAnchorElement):boolean {
|
||||
if (isClickedWithModifier(evt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const link = linkElement.href || '';
|
||||
|
||||
if (link === '' || !!linkElement.download) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin = window.location.origin;
|
||||
|
||||
try {
|
||||
const url = new URL(link, window.location.origin);
|
||||
if (origin !== url.origin) {
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// Do nothing if the url is invalid.
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -29,61 +29,111 @@
|
||||
import { ApplicationController } from 'stimulus-use';
|
||||
import { useMutation } from 'stimulus-use';
|
||||
|
||||
const BLANK_LINK_QUERY = 'a[target="_blank"]';
|
||||
const BLANK_LINK_DESCRIPTION_ID = 'open-blank-target-link-description';
|
||||
const LINK_QUERY = 'a[target="_blank"], a[href^="http://"], a[href^="https://"]';
|
||||
|
||||
const isLinkBlank = (link:HTMLAnchorElement) => link.target === '_blank';
|
||||
const isLinkExternal = (link:HTMLAnchorElement) => {
|
||||
try {
|
||||
const linkUrl = new URL(link.href, window.location.origin);
|
||||
return linkUrl.origin !== window.location.origin;
|
||||
} catch {
|
||||
// Do nothing if the url is invalid.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const isElement = (node:Node):node is Element => node.nodeType === Node.ELEMENT_NODE;
|
||||
const isBlankLink = (elem:Element):elem is HTMLAnchorElement => elem.matches(BLANK_LINK_QUERY);
|
||||
const isLink = (elem:Element):elem is HTMLAnchorElement => elem.matches(LINK_QUERY);
|
||||
const shouldProcessLink = (link:HTMLAnchorElement) => {
|
||||
const href = link.href || '';
|
||||
// Skip links with empty href or with download attribute
|
||||
if (href === '' || link.hasAttribute('download')) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Observes all external links and sets their ARIA `describedby` attribute to
|
||||
* {BLANK_LINK_DESCRIPTION_ID} - this element should exist in the DOM and
|
||||
* provide localized text content along the lines of "Open link in a new tab".
|
||||
* Dynamically observes and processes all links on the page, including those added later via Turbo
|
||||
* frames or DOM mutations.
|
||||
*
|
||||
* The goal is to make users of Assistive Technology aware that they may have to
|
||||
* switch tabs on clicking a link.
|
||||
* Part A) for links with `target="_blank"`
|
||||
* - Adds `aria-describedby` pointing to a description element (`BLANK_LINK_DESCRIPTION_ID`) to
|
||||
* inform users of assistive technologies that the link opens in a new tab.
|
||||
*
|
||||
* We consider links with a `target` attribute set to "_blank" as "external".
|
||||
* Part B) for external links (pointing to a different domain than the current page):
|
||||
* - Sets `target="_blank"` to open in a new tab.
|
||||
* - Sets `rel="noopener noreferrer"` for security and performance.
|
||||
* - and by virtue of setting `target="_blank"`, should be processed as in Part A.
|
||||
*
|
||||
* This ensures accessibility, security, and consistent behavior for all links, including
|
||||
* dynamically loaded content.
|
||||
*/
|
||||
export default class ExternalLinksController extends ApplicationController {
|
||||
connect() {
|
||||
useMutation(this, { attributes: true, childList: true, subtree: true, attributeFilter: ['target'] });
|
||||
useMutation(this, { attributes: true, childList: true, subtree: true, attributeFilter: ['target', 'href'] });
|
||||
|
||||
// initial pass
|
||||
document.querySelectorAll(BLANK_LINK_QUERY).forEach(applyLinkDescription);
|
||||
// Initial pass: handle existing external links (accessibility)
|
||||
document.querySelectorAll<HTMLAnchorElement>(LINK_QUERY).forEach((link)=>{
|
||||
if (!shouldProcessLink(link)) return;
|
||||
|
||||
if (isLinkBlank(link)) updateBlankLink(link);
|
||||
|
||||
if (isLinkExternal(link)) updateExternalLink(link);
|
||||
});
|
||||
}
|
||||
|
||||
mutate(mutations:MutationRecord[]) {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (isElement(node)) {
|
||||
// added element itself is a blank link
|
||||
if (isBlankLink(node)) {
|
||||
applyLinkDescription(node);
|
||||
// Added element itself is an external link
|
||||
if (isLink(node) && shouldProcessLink(node)) {
|
||||
if (isLinkBlank(node)) updateBlankLink(node);
|
||||
if (isLinkExternal(node)) updateExternalLink(node);
|
||||
}
|
||||
// added sub-trees
|
||||
node.querySelectorAll(BLANK_LINK_QUERY).forEach(applyLinkDescription);
|
||||
|
||||
node.querySelectorAll<HTMLAnchorElement>(LINK_QUERY).forEach((link)=>{
|
||||
if (!shouldProcessLink(link)) return;
|
||||
|
||||
if (isLinkBlank(link)) updateBlankLink(link);
|
||||
|
||||
if (isLinkExternal(link)) updateExternalLink(link);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// attribute changes
|
||||
// Attribute changes
|
||||
if (
|
||||
mutation.type === 'attributes' &&
|
||||
mutation.attributeName === 'target' &&
|
||||
isElement(mutation.target) &&
|
||||
isBlankLink(mutation.target)
|
||||
isLink(mutation.target) &&
|
||||
shouldProcessLink(mutation.target)
|
||||
) {
|
||||
applyLinkDescription(mutation.target);
|
||||
if (mutation.attributeName === 'target' && isLinkBlank(mutation.target)) updateBlankLink(mutation.target);
|
||||
if (mutation.attributeName === 'href' && isLinkExternal(mutation.target)) updateExternalLink(mutation.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyLinkDescription(link:HTMLAnchorElement) {
|
||||
const existingValue = link.getAttribute('aria-describedby');
|
||||
if (!existingValue) {
|
||||
function updateBlankLink(link:HTMLAnchorElement) {
|
||||
// Ensure accessibility description
|
||||
const describedBy = link.getAttribute('aria-describedby');
|
||||
if (!describedBy) {
|
||||
link.setAttribute('aria-describedby', BLANK_LINK_DESCRIPTION_ID);
|
||||
} else if (!existingValue.split(/\s+/).includes(BLANK_LINK_DESCRIPTION_ID)) {
|
||||
link.setAttribute('aria-describedby', existingValue + ' ' + BLANK_LINK_DESCRIPTION_ID);
|
||||
} else if (!describedBy.split(/\s+/).includes(BLANK_LINK_DESCRIPTION_ID)) {
|
||||
link.setAttribute('aria-describedby', `${describedBy} ${BLANK_LINK_DESCRIPTION_ID}`);
|
||||
}
|
||||
}
|
||||
|
||||
function updateExternalLink(link:HTMLAnchorElement) {
|
||||
// Ensure external link behavior
|
||||
link.target = '_blank';
|
||||
|
||||
// Merge rel attributes safely
|
||||
const relValues = new Set([
|
||||
...(link.getAttribute('rel')?.split(/\s+/) ?? []),
|
||||
'noopener',
|
||||
'noreferrer',
|
||||
]);
|
||||
link.setAttribute('rel', Array.from(relValues).join(' '));
|
||||
}
|
||||
|
||||
@@ -43,4 +43,74 @@ RSpec.describe "External links", :js do
|
||||
expect(page).to have_link target: "_blank", described_by: "Open link in a new tab"
|
||||
expect(page.all(:link, target: "_blank")).to all match_selector(:link, described_by: "Open link in a new tab")
|
||||
end
|
||||
|
||||
it "updates external links to open in a new tab and sets rel attributes" do
|
||||
visit "/"
|
||||
|
||||
page.execute_script <<~JS
|
||||
const link = document.createElement('a');
|
||||
link.href = 'https://example.com';
|
||||
link.textContent = 'External Example';
|
||||
document.body.appendChild(link);
|
||||
JS
|
||||
|
||||
# Wait for mutation observer to detect and update the link
|
||||
expect(page).to have_link("External Example", href: "https://example.com", target: "_blank")
|
||||
|
||||
link = find_link("External Example", href: "https://example.com", match: :first)
|
||||
|
||||
# Verify accessibility and security attributes
|
||||
expect(link[:target]).to eq("_blank")
|
||||
expect(link[:rel]).to include("noopener")
|
||||
expect(link[:rel]).to include("noreferrer")
|
||||
|
||||
# It should also get the accessibility description
|
||||
expect(link[:"aria-describedby"]).to include("open-blank-target-link-description")
|
||||
end
|
||||
|
||||
it "does not modify links with empty href or download attribute" do
|
||||
visit "/"
|
||||
|
||||
page.execute_script <<~JS
|
||||
const emptyLink = document.createElement('a');
|
||||
emptyLink.href = '';
|
||||
emptyLink.textContent = 'Empty link';
|
||||
document.body.appendChild(emptyLink);
|
||||
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.href = '/files/sample.pdf';
|
||||
downloadLink.download = 'sample.pdf';
|
||||
downloadLink.textContent = 'Download PDF';
|
||||
document.body.appendChild(downloadLink);
|
||||
JS
|
||||
|
||||
empty_link = find_link("Empty link", href: "", match: :first)
|
||||
download_link = find_link("Download PDF", href: "/files/sample.pdf", match: :first)
|
||||
|
||||
# The controller should NOT modify these links
|
||||
expect(empty_link[:target]).to be_in([nil, ""])
|
||||
expect(empty_link[:rel]).to be_nil.or eq("")
|
||||
expect(empty_link[:"aria-describedby"]).to be_nil.or eq("")
|
||||
|
||||
expect(download_link[:target]).to be_in([nil, ""])
|
||||
expect(download_link[:rel]).to be_nil.or eq("")
|
||||
expect(download_link[:"aria-describedby"]).to be_nil.or eq("")
|
||||
end
|
||||
|
||||
it 'adds aria-describedby to links with target="_blank"' do
|
||||
visit "/"
|
||||
|
||||
page.execute_script <<~JS
|
||||
const blankLink = document.createElement('a');
|
||||
blankLink.href = '/internal-page';
|
||||
blankLink.target = '_blank';
|
||||
blankLink.textContent = 'Opens in new tab';
|
||||
document.body.appendChild(blankLink);
|
||||
JS
|
||||
|
||||
link = find_link("Opens in new tab", href: "/internal-page")
|
||||
|
||||
expect(link[:target]).to eq("_blank")
|
||||
expect(link[:"aria-describedby"]).to include("open-blank-target-link-description")
|
||||
end
|
||||
end
|
||||
|
||||