diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/README.md b/docs/system-admin-guide/manage-work-packages/work-package-workflows/README.md index e7071c23879..a104fd92bcf 100644 --- a/docs/system-admin-guide/manage-work-packages/work-package-workflows/README.md +++ b/docs/system-admin-guide/manage-work-packages/work-package-workflows/README.md @@ -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: + +![Tabs to select between default transitions, when the user is the author or when the user is the asignee](admin_workflow_tabs.png) + +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. + + ![System-admin-guide-work-package-workflows](System-admin-guide-work-package-workflows.png) You will be able to adapt the following: diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows.png index 3e2127e7c58..df11b40ef46 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy.png index 27c62e2f6fc..d648532317d 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_edit.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_edit.png index 7cf895fe260..41b54497bed 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_edit.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_edit.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_summary.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_summary.png index 3bfdc0bdb13..b21f72ab583 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_summary.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_summary.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/admin_workflow_tabs.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/admin_workflow_tabs.png new file mode 100644 index 00000000000..4c8efe57c49 Binary files /dev/null and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/admin_workflow_tabs.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/oldSystem-admin-guide-work-package-workflows_edit.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/oldSystem-admin-guide-work-package-workflows_edit.png new file mode 100644 index 00000000000..7cf895fe260 Binary files /dev/null and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/oldSystem-admin-guide-work-package-workflows_edit.png differ diff --git a/docs/user-guide/documents/README.md b/docs/user-guide/documents/README.md index 6edb5cf8249..2c7ca474b10 100644 --- a/docs/user-guide/documents/README.md +++ b/docs/user-guide/documents/README.md @@ -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: + +![Documents index lists of all available documents in an OpenProject project](documents-new-index.png) + +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: + +![Viewing a document with an attachment in OpenProject](documents-new-view-with-attachment.png) + +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. +![Create a new document in OpenProject Documents module](documents-new-new-document.png) -![documents](image-20200130110857682.png) +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: -![edit or delete document](image-20200130111121885.png) +![Edit a document in OpenProject Documents module](documents-new-edit.png) ## Frequently asked questions (FAQ) diff --git a/docs/user-guide/documents/documents-new-edit.png b/docs/user-guide/documents/documents-new-edit.png new file mode 100644 index 00000000000..239c04b1448 Binary files /dev/null and b/docs/user-guide/documents/documents-new-edit.png differ diff --git a/docs/user-guide/documents/documents-new-index.png b/docs/user-guide/documents/documents-new-index.png new file mode 100644 index 00000000000..f9aaf36be15 Binary files /dev/null and b/docs/user-guide/documents/documents-new-index.png differ diff --git a/docs/user-guide/documents/documents-new-new-document.png b/docs/user-guide/documents/documents-new-new-document.png new file mode 100644 index 00000000000..72229d3efe9 Binary files /dev/null and b/docs/user-guide/documents/documents-new-new-document.png differ diff --git a/docs/user-guide/documents/documents-new-view-with-attachment.png b/docs/user-guide/documents/documents-new-view-with-attachment.png new file mode 100644 index 00000000000..ec7a7121184 Binary files /dev/null and b/docs/user-guide/documents/documents-new-view-with-attachment.png differ diff --git a/docs/user-guide/meetings/one-time-meetings/README.md b/docs/user-guide/meetings/one-time-meetings/README.md index 97cae65e234..d383206fc51 100644 --- a/docs/user-guide/meetings/one-time-meetings/README.md +++ b/docs/user-guide/meetings/one-time-meetings/README.md @@ -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. -![Menu showing options to edit agenda items in OpenProject meetings](openproject_userguide_meetings_agenda_item_more_menu.png) +![Edit, copy, move or delete an agenda item in OpenProject meetings module](meeting-more-menu-agenda-item.png) 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. diff --git a/docs/user-guide/meetings/one-time-meetings/meeting-more-menu-agenda-item.png b/docs/user-guide/meetings/one-time-meetings/meeting-more-menu-agenda-item.png new file mode 100644 index 00000000000..eefae55c0ef Binary files /dev/null and b/docs/user-guide/meetings/one-time-meetings/meeting-more-menu-agenda-item.png differ diff --git a/docs/user-guide/meetings/recurring-meetings/README.md b/docs/user-guide/meetings/recurring-meetings/README.md index 35b372915cb..2c078453565 100644 --- a/docs/user-guide/meetings/recurring-meetings/README.md +++ b/docs/user-guide/meetings/recurring-meetings/README.md @@ -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**. - -![Move an agenda item to next meeting in OpenProject recurring meetings](openproject_userguide_meetings_recurring_meeting_move_agenda_item_to_next_meeting.png) +![Move an agenda item to next meeting in OpenProject recurring meetings](meeting-more-menu-agenda-item-recurring.png) ## Meeting backlogs for recurring meetings diff --git a/docs/user-guide/meetings/recurring-meetings/meeting-more-menu-agenda-item-recurring.png b/docs/user-guide/meetings/recurring-meetings/meeting-more-menu-agenda-item-recurring.png new file mode 100644 index 00000000000..a97e9a492dc Binary files /dev/null and b/docs/user-guide/meetings/recurring-meetings/meeting-more-menu-agenda-item-recurring.png differ diff --git a/frontend/src/app/core/setup/globals/global-listeners.ts b/frontend/src/app/core/setup/globals/global-listeners.ts index 94d5f33ba7c..c2a8dca7979 100644 --- a/frontend/src/app/core/setup/globals/global-listeners.ts +++ b/frontend/src/app/core/setup/globals/global-listeners.ts @@ -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 /# - performAnchorHijacking(evt, linkElement); + if (performAnchorHijacking(evt, linkElement)) { + evt.preventDefault(); + } }); // Listen for 'zenModeToggled' event to toggle Zen Mode styling on the body. diff --git a/frontend/src/app/core/setup/globals/global-listeners/link-hijacking.ts b/frontend/src/app/core/setup/globals/global-listeners/link-hijacking.ts index 3b070a27600..7e4a98b510d 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/link-hijacking.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/link-hijacking.ts @@ -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; -} diff --git a/frontend/src/stimulus/controllers/external-links.controller.ts b/frontend/src/stimulus/controllers/external-links.controller.ts index fafcdfe22c4..f73d3428c43 100644 --- a/frontend/src/stimulus/controllers/external-links.controller.ts +++ b/frontend/src/stimulus/controllers/external-links.controller.ts @@ -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(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(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(' ')); +} diff --git a/spec/features/a11y/external_links_spec.rb b/spec/features/a11y/external_links_spec.rb index 363d427c925..2aeede766bc 100644 --- a/spec/features/a11y/external_links_spec.rb +++ b/spec/features/a11y/external_links_spec.rb @@ -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