From a9baeac4afb37af7a05dc92f840c2048d2fb68cd Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 21:54:29 +0000 Subject: [PATCH 01/17] [OP-20572] Refresh HAL resources doc Adds guidance for new code and fixes stale content. https://community.openproject.org/wp/OP-20572 --- .../concepts/hal-resources/README.md | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/development/concepts/hal-resources/README.md b/docs/development/concepts/hal-resources/README.md index 89d06118386b..197a5f96f90a 100644 --- a/docs/development/concepts/hal-resources/README.md +++ b/docs/development/concepts/hal-resources/README.md @@ -1,13 +1,13 @@ --- sidebar_navigation: title: HAL+JSON resources -description: Get an overview of how inline-editing of resources works +description: Get an overview of how the frontend loads and represents HAL+JSON API resources keywords: development concepts, HAL, JSON, hal resources, API requests --- # Development concept: HAL resources -HAL resources are the frontend counterpart to the `HAL+JSON` API of OpenProject. They are class instance of the JSON resources with action links being turned into callable functions to perform requests. +HAL resources are the frontend counterpart to the `HAL+JSON` API of OpenProject. They are class instances of the JSON resources with action links being turned into callable functions to perform requests. ## Key takeaways @@ -15,14 +15,25 @@ _HAL resources ..._ - are requested from the APIv3 endpoints and generated from their JSON response by the [`HALResourceService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/features/hal/services/hal-resource.service.ts). - contain `$links` and `$embedded` properties to map the original JSON object for linked resources, and the ones that were embedded to the response. -- Can have an arbitrary number of properties on the object that map to the JSON properties, or elements from the `_links` and `_embedded` JSON segments. -- They unfortunately are complex and mutable objects +- can have an arbitrary number of properties on the object that map to the JSON properties, or elements from the `_links` and `_embedded` JSON segments. +- are, unfortunately, complex and mutable objects. + +## Choosing an approach for new code + +`HalResource` is the oldest way the frontend works with API data, and much existing Angular code depends on it. For new code, prefer the newer options: + +- **Server-rendered UI with Hotwire and Primer.** For new screens and features, render HTML in Rails with Primer ViewComponents and add behaviour with Stimulus. The frontend then does not need to touch HAL at all. See [Using Hotwire with ViewComponents](../hotwire-view-components) and [Stimulus](../stimulus). +- **Modern Angular in existing Angular screens.** New Angular components are still fine where they integrate with existing Angular screens, such as the work package table or work package editing. Write them with `inject()`, signal inputs, and Signals wherever RxJS is not the right mental model. +- **Resource stores in `core/state`.** When Angular code needs API data, use a [`ResourceStoreService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/core/state/resource-store.service.ts) subclass such as `ProjectsResourceService`. These cache resources by ID and expose them as plain, typed interfaces (e.g., `IProject`) over the HAL JSON, without `HalResource` classes or in-place mutation. These are the current option for Angular code, not a long-term direction: they are built on [Akita](https://github.com/salesforce/akita), which is no longer maintained, and the frontend is moving towards Hotwire. +- **`HalResource`.** Use it when working on existing code built around it, or where no resource store exists for the type yet. + +The rest of this document explains `HalResource` in full. ## Prerequisites HAL resources on the frontend have no explicit prerequisite on our frontend. You will likely want to take a look at the [API documentation and the section on HAL+JSON](../../../api/introduction). -## Primer on HAL JSON +## HAL JSON in a nutshell The JSON response in HAL standard can contain these things: @@ -129,14 +140,14 @@ In this linked example, only the `status` and `project` links and embedded resou ## HalResourceService -On to loading the JSON resources from the API and turning them into usable class instances. This is the job of the the [`HALResourceService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/features/hal/services/hal-resource.service.ts). It has two responsibilities: +On to loading the JSON resources from the API and turning them into usable class instances. This is the job of the [`HALResourceService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/features/hal/services/hal-resource.service.ts). It has two responsibilities: -1. It uses the Angular `HTTPModule` for performing API requests to the APIv3 -2. It turns the responses of these requests (or HAL JSON generated in the frontend) into a HAL resource class +1. It uses Angular's `HttpClient` for performing API requests to the APIv3 +2. It turns the responses of these requests (or HAL JSON generated in the frontend) into a HAL resource class ### Performing requests against HAL API endpoints -The service has HTTP `get`, `post`, `put`, etc. methods as well as a generic `request` method that accept an URL and params/payload, and respond with an observable to the JSON transformed into a HAL resource. +The service has HTTP `get`, `post`, `put`, etc. methods as well as a generic `request` method that accept a URL and params/payload, and respond with an observable to the JSON transformed into a HAL resource. ### Error Handling @@ -144,7 +155,7 @@ For errors returned by the HAL API (specific error `_type` response in the JSON) ## Linked HAL resources -The `_links` entries of a HAL resource can have a `url`, `method`, and `title` property. They can also be `templated` if the link needs to be filled out by the frontend (e.g., to set a related ID to pass into it). +The `_links` entries of a HAL resource can have an `href`, `method`, and `title` property. They can also be `templated` if the link needs to be filled out by the frontend (e.g., to set a related ID to pass into it). In the process of building the HAL resource, action `_links` objects are being turned into resources themselves: @@ -174,7 +185,7 @@ console.log(wp.project.href); // /api/v3/projects/1 // The resource is not embedded, thus not loaded console.log(wp.project.$loaded); // false // The name property is available from the title attribute -console.log(project.name); // Demo Project +console.log(wp.project.name); // Demo Project // Explicitly load the HAL resource const project = await wp.project.$load(); @@ -187,7 +198,7 @@ On first glance, it might look nice to be able to `$load()` the embedded project Also, the `workPackage` state will be constantly mutated whenever these requests happen. You will always have to check whether the resource was loaded. -Instead of explicitly loading embedded resources, the frontend now usually uses a `CacheService` to load and cache a resource of a specific type by its href. For example, for the project, there is a `ProjectCacheService#require(href)` method that will ensure a project is loaded, or fetched from cache and returns a promise to use. This will no longer mutate the work package resource. +Instead of explicitly loading linked resources, prefer a resource store that loads and caches resources of a specific type. For example, `ProjectsResourceService#requireEntity(href)` returns an observable of the project, fetched from the API only if it is not already cached. This does not mutate the work package resource. See [Choosing an approach for new code](#choosing-an-approach-for-new-code). However, there are still use cases where `.$load()` is used and the resource is mutated. @@ -197,11 +208,11 @@ In order to turn the JSON properties from `_embedded` and `_links` into writable - Maintaining a `$source` property which is the pristine JSON response from the API. -- Mapping the properties under `_links` into `$links` property with `HalLinks` that can be called in the application. `e.g., workPackage.$links.update()` will call the API to the URL behind that link. +- Mapping the properties under `_links` into a `$links` property with `HalLink`s that can be called in the application. E.g., `workPackage.$links.update()` will call the API to the URL behind that link. - Mapping the properties under `_embedded` into `$embedded` and turning each of these into their own `HalResource` instance. -- It definers setters to all properties of the HAL resource to modify the `$source` object. For example, if you have a link `_links.project` in your JSON, you can override the project used for the resource with `resource.project = projectResource` or `resource.project = { href: '/api/v3/projects/1234' }`. This will modify the `$source` object. +- It defines setters for all properties of the HAL resource to modify the `$source` object. For example, if you have a link `_links.project` in your JSON, you can override the project used for the resource with `resource.project = projectResource` or `resource.project = { href: '/api/v3/projects/1234' }`. This will modify the `$source` object. The frontend doesn't really use this anymore due to it boiling down to a large mutable object. Instead, we use `ResourceChangesets` to modify resources and save them. [Click here to see the separate concept on them](../resource-changesets). @@ -211,8 +222,11 @@ In order to turn the JSON properties from `_embedded` and `_links` into writable - [`halResource.config.ts`](https://github.com/opf/openproject/tree/dev/frontend/src/app/features/hal/services/hal-resource.config.ts) for identifying what types in the JSON response and its members/links are being turned into which classes. - [`HalResource`](https://github.com/opf/openproject/tree/dev/frontend/src/app/features/hal/resources/hal-resource.ts) the base HAL resource class - [`HAL resource builder`](https://github.com/opf/openproject/tree/dev/frontend/src/app/features/hal/helpers/hal-resource-builder.ts) used for wiring up the links and embedded JSON properties into members of the HAL resource classes +- [`ResourceStoreService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/core/state/resource-store.service.ts) the base class for the typed, cached resource stores that are preferred for new code -## Discussions +## Known problems -- Due to the dynamic properties of the HAL resource, it traditionally has an index map to `any` which is the source of many typing issues and in turn, quite a number of bugs: [hal-resource.ts](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/hal/resources/hal-resource.ts#L63) -- The way HAL resources work by embedding and allowing to load +- **Dynamic properties are untyped.** Because properties are generated from whatever the JSON contains, `HalResource` has an index signature. It used to be `any`, which hid many typing errors and caused bugs. It is now `unknown` ([OP-20349](https://community.openproject.org/wp/OP-20349)), so reading an undeclared property requires narrowing it. Prefer declaring the property on the resource's class or interface (e.g., `WorkPackageResource`) over casting at the call site. +- **Resources are mutated in place.** `$load()` and the generated setters change the resource object, and every holder of that object sees the change. To edit a resource, use [resource changesets](../resource-changesets) instead of assigning to its properties. +- **`$load()` is not cached.** Calling `$load()` on the same linked resource from several places sends a request each time. Use a resource store instead. +- **Linked and embedded resources look alike.** Whether a property is fully loaded depends on what the backend chose to embed for that endpoint. Check `$loaded` before relying on anything beyond `href` and the link `title`. From 75a366bff10eebfc3f744875b6beb2f8abfcf4aa Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:09:03 +0000 Subject: [PATCH 02/17] [OP-20572] Refresh state management doc Adds guidance for new code and fixes the samples. https://community.openproject.org/wp/OP-20572 --- .../concepts/state-management/README.md | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/development/concepts/state-management/README.md b/docs/development/concepts/state-management/README.md index 740ccc40cb94..6e5fba6f8142 100644 --- a/docs/development/concepts/state-management/README.md +++ b/docs/development/concepts/state-management/README.md @@ -7,16 +7,24 @@ keywords: state management, stores, input states # Development concept: State management -State management in complex frontend applications is a topic that has been heavily evolving over the past years. Redux and stores, one-way data flow are all the rage nowadays. OpenProject is an old application, so its frontend exists way before these concepts were introduced and became popular. +State management in complex frontend applications is a topic that has been heavily evolving over the past years. OpenProject is an old application, so its frontend existed long before stores and one-way data flow became popular, and several generations of state management now live side by side. ## Key takeaways _State management in OpenProject frontend..._ -- is mainly controlled by `RxJs` and the [reactivestates](https://github.com/opf/reactivestates) library +- is historically controlled by `RxJS` and the [`@openproject/reactivestates`](https://github.com/opf/reactivestates) library, which this document describes in detail +- also uses [Akita](https://github.com/salesforce/akita) entity stores in `core/state` for API resources, and Angular Signals in newer components - `State` and `InputState` are mostly syntactic sugar over RxJS `Subject` and `BehaviorSubject` - States are used to hold and cache values with their values and non-values being observable +## Choosing an approach for new code + +- **Server-rendered UI with Hotwire and Primer.** For new screens and features, render HTML in Rails with Primer ViewComponents and add behaviour with Stimulus. State then lives on the server and in the DOM, not in a frontend store. See [Using Hotwire with ViewComponents](../hotwire-view-components) and [Stimulus](../stimulus). +- **Signals in existing Angular screens.** New Angular components are still fine where they integrate with existing Angular screens, such as the work package table or work package editing. Hold component and local state in Signals (`signal`, `computed`, signal inputs) wherever RxJS is not the right mental model, and keep RxJS for streams of events and requests. +- **Akita entity stores in `core/state`.** For API resources shared across Angular components, use a [`ResourceStoreService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/core/state/resource-store.service.ts) subclass such as `ProjectsResourceService`, following the entity store rules in the [frontend style guide](../../style-guide/frontend/). These are the current option for Angular code, not a long-term direction: Akita is no longer maintained, and the frontend is moving towards Hotwire. +- **`reactivestates`.** Use it only when working on existing code built around it, such as the `States` cache or the work package query space. + ## InputState An `InputState` object is a wrapper around RxJS [`BehaviorSubject`](https://rxjs.dev/api/index/class/BehaviorSubject). It provides some syntactic sugar over it to inspect values and provide helpers to observe streams and fill in the underlying `Subject`. @@ -25,7 +33,7 @@ To create an `InputState`, call `new InputState(initialValue:Type|undefine ```typescript // An initially empty state -const state = new InputState(); +const state = input(); console.log(state.isPristine()); // true console.log(state.hasValue()); // false @@ -75,7 +83,7 @@ You can also observe the `changes` which includes undefined values ```typescript state .changes$() - .subscribe(val => console.log("Observed " + (val ? "String value" : "Undefined")); + .subscribe(val => console.log("Observed " + (val ? "String value" : "Undefined"))); ``` ## MultiInputState @@ -91,7 +99,7 @@ const state = multi.get('my identifier'); state.putValue({ id: 1234 }); // Later on -multi.get('my identifier').value // { id: 1324} +multi.get('my identifier').value // { id: 1234 } ``` The MultiInputState can be observed as a whole: @@ -100,17 +108,17 @@ The MultiInputState can be observed as a whole: multi .observeChange() .subscribe(([changedId, foo]) => { - console.log(`CHANGE for ${changedId}: ${foo?.id || 'cleared'}); -} + console.log(`CHANGE for ${changedId}: ${foo?.id ?? 'cleared'}`); + }); -multi.clear('my identifier'); +multi.get('my identifier').clear(); // CHANGE for my identifier: cleared multi .get('my identifier') - .putFromPromiseIfPristine(() => Promise.resolve({ id: 'new' })); + .putFromPromiseIfPristine(() => Promise.resolve({ id: 5678 })); -// CHANGE for my identifier: new +// CHANGE for my identifier: 5678 ``` ## StatesGroup @@ -118,7 +126,9 @@ multi The `StatesGroup` aggregates multiple States or MultiInputStates into one class. The only benefit to this is debugging capabilities of the reactivestates library. You can call the following method in development mode to see all changes to states in a StateGroup logged to console: ```typescript -window.enableReactiveStatesLogging(); +import { enableReactiveStatesLogging } from '@openproject/reactivestates'; + +enableReactiveStatesLogging(); ``` This might then look like the following, with green color for added objects, and red color for removed values: @@ -129,11 +139,12 @@ This might then look like the following, with green color for added objects, and ## 🔗 Code references -- [`StatesService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/core/states/states.service.ts) Global `States` cache of MultiInputStates +- [`States`](https://github.com/opf/openproject/blob/dev/frontend/src/app/core/states/states.service.ts) Global `StatesGroup` cache of MultiInputStates - [`IsolatedQuerySpace`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/directives/query-space/isolated-query-space.ts) Query space `StatesGroup`. Is instantiated multiple times whenever a work package query is loaded. See [the separate concept guide](../queries) for more information. -- [ReactiveStates](https://github.com/ReactiveStates/reactivestates) library we use for the StatesGroup. This was developed by Roman primarily for us during AngularJS times. +- [`@openproject/reactivestates`](https://github.com/opf/reactivestates) library we use for the StatesGroup. This was developed by Roman primarily for us during AngularJS times and is now maintained by OpenProject. +- [`ResourceStoreService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/core/state/resource-store.service.ts) the base class for the Akita entity stores in `core/state` ## Discussions - In contrast to a `Store` concept of redux, the States and state groups do not have any concept of data immutability. As a caller you will need to ensure that. In OpenProject, many of the states are in fact mutable due to historic reasons and the fact that complex class instances are passed around that cannot be easily shallow copied. This will need to be refactored in the future. -- As the `reactivestates` library was primarily developed for us, we may need to take over its code or move to a different state management concept altogether. The recent developments in `ngxs` look very promising. +- As the `reactivestates` library was primarily developed for us, OpenProject has taken over its code and publishes it as `@openproject/reactivestates`. Akita was adopted alongside it for entity stores, but is itself no longer maintained. See [Choosing an approach for new code](#choosing-an-approach-for-new-code) for where new state should live. From ef06708322a56b56e0720fa262e99c2bd3b47d0d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:10:57 +0000 Subject: [PATCH 03/17] [OP-20572] Refresh inline editing doc Modernises the Angular samples and fixes stale names. https://community.openproject.org/wp/OP-20572 --- .../concepts/inline-editing/README.md | 165 ++++++++++-------- 1 file changed, 91 insertions(+), 74 deletions(-) diff --git a/docs/development/concepts/inline-editing/README.md b/docs/development/concepts/inline-editing/README.md index 2c02492de165..83633bbbb969 100644 --- a/docs/development/concepts/inline-editing/README.md +++ b/docs/development/concepts/inline-editing/README.md @@ -31,9 +31,16 @@ In order to understand Inline Editing, you will need the following concepts: - [Changesets](../resource-changesets) +## Choosing an approach for new code + +Inline editing as described here is an Angular concept used by work packages and other HAL resources with a schema. + +- **New screens and features:** build forms server-side with Primer forms and ViewComponents, and use Hotwire for partial updates. See [Using Hotwire with ViewComponents](../hotwire-view-components). +- **Existing Angular screens:** new Angular components that integrate with inline editing (for example in the work package table or work package editing) are still fine. Write them with `inject()`, signal inputs, and Signals wherever RxJS is not the right mental model, as in the examples below. + ## Components overview -In order to understand the different modes of the inline edition functionality, we will first look at the components and code that handle displaying of resource attributes, the display fields. +In order to understand the different modes of the inline editing functionality, we will first look at the components and code that handle displaying of resource attributes, the display fields. ### Display fields @@ -65,9 +72,9 @@ Since OpenProject can also have dynamic custom fields with varying formats, the } ``` -The `type` property will decide that for name, we're looking for a display field that can handle `String` type, while for the project `status` attribute, we're looking for a specific display type called `ProjectStatus`. +The `type` property decides that for `name`, we're looking for a display field that can handle the `String` type. -In the frontend, multiple display field classes exist and the [`DisplayFieldService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/display/display-field.service.ts) resolves what display field class is going to be used for which attribute. [There is a mapping file](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/display/display-field.initializer.ts) between the types returned by the API and the used display field class. In some cases such as for `id`, a specific field is set based on the attribute name, not its type. +In the frontend, multiple display field classes exist and the [`DisplayFieldService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/display/display-field.service.ts) resolves what display field class is going to be used for which attribute. [There is a mapping file](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/display/display-field.initializer.ts) between the types returned by the API and the used display field class. In some cases a specific field is set based on the attribute name, not its type. For example, `id` always uses its own field, and the project `status` attribute is resolved by name to the `ProjectStatusDisplayField`. With a resource and its schema present, there are multiple ways to render a display field for a given attribute. @@ -75,47 +82,51 @@ With a resource and its schema present, there are multiple ways to render a disp Since parts of the application are rendered in plain JavaScript (such as the work package table), most display fields are actually rendered explicitly to a DOM element through the [`DisplayFieldRenderer#render`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/display/display-field-renderer.ts) method. You will only need the resource with its schema loaded and the attribute name. -The `DisplayFieldRenderer` requires the Angular injector for injecting services such as the `DisplayFieldService`. It's instance will cache field types instantiated for performance reasons in large rendering contexts, such as the work package table. +The `DisplayFieldRenderer` requires the Angular injector for injecting services such as the `DisplayFieldService`. Its instance will cache field types instantiated for performance reasons in large rendering contexts, such as the work package table. Minimal example, rendering the status attribute of a work package to the element of the exemplary component: ```typescript -@Component({ selector: 'minimal-example', template: '' }) +@Component({ selector: 'op-minimal-example', template: '' }) export class ExampleComponent implements OnInit { - // The work package to render - @Input() workPackageId:string; - - constructor (private elementRef:ElementRef, - private injector:Injector, - private apiV3Service:ApiV3Service) {} - - ngOnInit() { - this - .apiV3Service - .work_packages - .id(this.workPackageId) - .get() - .subscribe(workPackage => { - const fieldRenderer = new DisplayFieldRenderer(injector, 'table'); - const displayElement = fieldRenderer.render(workPackage, 'status', null); - this.elementRef.nativeElement.appendChild(displayElement); - }); - } + readonly workPackageId = input.required(); + + private readonly elementRef = inject>(ElementRef); + private readonly injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); + private readonly apiV3Service = inject(ApiV3Service); + + ngOnInit() { + this.apiV3Service + .work_packages + .id(this.workPackageId()) + .get() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((workPackage) => { + const fieldRenderer = new DisplayFieldRenderer(this.injector, 'table'); + const displayElement = fieldRenderer.render(workPackage, 'status', null); + this.elementRef.nativeElement.appendChild(displayElement); + }); + } } ``` -The third parameter of the `render` method is to provide a changeset. This allows to render the value not from the pristine work package, but from a temporary changeset of the work package: +The third parameter of the `render` method is to provide a changeset. This allows rendering the value not from the pristine work package, but from a temporary changeset of the work package. Changesets are obtained from the `HalResourceEditingService`: ```typescript -// Assuming we changed the subject property -const changeset = new WorkPackageChangeset(workPackage); +const halEditing = inject(HalResourceEditingService); + +const changeset = halEditing.changeFor(workPackage); +changeset.setValue('subject', 'A changed subject'); + +const displayElement = fieldRenderer.render(workPackage, 'subject', changeset); ``` -#### Rendering as angular component: `DisplayFieldComponent` +#### Rendering as Angular component: `DisplayFieldComponent` -To render an attribute of a HAL resource explicitly, a display field can be used through the [`DisplayFieldComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/display/display-field-renderer.ts). In most cases, you will not render the display field alone as it would expect to inline editable. This is why when it's used, it often explicitly disables the writable CSS styles with `writable: false` set. +To render an attribute of a HAL resource explicitly, a display field can be used through the [`DisplayFieldComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/display/display-field.component.ts). In most cases, you will not render the display field alone, since it is styled as if it were inline editable. This is why, when it's used, it often explicitly disables the writable CSS styles with `writable: false` set. -Minimal example, rendering the status attribute of a work package +Minimal example, rendering the status attribute of a work package: ```html ` tag encapsulates a set of inputs. It is tied to a (HAL) `resource` input. - It has multiple responsibilities: +It has multiple responsibilities: - receives registration of fields within the form - knows which fields are currently actively editing - handles submission of changes to the resource -- activates erroneous fields after unsuccessfully trying to saving. +- activates erroneous fields after an unsuccessful save. -#### EditableAttributeField +#### EditableAttributeFieldComponent -The `EditableAttributeField` contains the logic to toggle between the _display_ and _edit_ states of a single attribute for the resource. The field will try to register to a parent form by injecting it through its constructor. Only fields within an `EditForm` parent are editable. +The `EditableAttributeFieldComponent` contains the logic to toggle between the _display_ and _edit_ states of a single attribute for the resource. The field will try to register to a parent form by injecting it with `inject(EditFormComponent, { optional: true })`. Only fields within an `EditForm` parent are editable. -The EditableAttributeField basically contains only two HTML elements that it wraps. These are used for the `display` and `edit` modes. Each is handled by their own `DisplayField` and `EditField` classes and components, as we will detail in the following. +The `EditableAttributeFieldComponent` basically contains only two HTML elements that it wraps. These are used for the `display` and `edit` modes. Each is handled by their own `DisplayField` and `EditField` classes and components, as we will detail in the following. #### Editing portals OpenProject often renders Angular components in manually rendered DOM, prominently so in the work package table for improved rendering time. This is from the time the project was still using AngularJS and large scale rendering components was quite slow. -To easily mount an edit field over a manually rendered `DisplayField` (such as from the `DisplayFieldRenderer` above), OpenProject uses the `EditingPortalService` to create an [Angular CDK portal](https://material.angular.io/cdk/portal/overview). +To easily mount an edit field over a manually rendered `DisplayField` (such as from the `DisplayFieldRenderer` above), OpenProject uses the `EditingPortalService` to create an [Angular CDK portal](https://material.angular.dev/cdk/portal/overview). -The `EditingPortalService` will render a `EditFormPortalComponent` with some HTML form wrapping for correct handling of submit events and labels. This portal will in turn render the actual `EditFieldComponent`. The service will wire up these components automatically. +The `EditingPortalService` will render an `EditFormPortalComponent` with some HTML form wrapping for correct handling of submit events and labels. This portal will in turn render the actual `EditFieldComponent`. The service will wire up these components automatically. If you were to explicitly render an edit field, this would look as follows. Note that again, this is handled by the `EditForm` automatically whenever the user activates an `EditableAttributeFieldComponent` field. ```typescript -@Component({ selector: 'minimal-example', template: '' }) +@Component({ selector: 'op-minimal-example', template: '' }) export class ExampleComponent implements OnInit { - // The work package to render - @Input() workPackageId:string; - - constructor (private elementRef:ElementRef, - private injector:Injector, - // Parent EditForm required - private editForm:EditFormComponent, - private apiV3Service:ApiV3Service) {} - - ngOnInit() { - this - .apiV3Service - .work_packages - .id(this.workPackageId) - .get() - .subscribe(workPackage => { - - - return this.editingPortalService.create( - this.elementRef.nativeElement, - this.injector, - this.editForm, - workPackage.schema, // Schema of the work package - 'subject', // Attribute field name - [] // Array of saving errors for error-style rendering - ); - }); - } + readonly workPackageId = input.required(); + + private readonly elementRef = inject>(ElementRef); + private readonly injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); + // Parent EditForm required + private readonly editForm = inject(EditFormComponent); + private readonly halEditing = inject(HalResourceEditingService); + private readonly editingPortalService = inject(EditingPortalService); + private readonly apiV3Service = inject(ApiV3Service); + + ngOnInit() { + this.apiV3Service + .work_packages + .id(this.workPackageId()) + .get() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((workPackage) => { + const schema = this.halEditing.changeFor(workPackage).schema.ofProperty('subject'); + if (!schema) return; + + void this.editingPortalService.create( + this.elementRef.nativeElement, + this.injector, + this.editForm, + schema, // Schema of the attribute + 'subject', // Attribute field name + [], // Array of saving errors for error-style rendering + this.destroyRef, // Cancels activation if the component is destroyed first + ); + }); + } } ``` +`create` resolves to the `EditFieldHandler` of the rendered field, described next. + #### Edit field handler There is one more class involved in this stack, the `EditFieldHandler`. It implements an adapter pattern to break the connection between the input-only characteristics of the `EditFieldComponent` and the handling of events towards an outer wrapper such as the `EditForm`. They are regular classes that handle events from the `EditFieldComponent` to make them reusable in cases where, for example, an `EditForm` does not exist. -Any user event that should trigger saving or resetting of the field is being handled by the `EditFieldHandler`, hence its name. For example, pressing \ESC\<\/kbd\> on a `TextEditFieldComponent` will trigger the `EditFieldHandler#handleUserCancel` method. The same is true for submit events on the field or form (e.g., pressing \ENTER\<\/kbd\> on the field), which trigger the `EditFieldHandler#handleUserSubmit` method. +Any user event that should trigger saving or resetting of the field is being handled by the `EditFieldHandler`, hence its name. For example, pressing Esc on a `TextEditFieldComponent` will trigger the `EditFieldHandler#handleUserCancel` method. The same is true for submit events on the field or form (e.g., pressing Enter on the field), which trigger the `EditFieldHandler#handleUserSubmit` method. -An example where this comes into play is the [`CustomText`](https://github.com/opf/openproject/tree/dev/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts) widget of the dashboards and My page, which use the `` manually and pass in a handler that handles saving of these widgets without access to an edit form. +An example where this comes into play is the [`WidgetCustomTextComponent`](https://github.com/opf/openproject/tree/dev/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts) widget of the dashboards and My page, which uses the `` manually and passes in a handler (the `CustomTextEditFieldService`) that saves these widgets without access to an edit form. ### 🔗 Code references - [`EditForm`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.ts) base class -- [`EditFormComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts#L28-L27) Angular `` component +- [`EditFormComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.component.ts) Angular `` component - [`EditableAttributeFieldComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/edit/field/editable-attribute-field.component.ts) Angular `` component for attributes within the edit form - [`DisplayField`](https://github.com/opf/openproject/tree/dev/frontend/src/app/shared/components/fields/display) definitions containing all display fields and the service to instantiate them. - [`DisplayFieldRenderer`](https://github.com/opf/openproject/tree/dev/frontend/src/app/shared/components/fields/display/display-field-renderer.ts) to manually render display fields from JavaScript - [`DisplayFieldComponent`](https://github.com/opf/openproject/tree/dev/frontend/src/app/shared/components/fields/display/display-field.component.ts) an Angular component to render display fields -- [`EditFieldComponent`](https://github.com/opf/openproject/tree/dev/frontend/src/app/shared/components/fields/edit/field-types) definitions containing all display fields and the service to instantiate them +- [`EditFieldComponent`](https://github.com/opf/openproject/tree/dev/frontend/src/app/shared/components/fields/edit/field-types) definitions containing all edit fields - [`EditingPortalService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/shared/components/fields/edit/editing-portal/editing-portal-service.ts) service to create an edit field with event handling in code -- [`WorkPackageFullViewComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html) Work package full view template that uses the `edit-form` attribute to create a form for the work package full view (as seen in the Gif above) +- [`WorkPackagesFullViewComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html) Work package full view template that uses the `edit-form` attribute to create a form for the work package full view (as seen in the Gif above) ## Minimal example @@ -267,9 +284,9 @@ The [`WorkPackageSingleViewComponent`](https://github.com/opf/openproject/blob/d ![Work package single view](single-view.png) -In the end, each of the attributes in the two groups above are a `EditableAttributeField`, and the entire page wraps these attributes in an `EditFormComponent`. +In the end, each of the attributes in the two groups above is an `EditableAttributeFieldComponent`, and the entire page wraps these attributes in an `EditFormComponent`. ## Discussions -- The `EditForm` has a similar responsibility to what Angular offers with the `FormGroup/FormControl` logic of the reactive forms module. It would be useful to evaluate the possibility and effort to refactor the edit form into such a behavior. Currently, as the table is rendered in plain JavaScript and relies on the edit form, this may not be possible. -- The `EditFieldHandler` class can likely be reduced into an interface that is implemented by both the `EditFieldComponent` and other classes using it such as `CustomTextComponent` and `WorkPackageCommentComponent` +- The `EditForm` has a similar responsibility to what Angular offers with the `FormGroup/FormControl` logic of the reactive forms module. Since the work package table is rendered in plain JavaScript and relies on the edit form, such a refactoring is unlikely. New forms should instead be built server-side with Primer forms (see [Choosing an approach for new code](#choosing-an-approach-for-new-code)). +- The `EditFieldHandler` class can likely be reduced into an interface that is implemented by both the `EditFieldComponent` and other classes using it, such as the `CustomTextEditFieldService`. From d1fd3c599079ca483213778f4604aef4b05b2c50 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:11:27 +0000 Subject: [PATCH 04/17] [OP-20572] Refresh resource changesets doc Fixes stale method and class names. https://community.openproject.org/wp/OP-20572 --- .../concepts/resource-changesets/README.md | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/development/concepts/resource-changesets/README.md b/docs/development/concepts/resource-changesets/README.md index 960f6773c034..2d02ab42ae2a 100644 --- a/docs/development/concepts/resource-changesets/README.md +++ b/docs/development/concepts/resource-changesets/README.md @@ -7,7 +7,7 @@ keywords: changesets, editing, HAL resources # Development concept: Resource changesets -Resource changesets allow to edit resources in a transparent way, tracking its changes and saving the change to the API. +Resource changesets allow editing resources in a transparent way, tracking their changes and saving them to the API. ## Key takeaways @@ -15,12 +15,12 @@ _Changesets ..._ - are created temporarily when editing HAL resources such as work packages - contain an isolated object of changes to apply when the resource is saved -- provide a `Proxy` javascript object `projectedResource` to the resource with the changes applied -- encapsulates a temporary schema and form resource for accessing newly added fields and errors +- provide a `Proxy` JavaScript object `projectedResource` to the resource with the changes applied +- encapsulate a temporary schema and form resource for accessing newly added fields and errors ## Prerequisites -In order to understand Inline Editing, you will need the following concepts: +In order to understand changesets, you will need the following concepts: - [HAL resources](../hal-resources) @@ -32,7 +32,7 @@ Let's go over the classes and services used to provide access to changesets. ### ResourceChangeset -The `ResourceChangeset` class is a temporarily created class whose lifetime is determined by the editing duration of a user to a resource such as work package. It gets created automatically when rendering inline-editable fields and is reset/destroyed/reinstantiated when a change has been submitted to the backend, or when the changes are cancelled. +The `ResourceChangeset` class is a temporarily created class whose lifetime is determined by the editing duration of a user on a resource such as a work package. It gets created automatically when rendering inline-editable fields and is reset/destroyed/reinstantiated when a change has been submitted to the backend, or when the changes are cancelled. The changeset maintains references to @@ -42,18 +42,18 @@ The changeset maintains references to The changeset provides a high level access to getting and writing changes as well as caching resources around editing to avoid reloading them too often. It also provides access to a `projectedResource`, which is a proxy class on the HAL resource with all overridden values returned from the changeset. It behaves as if the modified resource was saved and returned. -The ResourceChangeset is possibly subclassed for specific HAL resource types. For example, by the `WorkPackageChangeset`, a resource changeset specifically for work packages to correctly handle cases like switching types or projects - which will require in a reloading of the `FormResource` and its embedded schema for identifying and showing new attributes. +The ResourceChangeset is possibly subclassed for specific HAL resource types. For example, by the `WorkPackageChangeset`, a resource changeset specifically for work packages to correctly handle cases like switching types or projects - which require reloading the `FormResource` and its embedded schema for identifying and showing new attributes. ### HalResourceEditingService -In order to _create_, _remove_ or _saving_ changesets for a specific resource, the `HalResourceEditingService` is provided at various hierarchies of the application. The service maintains a store of available resource changesets, whose states and changes can be observed with an RXJS observable. +In order to create, save, reset or stop editing changesets for a specific resource, the `HalResourceEditingService` is provided at various hierarchies of the application. Its main methods are `edit`, `changeFor`, `save`, `reset`, `stopEditing`, and `typedState`. The service maintains a store of available resource changesets, whose states and changes can be observed with an RxJS observable. The main entry point for editing changesets is `HalResourceEditingService#changeFor`, which will either continue editing an existing changeset, or create a new one for the given class. #### Minimal example on using the changeset ```typescript -const halResourceEditingService = injector.get(HalResourceEditingService); +const halResourceEditingService = inject(HalResourceEditingService); const workPackage = /** provided by e.g., an input */ // Open a new changeset @@ -71,13 +71,12 @@ console.log(changeset.changes); // { subject: 'My new subject' } // Access current and previous values console.log(changeset.changeMap); // { subject: { from: 'Old subject', to: 'My new subject' } } -// Apply the changeset for the work package -const payload = changeset.buildPayloadFromChanges() // { lockVersion: 1, subject: 'My new subject'}; +// Build the request payload for saving the work package +const payload = await changeset.buildRequestPayload(); // { lockVersion: 1, subject: 'My new subject' } // Access the same changeset const changeset2 = halResourceEditingService.changeFor(workPackage); -console.log(changeset2 == changeset) // true - +console.log(changeset2 === changeset); // true // Reset the change changeset.clear(); @@ -93,14 +92,14 @@ You will often see other components pushing into the changeset, such as the `Edi #### Temporary editing resources -Some components such as the `WorkPackageSingleView` needs to update the view upon changes to a resource under edit. A prominent, but complex example is the work package gaining or losing attribute fields due to the `Type` or `Project` being changed. This is due to custom fields being configurable on a per-project level. +Some components such as the `WorkPackageSingleViewComponent` need to update the view upon changes to a resource under edit. A prominent, but complex example is the work package gaining or losing attribute fields due to the `Type` or `Project` being changed. This is due to custom fields being configurable on a per-project level. Assume you start creating a new work package of type `Task`, and then switch to a `Feature` which has an additional custom field. Even before saving the work package, you would expect this custom field to become available. The following GIF shows this behavior in both creating and editing a work package: ![Type switching resulting in new attributes](type-switching.gif) -For this to work, the `WorkPackageSingleView` needs to have access to the work package _as if it was saved_ after the type was changed. But the work package is a new resource and not yet actually saved. That's why `HalResourceEditingService#temporaryEditingResource` returns an observable state that returns either the pristine work package, or the proxied work package by the changeset to transparently access the modified-yet-unsaved work package resource. +For this to work, the `WorkPackageSingleViewComponent` needs to have access to the work package _as if it was saved_ after the type was changed. But the work package is a new resource and not yet actually saved. That's why `HalResourceEditingService#temporaryEditResource` returns an observable state that returns either the pristine work package, or the proxied work package by the changeset to transparently access the modified-yet-unsaved work package resource. ## Additional resources -Changesets are used in several components throughout the application. Notable examples are the [inline editing `EditForm`](../inline-editing). +Changesets are used in several components throughout the application. The most notable example is the [inline editing `EditForm`](../inline-editing). From 8d41299517173fadd46e124ce5910e472dcd37b0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:12:20 +0000 Subject: [PATCH 05/17] [OP-20572] Refresh resource schemas doc Fixes the dead API link and stale names. https://community.openproject.org/wp/OP-20572 --- .../concepts/resource-schemas/README.md | 64 ++++++++----------- 1 file changed, 26 insertions(+), 38 deletions(-) diff --git a/docs/development/concepts/resource-schemas/README.md b/docs/development/concepts/resource-schemas/README.md index a10da3b1cb27..817b62f89458 100644 --- a/docs/development/concepts/resource-schemas/README.md +++ b/docs/development/concepts/resource-schemas/README.md @@ -5,11 +5,9 @@ description: An introduction to resource schemas and how they are tied to editab keywords: concept, schemas, resource schemas --- +# Development concept: Resource schemas - -# Resource schemas - -In OpenProject, editable resources such as work packages or projects can be highly customized by the user. A resource can have an arbitrary number of additional custom fields. In the frontend, the associated schema to a resource needs to be loaded in many cases when rendering attributes of that resource, such as in an [inline-editable field](../inline-editing). +In OpenProject, editable resources such as work packages or projects can be highly customized by the user. A resource can have an arbitrary number of additional custom fields. In the frontend, the associated schema to a resource needs to be loaded in many cases when rendering attributes of that resource, such as in an [inline-editable field](../inline-editing). ## Key takeaways @@ -20,7 +18,7 @@ _Schemas contain:_ - a (possibly) localized name - The value type of the defined attributes - Constraints for the authenticated user, i.e., whether the attribute is currently writable -- (optional) additional option definitions for the attribute. +- (optional) additional option definitions for the attribute ## Prerequisites @@ -32,9 +30,9 @@ The following guides are related: ## API Backend -Schemas in the backend are regular Grape endpoints. For example, the schema of all projects is rendered through the [`::API::V3::Projects::Schemas::ProjectsSchemaAPI`](https://github.com/opf/openproject/blob/dev/lib/api/v3/projects/schemas/project_schema_api.rb). This in turn renders the associated [`::API::V3::Projects::Schemas::ProjectsSchemaRepresenter`](https://github.com/opf/openproject/blob/dev/lib/api/v3/projects/schemas/project_schema_representer.rb), which contains the set of schema properties to be rendered. +Schemas in the backend are regular Grape endpoints. For example, the schema of all projects is rendered through the [`::API::V3::Workspaces::Schemas::WorkspaceSchemaAPI`](https://github.com/opf/openproject/blob/dev/lib/api/v3/workspaces/schemas/workspace_schema_api.rb), which is mounted under both `/api/v3/projects` and `/api/v3/workspaces`. This in turn renders the associated [`::API::V3::Projects::Schemas::ProjectSchemaRepresenter`](https://github.com/opf/openproject/blob/dev/lib/api/v3/projects/schemas/project_schema_representer.rb), which contains the set of schema properties to be rendered. -The work packages' schemas are significantly more complex. Each work package type will define its own schema due to the dynamics of the [form configuration](../../../system-admin-guide/manage-work-packages/work-package-types/form-configuration). With it, the order and grouping of work package attributes can be defined per type, resulting in different attributes to be displayed. In addition, custom fields can be individually enable per project for even more flexibility. +The work packages' schemas are significantly more complex. Each work package type will define its own schema due to the dynamics of the [form configuration](../../../system-admin-guide/manage-work-packages/work-package-types/form-configuration). With it, the order and grouping of work package attributes can be defined per type, resulting in different attributes to be displayed. In addition, custom fields can be individually enabled per project for even more flexibility. This results in not a single schema for all work packages, but one schema for each project - type combination. @@ -62,7 +60,7 @@ This section describes some of the existing schemas. **Projects** -For projects, there is a single APIv3 endpoint for their schemas: `/api/v3/projects/schema`. This schema is identical for all projects. You can simply request the OpenProject Community schema for projects [here](https://community.openproject.org/api/v3/projects/schema). It contains a set of static properties (name, identifier, status, etc.), as well as all project-level custom fields. +For projects, there is a single APIv3 endpoint for their schemas: `/api/v3/projects/schema`, also available as `/api/v3/workspaces/schema`. This schema is identical for all projects. You can request [the OpenProject Community schema for projects](https://community.openproject.org/api/v3/projects/schema). It contains a set of static properties (name, identifier, status, etc.), as well as all project-level custom fields. **Work packages** @@ -79,47 +77,37 @@ Work package and project schemas contain references to their respective attribut ## Frontend usage -The OpenProject frontend usually ensure that whenever you get access to a HAL resource, its associated schema (if there is any) is also loaded. This is done through the [`SchemaCacheService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/core/schemas/schema-cache.service.ts). It will request the associated schema unless it has already been cached in the global states object to avoid loading a schema multiple times. +The OpenProject frontend usually ensures that whenever you get access to a HAL resource, its associated schema (if there is any) is also loaded. This is done through the [`SchemaCacheService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/core/schemas/schema-cache.service.ts). It will request the associated schema unless it has already been cached in the global states object to avoid loading a schema multiple times. -In some cases, such as the work package `/api/v3/work_packages` or `/api/v3/queries` endpoints, the needed schemas to represent the work packages contained in the collection are embedded automatically in the `_embedded.schemas` endpoint. Services handling these loaded requests such as the [`WorkPackagesStatesInitializationService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/components/wp-list/wp-states-initialization.service.ts) will automatically update the schema states. +In some cases, such as the work package `/api/v3/work_packages` or `/api/v3/queries` endpoints, the needed schemas to represent the work packages contained in the collection are embedded automatically in the `_embedded.schemas` endpoint. Services handling these loaded requests such as the [`WorkPackageStatesInitializationService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/components/wp-list/wp-states-initialization.service.ts) will automatically update the schema states. If you look at the HAL+JSON response of a work package API request, you will see it has a `_links.schema.href` property which identifies the schema resource that the loaded work package is associated with. ([Exemplary request](https://community.openproject.org/api/v3/work_packages/34250)) -If you have work package resource, you can get hold of its associated schema as follows: +If you have a work package resource, you can get hold of its associated schema as follows: ```typescript -const schemaCache = injector.get(SchemaCacheService); +const schemaCache = inject(SchemaCacheService); const workPackage = /** Work package from input or something */ -schemaCacheService - .ensureLoaded(workPackage) - .then((schema:SchemaResource) => { - // Output the localized name of the "subject" property. - console.log(schema.subject.name); - }); -``` -The schema resource is made out of properties that the frontend identifies as [`IFIeldSchema`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/field.base.ts) interface: +await schemaCache.ensureLoaded(workPackage); -```typescript -export interface IFieldSchema { - // Type of the schema property, such as "String", "Integer", etc. - type:string; - // Whether the property is writable - writable:boolean; - // A set or link of allowed values e.g., for list-types - allowedValues?:any; - // Whether this property requires a value to be saved - // (translates to input[required] property) - required?:boolean; - // Whether this property has a default value when saving - hasDefault:boolean; - // The localized name of this property - name:string; - // A set of options transmitted by the backend, mostly empty - options?:any; -} +// Output the localized name of the "subject" property. +console.log(schemaCache.of(workPackage).ofProperty('subject')?.name); ``` +`SchemaResource` inherits the untyped `unknown` index signature of `HalResource`, so read properties through the schema proxy's `ofProperty` as above rather than as `schema.subject`. + +Each property of the schema resource is described by the [`IOPFieldSchema`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/hal/interfaces.ts) interface. The fields layer narrows it to [`IFieldSchema`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/fields/field.base.ts), which requires a `name` and adds a `placeholder`. The most important properties are: + +- `type`: the type of the schema property, such as `String`, `Integer`, etc. +- `writable`: whether the property is writable +- `allowedValues`: a set or link of allowed values, e.g., for list types +- `required`: whether this property requires a value to be saved (translates to the `input[required]` property) +- `hasDefault`: whether this property has a default value when saving +- `name`: the localized name of this property +- `options`: a set of options transmitted by the backend, mostly empty +- `minLength`, `maxLength`, `minimum`, `maximum`: value constraints, where applicable + ### Form schemas When you try to update a resource such as a work package, you will commonly request a `Form` resource for this work package, which is a temporary resource that will have your changes applied to them, including error handling. In these forms, an embedded schema is output that represents the schema with permissions applied for the current user. From 26be2e2532d99602b2e19e142848711cac69f3a5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:13:19 +0000 Subject: [PATCH 06/17] [OP-20572] Refresh queries doc Fixes stale paths and class names. https://community.openproject.org/wp/OP-20572 --- docs/development/concepts/queries/README.md | 47 ++++++++++----------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/docs/development/concepts/queries/README.md b/docs/development/concepts/queries/README.md index b296cfbbef18..39494421d660 100644 --- a/docs/development/concepts/queries/README.md +++ b/docs/development/concepts/queries/README.md @@ -5,13 +5,11 @@ description: An introduction to queries in OpenProject and their use in the fron keywords: queries, query space, work package views --- +# Development concept: Queries +The Query object is the concept of holding the configuration for a specific work package view as well as computing and outputting its results. They are fundamental building blocks in OpenProject and used in many modules (Work packages, BIM, Boards, Gantt charts, embedded tables). Their flexibility allows for building complex features with relatively little effort on the backend side. For an OpenProject developer who wants to improve or create new features it is fundamental to understand how queries work. -# Query - -The Query object is the concept of holding the configuration for a specific work package view as well as computing and outputting its results. They are fundamental building blocks in OpenProject and used in many modules (Work packages, BIM, boards, timeline, embedded tables). Their flexibility allows for building complex features with relatively little effort on the backend side. For an OpenProject developer who wants to improve or create new features it is fundamental to understand how queries work. - -Most of the communication in OpenProject is organized in work packages. Work packages are managed and displayed in many different places, such as the work package table in the _Work packages_ module, or as cards the _Boards_ module, within the _Calendar_ or in _My page_ widgets, such as charts for instance. Even the list of child work packages within a work package is a query. +Most of the communication in OpenProject is organized in work packages. Work packages are managed and displayed in many different places, such as the work package table in the _Work packages_ module, or as cards in the _Boards_ module, within the _Calendar_ or in _My page_ widgets, such as charts for instance. Even the list of child work packages within a work package is a query. For the work packages table, the query object holds the way the table displays (selected columns, display mode) as well as what data it contains (filters, sort criteria). @@ -36,12 +34,12 @@ The following guides are related: ## Query definition -A query is an object that revolves about two types of information: +A query is an object that revolves around two types of information: 1. The configuration needed to fetch data from the database (filters, sort criteria, grouping criterion etc.) 2. The way the results retrieved from the database with the above filters are displayed (set of visible columns, highlighting, display mode of the work package table, etc.) -Currently, the Queries endpoint and object is highly specific to work packages, but this is bound to change with more resources becoming queryable. For some other resources such as projects, queries already exists in the backend, but not yet in the frontend application. +The `/api/v3/queries` endpoint and the frontend `QueryResource` described here are specific to work packages. Other resources have their own query concepts: project lists, for example, use the `ProjectQuery` model and are rendered server-side by `Projects::QueriesController` and `Projects::TableComponent`, without the Angular query space described below. ## API Backend @@ -50,7 +48,7 @@ Queries are regular APIv3 grape endpoints that can be accessed through the `/api 1. By accessing a previously saved query by their ID such as `/api/v3/queries/2453` ([Example JSON response](https://community.openproject.org/api/v3/queries/2453) on community) 2. By setting dynamic GET parameters on a saved or `default` query such as `/api/v3/queries/default?columns[]=id` ([Example JSON response](https://community.openproject.org/api/v3/queries/default?columns[]=id) on community) -The default query `/api/v3/queries/default` and `/api/v3/:project_id/queries/default` contains a default set of configuration (back-end and front-end) global and for the given project, respectively. They can only be modified administrators through some global settings. +The default queries `/api/v3/queries/default` and `/api/v3/workspaces/:id/queries/default` (also available as `/api/v3/projects/:id/queries/default`) contain a default set of configuration (back-end and front-end), globally and for the given project, respectively. They can only be modified by administrators through some global settings. A number of parameters can be passed to the Query through parameters as elaborated on in [the APIv3 Queries documentation](../../../api/endpoints/queries/). @@ -58,7 +56,7 @@ Clients can define a query once, save it and use it later on to load the same se ### Query collections responses -Since queries can be saved and should be listed to the user such as in the work package sidebar, they can also be requested as a collection of resources through `/api/v3/queries`. This endpoint can also be filtered. For more details on that, see the [respective APIv3 Queries filtering section](../../../api/endpoints/queries/#query-filter-instance). +Since queries can be saved and should be listed to the user such as in the work package sidebar, they can also be requested as a collection of resources through `/api/v3/queries`. This endpoint can also be filtered. For more details on that, see the [respective APIv3 Queries filtering section](../../../api/endpoints/queries/#query-filter-instance). This response will end up representing the available queries on the `work packages` module sidebar as shown below. @@ -76,8 +74,8 @@ When accessing a singular query resource, the response will always contain the s - `createdAt` / `updatedAt` timestamps for the resource - `_links.project` to the project it is saved in (if project-scoped) - `_links.user` reference to the user that saved or requested the query -- Properties regarding the **displaying of the query** results such as `timelineVisible` (show the gantt chart), `highlightingMode` and `showHierarchies` -- **Embedded HAL links** and `_embedded` related to how the results are to be fetched +- Properties regarding the **displaying of the query** results such as `timelineVisible` (show the Gantt chart), `highlightingMode` and `showHierarchies` +- **Embedded HAL links** and `_embedded` related to how the results are to be fetched - `filters` selected filters array - `columns` embedded array of selected `columns` - `sortBy` array of one or multiple sort criteria. @@ -96,7 +94,7 @@ These filters are also saved within the queries. Read the [APIv3 filters documen Due to the public nature of the OpenProject community, you can check out the following exemplary query response in HAL+JSON: [community.openproject.org/api/v3/queries/2453](https://community.openproject.org/api/v3/queries/2453) -It returns a saved query for the OpenProject 11.0 release, with a type filter `type is not [Idea, Phase, Release]` , a version filter `version = 11.0.0` and a "show all subprojects" filter with `subProject = all` . It is sorted by `type ascending`. +It returns a saved query for the OpenProject 11.0 release, with a type filter `type is not [Idea, Phase, Release]`, a version filter `version = 11.0.0` and a "show all subprojects" filter with `subProject = all`. It is sorted by `type ascending`. The resulting work packages will differ based on the visibility of the work packages and your permissions in the projects. For more information, check out [the concept on permissions](../permissions). @@ -118,42 +116,43 @@ This can happen several times on other pages too. For example, the board page wi Each of these queries need their own set of services for handling selected filters, sort criterion and other view related data. -To properly isolate this data, the application has the concept of an [`IsolatedQuerySpace`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/directives/query-space/isolated-query-space.ts) and associated `IsolatedQuerySpaceDirective`, which is an Angular directive that provides a query space (a store object) to its DOM children. This uses the Angular hierarchical injectors functionality, [which you can read up on here](https://angular.io/guide/hierarchical-dependency-injection). +To properly isolate this data, the application has the concept of an [`IsolatedQuerySpace`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/directives/query-space/isolated-query-space.ts) and the associated [`WorkPackageIsolatedQuerySpaceDirective`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/directives/query-space/wp-isolated-query-space.directive.ts) (`[opWorkPackageIsolatedQuerySpace]`), which is an Angular directive that provides a query space (a store object) to its DOM children. This uses [Angular hierarchical dependency injection](https://angular.dev/guide/di/hierarchical-dependency-injection). -The `IsolatedQuerySpace` has individually accessible states for every part of the query resource, such as `filters` , `columns`, `groups`, and so on. +The `IsolatedQuerySpace` is a `StatesGroup` (see [state management](../state-management)) with individually accessible states for parts of the query, such as `columns`, `groups`, and the results. The options available for selection, such as the available `filters`, `columns` and `sortBy` criteria, live under `available.*`. The currently selected filters and sort criteria are held by the view services below. Additionally, the logical parts of the query are encapsulated into view services that components of the table can use to modify and update data in the frontend, before saving the query back to the API. -These states are being initialized in the [`WorkPackagesInitializationService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/components/wp-list/wp-states-initialization.service.ts) for the current isolated query space whenever a query is loaded through the designated services. The important services are: +These states are being initialized in the [`WorkPackageStatesInitializationService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/components/wp-list/wp-states-initialization.service.ts) for the current isolated query space whenever a query is loaded through the designated services. The important services are: - `WorkPackageViewFiltersService` for manipulating, adding, and removing filters of the query - `WorkPackageViewColumnsService` for adding, moving, or removing columns and getting available columns for selection - `WorkPackageViewSortByService` for getting and updating the sort criteria object of the query - `WorkPackageViewGroupByService` for getting and updating the grouping property of the query -- `WorkPackageViewTimelineService` for identifying and modifying whether the timeline is visible in the table, and what settings it has -- `WorkPackageViewHierarchiesService` for manipulating the visible hierarchies in the table and temporarily storing what hierarchies are being shown or collapsed. +- `WorkPackageViewTimelineService` for identifying and modifying whether the Gantt chart (timeline) is visible next to the table, and what settings it has +- `WorkPackageViewHierarchiesService` for manipulating the visible hierarchies in the table and temporarily storing what hierarchies are being shown or collapsed +- `WorkPackageViewSumService` for showing or hiding the sums of the table There are additional services for handling pagination and other parts of the query that can be controlled individually. -Whenever the frontend updates a state of these services, the original query _may_ be updated due to change observers in the [`WorkPackagesViewBase`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/routing/wp-view-base/work-packages-view.base.ts) component. They will end up updating the query resource, and in turn updating the view services again. However the frontend will never directly modify the query resource itself to preserve one way data flow. +Whenever the frontend updates a state of these services, the original query _may_ be updated due to change observers in the [`WorkPackagesViewBase`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/routing/wp-view-base/work-packages-view.base.ts), an abstract directive class that the work package view pages extend. They will end up updating the query resource, and in turn updating the view services again. However the frontend will never directly modify the query resource itself to preserve one way data flow. ### Loading queries with WorkPackagesListService -First, you'll need to get hold of a query resource by loading it from the API. This is done through the [`WorkPackagesListService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts). It handles streaming of requests through RXJS observables and initializing all the necessary services of the query space. +First, you'll need to get hold of a query resource by loading it from the API. This is done through the [`WorkPackagesListService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts). It handles streaming of requests through RxJS observables and initializing all the necessary services of the query space. The easiest use case is loading a query from its saved `id` property. This would work as follows: ```typescript -const wpList = injector.get(WorkPackagesListService); +const wpList = inject(WorkPackagesListService); wpList - .fromQueryParams({ query_id: 1234 }) - .subscribe(query => console.log(query.results.count)) + .fromQueryParams({ query_id: '1234' }) + .subscribe((query) => console.log(query.results.count)); ``` In practice, you will likely not only access the query resource itself, but rather one of the many `WorkPackageView*Service` view services to control `filters` and other properties. -The `WorkPackagesListService` can also update and save existing queries passed to it. This flow will often happen in the [`PartitionedQuerySpaceComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts), which is the basis for the modules showing work packages as a table or grid such as the [`WorkPackageViewPageComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/routing/wp-view-page/wp-view-page.component.ts) or the [`IfcViewerPageComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/bim/ifc_models/pages/viewer/ifc-viewer-page.component.ts). +The `WorkPackagesListService` can also update and save existing queries passed to it. This flow will often happen in the [`PartitionedQuerySpacePageComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts), which is the basis for the modules showing work packages as a table or grid such as the [`WorkPackageViewPageComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/work-packages/routing/wp-view-page/wp-view-page.component.ts) or the [`IFCViewerPageComponent`](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/bim/ifc_models/pages/viewer/ifc-viewer-page.component.ts). -`PartitionedQuerySpaceComponent` instances will be instantiated by the router and listen to URL params to load the corresponding query object. The most prominent example of such a page is the work packages module such as [community.openproject.org/work_packages](https://community.openproject.org/work_packages). +`PartitionedQuerySpacePageComponent` instances will be instantiated by the router and listen to URL params to load the corresponding query object. The most prominent example of such a page is the work packages module such as [community.openproject.org/work_packages](https://community.openproject.org/work_packages). The partitioning comes from showing a work package table (or cards view) on one side, and a details view of a single work package on another side, splitting the page in two. The width of the split areas can be customized by the user through a drag-handle. From d51d99f7630002d7cdf45ecc63fa0476a1bd11b7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:14:21 +0000 Subject: [PATCH 07/17] [OP-20572] Refresh translations doc Fixes the locale export details and samples. https://community.openproject.org/wp/OP-20572 --- .../concepts/translations/README.md | 63 +++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/docs/development/concepts/translations/README.md b/docs/development/concepts/translations/README.md index ee70dd743e5d..dd3530f855ff 100644 --- a/docs/development/concepts/translations/README.md +++ b/docs/development/concepts/translations/README.md @@ -7,9 +7,9 @@ keywords: translations, I18n, localization, locales, languages # Development concept: Translations -OpenProject is currently being translated in over 40 languages. We use a cloud translation service called [Crowdin](https://crowdin.com/project/openproject) for allowing the community and professional translators to contribute translations to OpenProject. +OpenProject is currently being translated into over 50 languages. We use a cloud translation service called [Crowdin](https://crowdin.com/project/openproject) for allowing the community and professional translators to contribute translations to OpenProject. -Not all translations are fully translated, and they will fallback to english strings for the untranslated portions. +Not all translations are fully translated, and they will fall back to English strings for the untranslated portions. ![Overview of translation and their progress](translations-overview.png) @@ -19,24 +19,27 @@ _Translations in OpenProject..._ - depend on the [I18n yaml-based Rails standard](https://guides.rubyonrails.org/i18n.html) for translation files - are split into backend and frontend strings +- are used with Rails `t` in views and ViewComponents, and with the global `I18n.t` (or the `I18nService` in Angular) in frontend code ## I18n definitions -The OpenProject localizable strings are stored in the [Rails-standard I18n YAML files](https://guides.rubyonrails.org/i18n.html). The core only manages english source strings in `en.yml` and `js-en.yml` files. The can be found at `config/locales/en.yml` and `config/locales/js-en.yml`, respectively. +The OpenProject localizable strings are stored in the [Rails-standard I18n YAML files](https://guides.rubyonrails.org/i18n.html). The core only manages English source strings in `en.yml` and `js-en.yml` files. They can be found at `config/locales/en.yml` and `config/locales/js-en.yml`, respectively. Additionally, modules can define their own translations, such as `modules/budgets/config/locales/en.yml`. -The `js-en.yml` are not special on their own, but are simply prefixed with the `js:` key at the beginning of the file. This means all translations within are prefixed with the `js.` key. This is picked up by [`I18n.js`](https://github.com/fnando/i18n-js), a Ruby gem and frontend library that helps outputting javascript objects for the frontend. Only strings that are prefixed with `js.` and some internals will end up in the frontend due to the config we applied in [`config/i18n.yml`](https://github.com/opf/openproject/blob/dev/config/i18n.yml). The translations are output by the rake task `./bin/rails assets:export_locales` to `frontend/src/locales/{language}.js`. +The `js-en.yml` files are not special on their own, but are simply prefixed with the `js:` key at the beginning of the file. This means all translations within are prefixed with the `js.` key. This is picked up by [`I18n.js`](https://github.com/fnando/i18n-js), a Ruby gem and frontend library that exports translations for the frontend. Only strings that are prefixed with `js.`, plus the `number`, `time` and `date` formats, will end up in the frontend due to the config we applied in [`config/i18n.yml`](https://github.com/opf/openproject/blob/dev/config/i18n.yml). English fallbacks are embedded for untranslated strings. + +The translations are output by the rake task `./bin/rails assets:export_locales` to `frontend/src/locales/{language}.json`. `bin/setup_dev` runs this task for you, and in development, [`config/initializers/i18n-js.rb`](https://github.com/opf/openproject/blob/dev/config/initializers/i18n-js.rb) listens for changes to the locale files and re-exports them automatically. ## Using I18n in your Rails code -You are likely familiar already with the `I18n.t` helper provided by Rails. We simply use this helper to localize strings. Translations are simply loaded by Rails and available everywhere in the application. +You are likely familiar already with the `I18n.t` helper provided by Rails. We use this helper to localize strings. Translations are loaded by Rails and available everywhere in the application. -Let's say want to add a specific label to a work package page and use that in a HTML ruby file, you should go through this way: +Let's say you want to add a specific label to a work package page and use that in an ERB view. You should go through this way: You have to add the below rows to the appropriate `en.yml` file. If you're editing a module, add the string to the module's `config/locales/en.yml` file. -You should prefix your string unless its something very generic: +You should prefix your string unless it's something very generic: ```yaml # ... @@ -45,12 +48,14 @@ work_packages: my_button: "My localized button label" ``` -You can then use the `t()` helper method in the Rails erb view to translate the string: +You can then use the `t()` helper method in the Rails ERB view to translate the string: ```erb -<%= styled_button_tag t('work_packages.my_specific_page.my_button') %> +<%= render(Primer::Beta::Button.new(type: :submit, scheme: :primary)) { t("work_packages.my_specific_page.my_button") } %> ``` +In ViewComponents, prefer [lazy lookup](https://guides.rubyonrails.org/i18n.html#lazy-lookup) with a leading dot. The key is then scoped to the component, so `t(".heading_description")` in `Settings::UserCustomFields::HeaderComponent` resolves to `settings.user_custom_fields.header_component.heading_description`. + Outside of the views and controllers, you need to use the module function `I18n.t` to be used, for example in Ruby tests: ```ruby @@ -61,9 +66,11 @@ The Rails guide on translations will give advanced examples on variable interpol ## Using I18n in your frontend code -The frontend receives all translations prefixed with `js-` (see above) through compiled javascript files from `frontend/src/locales/{language}.js` folder generated by I18n.js. These locales are loaded early on in the application in [frontend/src/main.ts](https://github.com/opf/openproject/blob/dev/frontend/src/main.ts) to ensure they are present when the page loads. The import happens dynamically so only the language the user has selected is going to be loaded by the bundler. +The frontend receives all translations prefixed with `js.` (see above) through the `frontend/src/locales/{language}.json` files generated by I18n.js. These locales are loaded early on by [`initializeLocale()`](https://github.com/opf/openproject/blob/dev/frontend/src/app/core/setup/init-locale.ts), which [frontend/src/main.ts](https://github.com/opf/openproject/blob/dev/frontend/src/main.ts) calls to ensure they are present when the page loads. The import happens dynamically so only the language the user has selected is going to be loaded by the bundler. -If you want to create an angular component named ‘news’ and translate the text of a "save news" button and author of the news, you should go through this way: +The loaded translations are available through a global `I18n` object. Stimulus controllers use it directly, e.g., `I18n.t('js.sort.sorted_asc')`. + +If you want to create an Angular component named `news` and translate the text of a "save news" button and the author of the news, you should go through this way: You have to add the below (exemplary) entry to the `js-en.yml` file @@ -75,29 +82,33 @@ js: label_added_by: "Added by %{author}" ``` -You will then need to access the Angular [`I18nService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/core/i18n/i18n.service.ts) that provides access to the `I18n.js` helper. Right now, there is also a window global `I18n` that works, but it is not recommended to use that wherever you can inject the service instead. +In Angular code, inject the [`I18nService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/core/i18n/i18n.service.ts), which wraps the same `I18n.js` helper, instead of using the global. -In the `news.component.ts`, you should import the `I18nService`. Then you have to define a property named ‘text’ used for all text in your component, like below: +In the `news.component.ts`, inject the `I18nService`. Then define a property named `text` used for all static text in your component, and derive dynamic text with `computed`, like below: ```typescript -public text = { - saveButton: this.I18n.t('js.news.button_save'), - addedBy: (news:NewsResource) => this.I18n.t('js.news.label_added_time_by', { author: news.author} -}; +@Component({ selector: 'op-news', templateUrl: './news.component.html' }) +export class NewsComponent { + readonly authorName = input(); + + private readonly I18n = inject(I18nService); + + readonly text = { + saveButton: this.I18n.t('js.news.button_save'), + }; + + readonly addedBy = computed(() => this.I18n.t('js.news.label_added_by', { author: this.authorName() })); +} ``` Then you can use them as below in your template: ```html - - -@if (news.author) { -
- -
+ + +@if (authorName()) { +
{{ addedBy() }}
} ``` -For more information on formatting and other helpers of the `I18n.js` library, see [github.com/fnando/i18n-js](https://github.com/fnando/i18n-js) +For more information on formatting and other helpers of the `I18n.js` library, see [github.com/fnando/i18n-js](https://github.com/fnando/i18n-js). From cc309e60d9e7c43f849f0d15769a0ae4adf42509 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:15:49 +0000 Subject: [PATCH 08/17] [OP-20572] Refresh Hotwire concept doc Fixes stale helpers and resolves the TODOs. https://community.openproject.org/wp/OP-20572 --- .../hotwire-view-components/README.md | 93 ++++++++----------- 1 file changed, 40 insertions(+), 53 deletions(-) diff --git a/docs/development/concepts/hotwire-view-components/README.md b/docs/development/concepts/hotwire-view-components/README.md index b28cfcb8c550..93c2a5a72a92 100644 --- a/docs/development/concepts/hotwire-view-components/README.md +++ b/docs/development/concepts/hotwire-view-components/README.md @@ -15,24 +15,21 @@ The approach below is meant to be a thin abstraction layer on top of Hotwire's T **Component Setup** -- Components must include `OpTurbo::Streamable` module +- Components must include the [`OpTurbo::Streamable`](https://github.com/opf/openproject/blob/dev/app/components/concerns/op_turbo/streamable.rb) module - Requires `component_wrapper` in templates for turbo-stream updates - Can specify insert targets for append/prepend operations **Controller Integration** -- Controllers must include `OpTurbo::ComponentStream` module, which provides methods for turbo-stream operations: - - `update_via_turbo_stream` - - `replace_via_turbo_stream` - - `remove_via_turbo_stream` - - `modify_via_turbo_stream` - - `append_via_turbo_stream` - - `prepend_via_turbo_stream` - - `add_before_via_turbo_stream` - - `render_error_flash_message_via_turbo_stream` - - `update_flash_message_via_turbo_stream` - - `scroll_into_view_via_turbo_stream` -- Uses `respond_with_turbo_streams` to handle responses +- Controllers must include the [`OpTurbo::ComponentStream`](https://github.com/opf/openproject/blob/dev/app/controllers/concerns/op_turbo/component_stream.rb) module, which provides methods for turbo-stream operations. The most common ones are: + - Components: `update_via_turbo_stream`, `replace_via_turbo_stream`, `remove_via_turbo_stream`, `append_via_turbo_stream`, `prepend_via_turbo_stream`, `add_before_via_turbo_stream`, `add_after_via_turbo_stream`, and the generic `modify_via_turbo_stream` + - Flash messages: `render_success_flash_message_via_turbo_stream`, `render_error_flash_message_via_turbo_stream`, and the generic `render_flash_message_via_turbo_stream` + - Dialogs: `respond_with_dialog`, `dialog_via_turbo_stream`, `close_dialog_via_turbo_stream`, `update_dialog_title_via_turbo_stream` + - Page and frames: `scroll_into_view_via_turbo_stream`, `set_page_title_via_turbo_stream`, `reload_page_via_turbo_stream`, `reload_frame_via_turbo_stream`, `set_frame_src_via_turbo_stream`, `dispatch_event_via_turbo_stream` + + See the module itself for the full list and their options. +- Uses `respond_with_turbo_streams(status:)` to handle responses. The status can be a symbol or a `ServiceResult`, which maps to `:ok` or `:unprocessable_entity`. +- [`OpTurbo::FlashStreamHelper`](https://github.com/opf/openproject/blob/dev/app/controllers/concerns/op_turbo/flash_stream_helper.rb) adds `respond_with_flash`, `respond_with_flash_error` and `respond_with_flash_success` for responses that only show a flash message. ## Example @@ -60,17 +57,13 @@ class JournalIndexComponent < ApplicationComponent true end - def insert_target_modifier_id - "work-package-journals" - end - # ... end ``` with the following template: -```ruby +```erb <%= component_wrapper do # wrapper is required for turbo-stream updates! flex_layout do |journals_index_wrapper_container| @@ -91,6 +84,8 @@ with the following template: %> ``` +Appended or prepended components are inserted into the element with the id `insert_target_modifier_id`, which defaults to `"#{wrapper_key}-insert-target-modifier"` and can be overridden. Alternatively, `insert_target_container` renders such an element for you. For a real example, see [`WorkPackages::ActivitiesTab::Journals::LazyIndexComponent`](https://github.com/opf/openproject/blob/dev/app/components/work_packages/activities_tab/journals/lazy_index_component.rb). + And this is the show component: ```ruby @@ -111,7 +106,7 @@ end with the following template: -```ruby +```erb <%= component_wrapper do # wrapper is required for turbo-stream updates! render(border_box_container()) do |border_box_component| @@ -163,20 +158,16 @@ class JournalController < ApplicationController component: JournalNewComponent.new(work_package: journal.work_package) ) else - # optionally set a turbo status for the response - @turbo_status = :bad_request - # trigger a flash message via turbo-stream - # more on this here lookbook/pages/patterns/flash_banner - update_flash_message_via_turbo_stream( - message: journal.errors.full_messages.join(", "), - scheme: :danger + # more on this in the flash banner pattern in Lookbook + render_error_flash_message_via_turbo_stream( + message: journal.errors.full_messages.join(", ") ) end # respond with turbo streams which were collected in the @turbo_streams variable behind the scenes - # handy if this method is just meant to respond to turbo-stream requests - respond_with_turbo_streams + # the status is passed explicitly, as each turbo stream call above sets its own default status + respond_with_turbo_streams(status: journal.errors.empty? ? :ok : :bad_request) end # ... @@ -185,7 +176,7 @@ end ## Mixing turbo-streams and other responses -TODO: Discuss the below example +To respond to other formats as well, pass a block to `respond_with_turbo_streams`. It is called with the `respond_to` format object, so you can add an HTML response for requests that do not accept turbo streams: ```ruby class JournalController < ApplicationController @@ -196,17 +187,12 @@ class JournalController < ApplicationController def update # ... - respond_to do |format| - format.html do - # ... - end - format.turbo_stream do - update_via_turbo_stream( - component: JournalShowComponent.new(journal: journal) - ) + update_via_turbo_stream( + component: JournalShowComponent.new(journal: journal) + ) - render turbo_stream: resolve_turbo_streams, status: :ok - end + respond_with_turbo_streams do |format| + format.html { redirect_to journal_path(journal) } end end @@ -216,16 +202,16 @@ end ## Usage alongside Primer Forms/Buttons -TODO: is `turbo: true` required here? +Turbo Drive is enabled globally, so forms and links only need `turbo_stream: true` to request a turbo-stream response: -```ruby +```erb <%= component_wrapper do # ... primer_form_with( model: journal, method: :put, - data: { turbo: true, turbo_stream: true }, # add this! + data: { turbo_stream: true }, # add this! url: journal_path(id: journal.id) ) do |f| # ... @@ -235,15 +221,16 @@ TODO: is `turbo: true` required here? %> ``` -Rendering of a cancel button to remove the edition form. The button calls the `cancel_edit` action on the controller. The `cancel_edit` action sends a turbo stream `replace` to replace the journal edit form with the journal view. +Rendering of a cancel button to remove the edit form. The button calls the `cancel_edit` action on the controller. The `cancel_edit` action sends a turbo stream `replace` to replace the journal edit form with the journal view. -```ruby +```erb <%= component_wrapper do # ... render(Primer::Beta::Button.new( + tag: :a, href: cancel_edit_journal_path(journal.id), - data: { turbo: true, turbo_stream: true } # add this! + data: { turbo_stream: true } # add this! )) do t("button_cancel") end @@ -254,21 +241,19 @@ Rendering of a cancel button to remove the edition form. The button calls the `c ## Requesting turbo-streams within Stimulus controllers -If for some reason you need to request a turbo-stream programmatically from within a Stimulus controller, you can use the `TurboRequestsService` to do so. - -TODO: Discuss the TurboRequestsService API +If for some reason you need to request a turbo-stream programmatically from within a Stimulus controller, you can use the [`TurboRequestsService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/core/turbo/turbo-requests.service.ts) to do so. Get hold of it with the [`useAngularServices`](https://github.com/opf/openproject/blob/dev/frontend/src/stimulus/mixins/use-angular-services.ts) mixin, which handles waiting for the Angular plugin context and controllers that disconnect in the meantime: ```typescript import { Controller } from '@hotwired/stimulus'; +import { ServiceKey, useAngularServices } from 'core-stimulus/mixins/use-angular-services'; import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; export default class IndexController extends Controller { + static services:ServiceKey[] = ['turboRequests']; + declare turboRequests:TurboRequestsService; - private turboRequests:TurboRequestsService; - - async connect() { - const context = await window.OpenProject.getPluginContext(); - this.turboRequests = context.services.turboRequests; + initialize() { + useAngularServices(this); } private async someMethod() { @@ -282,3 +267,5 @@ export default class IndexController extends Controller { } } ``` + +Besides `request(url, init)`, the service provides `requestStream(url)` for GET requests of turbo streams, `submitForm(form, params, url)` to submit a form and render its turbo-stream response, and `abortRequest(requestId)` to cancel a pending request. From d3d3f797460f0ca320dd2a4f70cd652eb7fff5f3 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:16:56 +0000 Subject: [PATCH 09/17] [OP-20572] Refresh Stimulus concept doc Fixes the plugin import and adds conventions. https://community.openproject.org/wp/OP-20572 --- docs/development/concepts/stimulus/README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/development/concepts/stimulus/README.md b/docs/development/concepts/stimulus/README.md index 5dec8ee244ec..4779a49c6107 100644 --- a/docs/development/concepts/stimulus/README.md +++ b/docs/development/concepts/stimulus/README.md @@ -2,12 +2,10 @@ sidebar_navigation: title: Using Stimulus description: An introduction of how we use Stimulus to sprinkle interactivity -keywords: Stimulus, Ruby on Rail, Hotwire +keywords: Stimulus, Ruby on Rails, Hotwire --- - - -# Using Stimulus +# Using Stimulus In a decision to move OpenProject towards the [Hotwire approach](https://hotwired.dev/), we introduced [Stimulus.js](https://stimulus.hotwired.dev) to replace a collection of dynamically loaded custom JavaScript files used to sprinkle some interactivity. @@ -17,7 +15,7 @@ This guide will outline how to add controllers and the conventions around it. Th All controllers live under `frontend/src/stimulus/controllers/`. The naming convention is `.controller.ts`, meaning to dasherize the name of the controller. This makes it easier to generate names and classes using common IDEs. -If you want to add a common pattern, manually register the controller under `frontend/src/stimulus/setup.ts`. Often you'll want to have a dynamically loaded controller instead though. +If you want to add a common pattern, manually register the controller in `frontend/src/stimulus/setup.ts` with a `preregister` call, e.g., `OpenProjectStimulusApplication.preregister('flash', FlashController);`. Often you'll want to have a dynamically loaded controller instead though. ### Adding a static controller from a plugin @@ -26,7 +24,6 @@ If you want to add a stimulus controller from plugin code, you can do so by manu ```typescript import { OpenProjectStimulusApplication } from 'core-stimulus/openproject-stimulus-application'; import { MyTestControllerClass } from './test/foo/my-test.controller'; - OpenProjectStimulusApplication.preregister( 'test', @@ -61,7 +58,7 @@ You need to take care to prefix all actions, values etc. with the exact same pat If you want to add a dynamic stimulus controller import from plugin code, you can do so by manually adding it to the preregister: ```typescript -import { OpenProjectStimulusApplication } from 'core-app/stimulus/app'; +import { OpenProjectStimulusApplication } from 'core-stimulus/openproject-stimulus-application'; OpenProjectStimulusApplication.preregisterDynamic( 'test', @@ -79,3 +76,11 @@ If you have a single controller used in a partial, we have added a helper to use <% content_controller 'project-storage-form', 'project-storage-form-folder-mode-value': @project_storage.project_folder_mode %> ``` + +Similarly, `body_controller 'name'` adds a controller to the `` tag. + +## Conventions + +- **Angular services:** to use Angular services such as the `TurboRequestsService` from a controller, use the [`useAngularServices`](https://github.com/opf/openproject/blob/dev/frontend/src/stimulus/mixins/use-angular-services.ts) mixin instead of calling `window.OpenProject.getPluginContext()` yourself. See [requesting turbo-streams within Stimulus controllers](../hotwire-view-components/#requesting-turbo-streams-within-stimulus-controllers) for an example. +- **Element lookups:** `@openproject/stimulus-elements` is installed in `setup.ts` and allows declaring lookups of custom elements with `static elements`, e.g., `static elements = { menu: 'action-menu' };` together with `declare readonly menuElement:ActionMenuElement;`. +- **Tests:** controller specs live next to the controller as `.controller.spec.ts`. The helpers in `frontend/src/stimulus/test-helpers.ts` are described in the [frontend testing guide](https://github.com/opf/openproject/blob/dev/frontend/doc/TESTING.md). From ff97d0a6458ca7fe61029a0d4096e98f0e8845a1 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:18:20 +0000 Subject: [PATCH 10/17] [OP-20572] Refresh WYSIWYG editor doc Updates build, conversion and mention details. https://community.openproject.org/wp/OP-20572 --- .../concepts/wysiwyg-editor/README.md | 267 +++++++++--------- 1 file changed, 136 insertions(+), 131 deletions(-) diff --git a/docs/development/concepts/wysiwyg-editor/README.md b/docs/development/concepts/wysiwyg-editor/README.md index 80b44b9b77aa..0e3dc0f9cc9e 100644 --- a/docs/development/concepts/wysiwyg-editor/README.md +++ b/docs/development/concepts/wysiwyg-editor/README.md @@ -1,6 +1,21 @@ -# WYSIWYG editor +--- +sidebar_navigation: + title: WYSIWYG editor +description: An introduction to the CKEditor-based WYSIWYG editor and how it converts between HTML and Markdown +keywords: WYSIWYG, CKEditor, Markdown, editor, mentions +--- -OpenProject uses the ckEditor WYSIWYG editor. ckEditor is a great WYSIWYG framework with a lot of functionalities and great flexibility (custom builds, plugins...). All the OpenProject code related to ckEditor is hosted on a separate [repository](https://github.com/opf/commonmark-ckeditor-build). +# Development concept: WYSIWYG editor + +OpenProject uses the CKEditor 5 WYSIWYG editor. CKEditor is a WYSIWYG framework with a lot of functionality and great flexibility (custom builds, plugins...). All the OpenProject code related to CKEditor is hosted in a separate [repository](https://github.com/opf/commonmark-ckeditor-build). + +CKEditor is used in OpenProject as an input for string fields that require formatting, for example the description of a task, a comment or a wiki page. The collaborative editor of the _Documents_ module is a separate editor based on [BlockNote](https://www.blocknotejs.org/) and is not covered here. + +## Building and loading the editor + +The build repository bundles CKEditor together with the OpenProject plugins. Running `npm run build` there writes the bundle to `frontend/src/vendor/ckeditor/` in this repository, where it is committed. Changes to the build repository therefore always come with a core pull request containing the new bundle. See the [build repository README](https://github.com/opf/commonmark-ckeditor-build#building) for details and for a watch mode during development. + +In the frontend, the [`CKEditorSetupService`](https://github.com/opf/openproject/blob/dev/frontend/src/app/shared/components/editor/components/ckeditor/ckeditor-setup.service.ts) loads the bundle dynamically from `core-vendor/ckeditor/ckeditor` when an editor is first needed. ## TypeScript declarations in OpenProject core @@ -10,29 +25,28 @@ When updating the vendored build, update all direct `@ckeditor/ckeditor5-*` deve `ckeditor.types.ts` retains only the OpenProject configuration and `createCustomized` and `onEditorError` extensions alongside an alias for the upstream editor type. This is an interim integration until [OP-18993](https://community.openproject.org/wp/OP-18993) provides declarations from the custom build itself. -ckEditor is used in OpenProject as an input for string fields that require formatting, for example the description of a task, a comment or a wiki page. +## Markdown -## MarkDown +All formatted string field values are saved in the backend in [Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) format. -All formatted string field values are saved in the backend in [Markdown](https://guides.github.com/features/mastering-markdown/) format. +The `CommonMarkDataProcessor` class (`src/commonmark/commonmarkdataprocessor.ts` in the build repository) converts between the Markdown of the backend and the HTML that CKEditor needs: -In order to convert the backend markdown into the HTML that ckEditor needs, and the other way around, to convert the HTML that ckEditor outputs into the markdown that the backend is expecting, OpenProject uses [Turndown](https://github.com/domchristie/turndown). +- Its `toView` method turns Markdown into HTML using [markdown-it](https://github.com/markdown-it/markdown-it) (with task list support). +- Its `toData` method turns the HTML output of CKEditor back into Markdown using [Turndown](https://github.com/mixmark-io/turndown) and the `turndown-plugin-gfm` plugin. -The CommonMarkDataProcessor class (```commonmarkdataprocessor.js```) is in charge of this conversion, making all the tasks needed in order to turn markdown into HTML inside its "toView" method, and placing all the tasks needed to turn HTML into markdown within its "toData" method. +Because some elements needed in the editor (macros, mentions...) are not standard Markdown elements, they are not converted to Markdown but placed as HTML strings in the Markdown (``). The conversion from Markdown to HTML will just skip converting them and CKEditor will handle them properly. -Because some elements needed into the editor (macros, mentions...) are not standard markdown elements, they are not converted to markdown but placed as strings in the markdown (<macro class="embedded-table" data-query-props="..."></macro>). The conversion from markdown to HTML will just skip converting them and ckEditor will handle them properly. +## CKEditor mental model -## ckEditor Mental Model - -ckEditor follows a particular model-view-controller architecture that could be simplified like this: +CKEditor follows a particular model-view-controller architecture that could be simplified like this: ### Model -Is the HTML input data once loaded into the editor. ckEditor has a Model "DOM" where the HTML elements are replaced by ckEditor model elements emulating somehow the DOM tree. +Is the HTML input data once loaded into the editor. CKEditor has a Model "DOM" where the HTML elements are replaced by CKEditor model elements emulating the DOM tree. ### Views -CkEditor has two types of views: +CKEditor has two types of views: - **Data View:** the editor's input/output, the data imported to the editor and exported from the editor (HTML). - **Editing View:** the UI, the elements that the user interacts with in the editor's UI. @@ -44,17 +58,17 @@ Layer in charge of transforming the elements from the two Views to the Model and - The Data View (input HTML elements) into Model elements and the other way around. - The Editing View (UI elements) into Model elements and the other way around. -In ckEditor the Model is considered to be above the controllers, while the Views are below. This mental model is key to understand how the editor works. +In CKEditor the Model is considered to be above the controllers, while the Views are below. This mental model is key to understanding how the editor works. -![ckEditor workflow diagram](ckeditor-diagram.png) +![CKEditor workflow diagram](ckeditor-diagram.png) ## Plugins: Adding functionality -Because OpenProject is using some functionality/elements that are not the HTML standards, we need to extend the functionality of the CkEditor. This is done by adding Plugins that hook into the editor's operations which are the following: +Because OpenProject is using some functionality/elements that are not HTML standards, we need to extend the functionality of CKEditor. This is done by adding Plugins that hook into the editor's operations, which are the following: ### Data Upcast -Import the Data View into the Model (loading the HTML into the editor). We can hook into this process with the method editor.conversion.for("upcast"). +Import the Data View into the Model (loading the HTML into the editor). We can hook into this process with the method `editor.conversion.for('upcast')`. ### Editing Upcast @@ -62,44 +76,49 @@ Import the Editing View into the Model (translate the elements of the editor's U ### Data Downcast -Turning the Model into the Data View (the HTML output). We can hook into this process with the method editor.conversion.for("dataDowncast"). +Turning the Model into the Data View (the HTML output). We can hook into this process with the method `editor.conversion.for('dataDowncast')`. ### Editing Downcast -Turning the Model into the Editing View (the UI elements that the user manipulates). We can hook into this process with the method editor.conversion.for("editingDowncast"). +Turning the Model into the Editing View (the UI elements that the user manipulates). We can hook into this process with the method `editor.conversion.for('editingDowncast')`. -If we would want to hook into the Editing Downcast and the Data Downcast at the same time, applying the same transformations to the Model elements in both views, we could hook into the generic method editor.conversion.for("downcast"). +If we wanted to hook into the Editing Downcast and the Data Downcast at the same time, applying the same transformations to the Model elements in both views, we could hook into the generic method `editor.conversion.for('downcast')`. ## Elements, Attributes and Schemas -ckEditor has a Model "DOM" where the HTML elements are replaced by ckEditor model elements emulating somehow the DOM tree. Like in the DOM, in the CkEditor Model DOM we can place elements and attributes on elements. +CKEditor has a Model "DOM" where the HTML elements are replaced by CKEditor model elements emulating the DOM tree. Like in the DOM, in the CKEditor Model DOM we can place elements and attributes on elements. -In order to work with custom elements (ie: <macro>, <mention>...), we'll need to add them to the CkEditor schema. +In order to work with custom elements (i.e., ``, ``...), we'll need to add them to the CKEditor schema. ## Events -CkEditor provides an alternative way to hook into the upcast and downcast (editing downcast and data downcast) pipelines through events. +CKEditor provides an alternative way to hook into the upcast and downcast (editing downcast and data downcast) pipelines through events. For example, we could hook on the data downcast whenever a mention attribute is parsed with: ```javascript -editor.conversion.for( 'dataDowncast' ).add( dispatcher => { - dispatcher.on( 'attribute:mention', - ( evt, data, conversionApi ) => {/*...*/}, - { priority: 'high' } ); -}) +editor.conversion.for('dataDowncast').add((dispatcher) => { + dispatcher.on( + 'attribute:mention', + (evt, data, conversionApi) => { /* ... */ }, + { priority: 'high' }, + ); +}); ``` ## Debugging -CkEditor comes with a debugger inspector that will make the development process easier, showing in real time the view and model representation that the editor has: +CKEditor comes with a debugger inspector that will make the development process easier, showing in real time the view and model representation that the editor has: ![ckeditor-inspector](ckeditor-inspector.png) -In order to activate it, just import it (```import CKEditorInspector from '@ckeditor/ckeditor5-inspector';```) at the beginning of the ```op-config-customizer.js``` file and then attach it inside the editorClass.create method: +In order to activate it, install `@ckeditor/ckeditor5-inspector` in the build repository, import it (`import CKEditorInspector from '@ckeditor/ckeditor5-inspector';`) at the beginning of the `src/op-config-customizer.ts` file, and then attach it after the `editorClass.create` call: -```javascript -return editorClass.create(wrapper, configuration).then(editor => { +```typescript +return editorClass.create({ + ...configuration, + root: { ...configuration.root, element: wrapper }, +}).then((editor) => { CKEditorInspector.attach(editor); return editor; @@ -108,148 +127,134 @@ return editorClass.create(wrapper, configuration).then(editor => { ## An example -As an example, let's take a look at our mention feature that allows mentioning colleagues on the ckEditor with an autocomplete. The backend will send us them as mention tags inside the raw markdown (ie: <mention data-type="user" data-id="1" data-link="/users/1" data-text="@userName">@userName</mention>). +As an example, let's take a look at our mention feature that allows mentioning colleagues in CKEditor with an autocomplete. The backend will send them as mention tags inside the raw Markdown (i.e., `@userName`). + +The converters below are simplified excerpts. For the full code, including work package and emoji mentions, see `src/mentions/mentions-caster.ts` and `src/commonmark/commonmarkdataprocessor.ts` in the [build repository](https://github.com/opf/commonmark-ckeditor-build). In order to get it working, we did the following steps: ### 1 - Markdown to HTML -Define how the markdown elements are going to be translated to HTML elements. In this case, the backend is going to send the mention elements as tags (<mention ...>...</mention>) right inside the markdown data, so we don't need to make any conversion in order to turn it into the valid HTML that the CkEditor needs. \ -\ -In case it would be needed, it would be defined in the CommonMarkDataProcessor class (```commonmarkdataprocessor.js```), in its "toView" method. +Define how the Markdown elements are going to be translated to HTML elements. In this case, the backend is going to send the mention elements as tags (`...`) right inside the Markdown data, so we don't need to make any conversion in order to turn them into the valid HTML that CKEditor needs. + +If a conversion were needed, it would be defined in the `toView` method of the `CommonMarkDataProcessor`. ### 2 - Upcast -Define how the mention elements of the HTML input data are going to be represented in the ckEditor's Model: +Define how the mention elements of the HTML input data are going to be represented in CKEditor's Model: -```javascript +```typescript editor.conversion - .for( 'upcast' ) - .elementToAttribute( { - view: { - name: 'mention', - key: 'data-mention', - classes: 'mention', - }, - model: { - key: 'mention', - value: viewItem => { + .for('upcast') + .elementToAttribute({ + view: { + name: 'mention', + classes: 'mention', + }, + model: { + key: 'mention', + value: (viewItem) => { + const dataId = viewItem.getAttribute('data-id'); + const dataDisplayId = viewItem.getAttribute('data-display-id'); + const type = viewItem.getAttribute('data-type'); + const text = viewItem.getAttribute('data-text'); + + // Only used for the link in the editing view, not persisted + const link = getMentionLink(dataDisplayId || dataId, type); + // Third party plugin config (https://ckeditor.com/docs/ckeditor5/latest/features/mentions.html) - const mentionAttribute = editor.plugins.get( 'Mention' ).toMentionAttribute( viewItem, { - // Data that we need to pass to the model. - id: viewItem.getAttribute( 'data-id' ), - link: viewItem.getAttribute( 'data-link' ), - text: viewItem.getAttribute( 'data-text' ), - type: viewItem.getAttribute( 'data-type' ), - } ); - - return mentionAttribute; - } - }, - converterPriority: 'high' -} ); + return editor.plugins.get('Mention').toMentionAttribute(viewItem, { + dataId, + dataDisplayId, + link, + text, + type, + }); + }, + }, + converterPriority: 'high', + }); ``` -Here we basically say that when the ckEditor finds a 'mention' element with the CSS class 'mention' in the input data (HTML), it should place it in the editor's model as a 'mention' attribute with the additional data that we pass (id, link, text and type). - -This is defined in the CkEditor plugin (```mentions-caster.js```). +Here we say that when CKEditor finds a `mention` element with the CSS class `mention` in the input data (HTML), it should place it in the editor's model as a `mention` attribute with the additional data that we pass. The `link` is computed from the type and ID, as it is not part of the stored HTML. ### 3 - Editing downcast -Define how the mention attribute is going to be represented in the ckEditor's UI (Editing View): +Define how the mention attribute is going to be represented in CKEditor's UI (Editing View): -```javascript +```typescript editor.conversion .for('editingDowncast') .attributeToElement({ - model: 'mention', - converterPriority: 'high', - view: (modelAttributeValue, {writer}) => { - // Do not convert empty attributes (lack of value means no mention). - if ( !modelAttributeValue ) { - return; - } - - const element = writer.createAttributeElement( - 'a', - { - 'class': 'mention', - 'href': modelAttributeValue.link || '', - 'data-mention': modelAttributeValue.id || '', - 'title': modelAttributeValue.text || '', - } - ); - - return element; - } + model: 'mention', + converterPriority: 'high', + view: (modelAttributeValue, { writer }) => { + // Do not convert empty attributes (lack of value means no mention). + if (!modelAttributeValue) { + return; + } + + return writer.createAttributeElement('a', { + class: 'mention', + href: modelAttributeValue.link, + 'data-mention': modelAttributeValue.text, + title: modelAttributeValue.text, + }); + }, }); ``` -Here we say that we want to place the mention attributes of the ckEditor's model as links in the ckEditor's UI (<a href="/users/1" class="mention" data-mention="1" title="@userName">@userName</a>).\ -\ -This is defined in the CkEditor plugin (```mentions-caster.js```). +Here we say that we want to place the mention attributes of CKEditor's model as links in CKEditor's UI (`@userName`). ### 4 - Data downcast -Define how the mention attribute is going to be represented in the ckEditor's output (Data View): +Define how the mention attribute is going to be represented in CKEditor's output (Data View): -```javascript +```typescript editor.conversion .for('dataDowncast') .attributeToElement({ - model: 'mention', - converterPriority: 'high', - view: (modelAttributeValue, {writer}) => { - if ( !modelAttributeValue ) { - return; - } - - const element = writer.createAttributeElement( - 'mention', - { - 'class': 'mention', - 'data-id': modelAttributeValue.link || '', - 'data-type': modelAttributeValue.type || '', - 'data-link': modelAttributeValue.link || '', - 'data-text': modelAttributeValue.text || '', - } - ); - - return element; - } + model: 'mention', + converterPriority: 'high', + view: (modelAttributeValue, { writer }) => { + if (!modelAttributeValue) { + return; + } + + const attrs = { + class: 'mention', + 'data-id': modelAttributeValue.dataId, + 'data-type': modelAttributeValue.type, + 'data-text': modelAttributeValue.text, + }; + if (modelAttributeValue.dataDisplayId) { + attrs['data-display-id'] = modelAttributeValue.dataDisplayId; + } + + return writer.createAttributeElement('mention', attrs); + }, }); ``` -Here we say that we want to place the mention attributes of the ckEditor's model as mention tags in the ckEditor's output HTML (<mention data-type="user" data-id="1" data-link="/users/1" data-text="@userName">@userName</mention>). - -This is defined in the CkEditor plugin (```mentions-caster.js```). +Here we say that we want to place the mention attributes of CKEditor's model as mention tags in CKEditor's output HTML (`@userName`). The model update of the mentions when the user adds or removes a mention is handled by the editor features/plugins (a third party mention plugin in this case). ### 5 - HTML to Markdown -Define how the HTML mention tags are going to be represented in the markdown. They should be placed as strings, so we need to turn any mention element into its string representation: +Define how the HTML mention tags are going to be represented in the Markdown. They should be placed as strings, so we need to turn any mention element into its string representation: -```javascript -turndownService.addRule( 'mentions', { - filter: (node) => { - return ( - node.nodeName === 'MENTION' && - node.getAttribute('data-id') && - node.getAttribute('data-type') && - node.getAttribute('data-link') && - node.getAttribute('data-text') && - node.classList.contains('mention') - ) - }, - replacement: ( _content, node ) => node.outerHTML, +```typescript +turndownService.addRule('mentions', { + filter: (node) => node.nodeName === 'MENTION' && node.classList.contains('mention'), + replacement: (_content, node) => node.outerHTML, }); ``` -This is defined in the CommonMarkDataProcessor class (```commonmarkdataprocessor.js```), in its "toData" method. +This is defined in the `toData` method of the `CommonMarkDataProcessor`. The actual rule additionally collapses single-hash work package mentions into plain Markdown. ### More documentation -For more details about CkEditor, checkout its [documentation](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/overview.html). +For more details about CKEditor, check out its [documentation](https://ckeditor.com/docs/ckeditor5/latest/), in particular the [editing engine architecture](https://ckeditor.com/docs/ckeditor5/latest/framework/architecture/editing-engine.html). -If you're looking for instructions on how to use the WYSIWYG editor on OpenProject please look at [this page](../../../user-guide/wysiwyg). +If you're looking for instructions on how to use the WYSIWYG editor in OpenProject, please look at [this page](../../../user-guide/wysiwyg). From 61f0dd375472a9362cd2928893f237c5a9420bf8 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:19:02 +0000 Subject: [PATCH 11/17] [OP-20572] Complete HasDetailsTable doc Documents missing behaviour of the concern. https://community.openproject.org/wp/OP-20572 --- .../concepts/has-details-table/README.md | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/development/concepts/has-details-table/README.md b/docs/development/concepts/has-details-table/README.md index 99334a859186..1b6c4d79ebe4 100644 --- a/docs/development/concepts/has-details-table/README.md +++ b/docs/development/concepts/has-details-table/README.md @@ -23,6 +23,8 @@ _HasDetailsTable …_ - promotes validation errors from the detail onto the owner so they appear as first-class attributes. - provides `with_detail` and `where_detail` scopes for eager-loading and filtering. - duplicates the detail when the owner is `dup`'d. +- merges the detail's dirty tracking into the owner (`changed`, `changes`, `changed?`, `previous_changes`, …), so that `ModelContract` can detect unauthorized writes to delegated attributes. +- reloads the detail when the owner is reloaded. ## When to use @@ -72,19 +74,21 @@ widget.detail # => # ## What it sets up automatically -| Feature | Details | -| ---------------------- | --------------------------------------------------------------------------------------- | -| Detail class | `Detail` constant, subclass of `ApplicationRecord` | -| Association | `has_one :_detail` on owner, `belongs_to :` on detail | -| Aliases | `detail` / `detail=` / `build_detail` point to the association | -| Column delegation | Readers delegated via `delegate`, writers via custom methods that auto-build the detail | -| Association delegation | `belongs_to` associations declared in the block are delegated (both object and `_id`) | -| Auto-build | `after_initialize` builds the detail for new records | -| Error promotion | Detail validation errors are copied onto the owner | -| Dup support | `dup` on the owner also duplicates the detail | -| `with_detail` scope | `joins` + `includes` for eager loading | -| `where_detail` scope | `joins` + `where` for filtering by detail columns | -| Nested attributes | `accepts_nested_attributes_for` is called automatically | +| Feature | Details | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Detail class | `Detail` constant, subclass of `ApplicationRecord` | +| Association | `has_one :_detail` on owner, `belongs_to :` on detail | +| Aliases | `detail` / `detail=` / `build_detail` point to the association | +| Column delegation | Readers delegated via `delegate`, writers via custom methods that auto-build the detail; boolean columns also get `?` predicates | +| Association delegation | `belongs_to` associations declared in the block are delegated (both object and `_id`) | +| Auto-build | `after_initialize` builds the detail for new records | +| Error promotion | Detail validation errors are copied onto the owner | +| Dup support | `dup` on the owner also duplicates the detail | +| Dirty tracking | `changed`, `changes`, `changed?`, `changed_attributes`, `previous_changes`/`saved_changes` and `restore_attributes` include detail columns | +| Reload | `reload` on the owner also reloads the detail | +| `with_detail` scope | `joins` + `includes` for eager loading | +| `where_detail` scope | `joins` + `where` for filtering by detail columns | +| Nested attributes | `accepts_nested_attributes_for` is called automatically | ## Custom foreign key (STI) @@ -127,7 +131,7 @@ The detail table must follow these conventions: | Required columns | FK (non-null), `created_at`, `updated_at` | | Unique index | On the FK column (enforces 1:1) | -The concern reads the detail table's columns at load time to set up delegation, so **the migration must run before the model is loaded**. In practice this means the migration should exist before or alongside the code change — standard Rails migration ordering. +The concern reads the detail table's columns at load time to set up delegation, so **the migration should run before the model is loaded**. If it hasn't, delegation is deferred to `after_initialize` (see [Gotchas](#gotchas)). In practice this means the migration should exist before or alongside the code change — standard Rails migration ordering. ## Adding associations to the detail @@ -148,7 +152,7 @@ group.parent_id # delegated via column delegation group.parent_id = 42 # delegated via column writer ``` -The back-reference from the details table to the owner (`belongs_to :group` / `belongs_to :principal`) is set up automatically — don't declare it yourself. +The back-reference from the details table to the owner (e.g. `belongs_to :group`, with `foreign_key: :principal_id` when a custom foreign key is given) is set up automatically — don't declare it yourself. ## Gotchas From d462fdb05521da85d2efa2488c9ef90f4f61db82 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:20:17 +0000 Subject: [PATCH 12/17] [OP-20572] Refresh permissions doc Replaces removed methods and broken samples. https://community.openproject.org/wp/OP-20572 --- .../concepts/permissions/README.md | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/docs/development/concepts/permissions/README.md b/docs/development/concepts/permissions/README.md index 071d22de8990..cb3bb2135252 100644 --- a/docs/development/concepts/permissions/README.md +++ b/docs/development/concepts/permissions/README.md @@ -29,7 +29,7 @@ that holds a set of permissions associated in a `RolePermission` lookup table. There are multiple types of roles: - [Global roles](https://github.com/opf/openproject/blob/dev/app/models/global_role.rb) that are granted to user on a global level, i.e. they are not assigned per project. They can contain the permissions to e.g., _Create new projects_ or _Create_ or _Manage users_ -- Entity specific roles like [WorkPackage Role](https://github.com/opf/openproject/blob/dev/app/models/work_package_role.rb) that allows a user to get certain permissions on a single entity. _Currently, those roles are only seeded and cannot be manually edited or created by administrators._ +- Entity specific roles like the [WorkPackage role](https://github.com/opf/openproject/blob/dev/app/models/work_package_role.rb) and the [ProjectQuery role](https://github.com/opf/openproject/blob/dev/app/models/project_query_role.rb) that allow a user to get certain permissions on a single entity, such as a shared work package or project list. _Currently, those roles are only seeded and cannot be manually edited or created by administrators._ - All other roles (and the de facto default) are [Project roles](https://github.com/opf/openproject/blob/dev/app/models/project_role.rb). They grant users permission on an entire project and all resources belonging to that project. There are 2 special types of project roles: - _Non member_ roles that is a special role that applies to any _authenticated_ user and all public projects that this user is not a member of. On the OpenProject community, it is configured to grant non-members of the public projects, i.e. all logged in users, the permissions _Add forum posts_ and _Create new work package_. - _Anonymous_ roles that is a special role, similar to the _non member_ role but applying to non-authenticated users. @@ -47,11 +47,11 @@ Scrolling through the list of available permissions, you will begin to see the f The permissions are defined in two places: 1. The core [`config/initializers/permissions.rb`](https://github.com/opf/openproject/tree/dev/config/initializers/permissions.rb) initializer file. It defines the available project modules and its associated permissions -2. Module permissions defined in the `engine.rb` of modules under `module/` folder. For example, the definitions for budgets are defined in[`modules/budgets/lib/budgets/engine.rb`](https://github.com/opf/openproject/tree/dev/modules/budgets/lib/budgets/engine.rb). +2. Module permissions defined in the `engine.rb` of modules under the `modules/` folder. For example, the definitions for budgets are defined in [`modules/budgets/lib/budgets/engine.rb`](https://github.com/opf/openproject/tree/dev/modules/budgets/lib/budgets/engine.rb). -These definitions determine the name of the permission and the Rails controller actions that this permission unlocks. In some cases, the permissions do not define a controller action and then is only used for authorization checks in contracts. +These definitions determine the name of the permission and the Rails controller actions that this permission unlocks. In some cases, the permissions do not define a controller action and are then only used for authorization checks in contracts. -The definition also specifies what context a permission can be given to. The `permissible_on:` specifies those allowed contexts. The permission system will than take care of hinting the developer when they are checking a permission in a wrong context (i.e. checking for `create_user` permission on a specific project, but it only being a global permission). +The definition also specifies what context a permission can be given to. The `permissible_on:` specifies those allowed contexts. The permission system will then take care of hinting the developer when they are checking a permission in a wrong context (i.e. checking for `create_user` permission on a specific project, but it only being a global permission). ## Checking of permissions in Backend @@ -70,25 +70,42 @@ While not the case throughout the application, permissions: If the permission should be tested for a specific controller action, it will suffice to call the `before_action :authorize` to handle authorization. -As an example, the permissions `:manage_members`, `:view_members` is defined as follows: +As an example, the permissions `:manage_members` and `:view_members` are defined as follows (simplified): ```ruby -permission :manage_members, { members: %i[index new create update destroy autocomplete_for_member] } -permission :view_members, { members: [:index] } +map.permission :manage_members, + { + members: %i[index new create update destroy destroy_by_principal autocomplete_for_member menu], + "members/menus": %i[show] + }, + permissible_on: :project, + require: :member, + dependencies: :view_members, + contract_actions: { members: %i[create update destroy] } + +map.permission :view_members, + { + members: %i[index menu], + "members/menus": %i[show] + }, + permissible_on: :project, + contract_actions: { members: %i[read] } ``` This means if a user has only the `:view_members` permission, the `authorize` check in the [`MembersController`](https://github.com/opf/openproject/tree/dev/app/controllers/members_controller.rb) will allow the user to pass through for the `index` action (Overview of the members), but not the CRUD actions. These will only pass if the user has the `manage_members` (or both) permissions. ### API Endpoints -Given that the API endpoints are controllers as well, the permission checks are handled similarly, to protect an endpoint against unauthorized access, a before block is defined e.g. like this: +Given that the API endpoints are controllers as well, the permission checks are handled similarly. To protect an endpoint against unauthorized access, an `after_validation` block is defined, e.g., like this: ```ruby after_validation do - authorize :manage_members, global: true + authorize_in_project(:manage_members, project: @project) end ``` +The [API root](https://github.com/opf/openproject/blob/dev/lib/api/root_api.rb) provides matching helpers for each context: `authorize_in_project`, `authorize_in_projects`, `authorize_in_any_project`, `authorize_in_work_package`, `authorize_in_any_work_package` and `authorize_globally`. + However, for most endpoints, this does not need to be and should not be done on the controller level. Endpoints that are contract backed, which is true for most of the create, update and delete endpoints, the permissions, including access to a resource is checked within the contracts. The index endpoints are mostly protected by their queries relying on a `visible` scope which factors the permissions into the SQL fetching the records. That way, an empty collection is returned if the user lacks permission. If an explicit 403 needs to be returned, though, the explicit permission check in the endpoint is required for now. The show endpoints need to be protected akin to how the index actions. The `visible` scope is applied (e.g. `WorkPackage.visible.find(5)`). This will lead to a `RecordNotFound` exception being thrown when the permission is lacking. That exception is then handled transparently by returning a 404. We return 404 instead of 403 to not reveal the existence of a record. ### Scopes @@ -99,12 +116,12 @@ When a set of records is to be returned, e.g. for an index action, it is best to |-----------------------------------------------------|-----------------------------------------------------|--------------------------------------------------------------| | All projects a user is allowed a permission in | `Project.allowed_to(user, permission)` | `Project.allowed_to(User.current, :view_work_packages)` | | All work packages a user is allowed a permission in | `WorkPackage.allowed_to(user, permission)` | `WorkPackage.allowed_to(User.current, :view_work_packages)` | -| All users granted a permission in a project | `Authorization.users(permission, project: project)` | `Authorization.users(:view_work_packages, project: project)` | +| All users granted a permission in a project | `Authorization.users(permission, project)` | `Authorization.users(:view_work_packages, project)` | | All roles a user has in a project | `Authorization.roles(user, project)` | `Authorization.roles(User.current, project)` | | All roles a user has for a specific resource | `Authorization.roles(user, entity)` | `Authorization.roles(User.current, work_package)` | | All roles a user has globally | `Authorization.roles(user)` | `Authorization.roles(User.current)` | -Most of the time, a developer will not witness those queries as they are the embedded deeply within the existing scopes. E.g. the `visible` scopes defined for most AR models. +Most of the time, a developer will not witness those queries as they are embedded deeply within the existing scopes. E.g. the `visible` scopes defined for most AR models. The developer should mostly rely on the `allowed_to` scopes defined on the models that permissions can be checked for. @@ -124,21 +141,23 @@ user.allowed_in_project?(:view_members, project) user.allowed_in_any_project?(:view_members) # Checks if the user has the permission in the given work package *or* the project it belongs to -user.allowed_in_work_package?(:view_work_package, work_package) +user.allowed_in_work_package?(:view_work_packages, work_package) # Checks if the user has the permission in any work package *or* project -user.allowed_in_any_work_package?(:view_work_package) +user.allowed_in_any_work_package?(:view_work_packages) # Checks if the user has the permission in the given project *or* any work package that belongs to the given project -user.allowed_in_any_work_package?(:view_work_package, in_project: project) +user.allowed_in_any_work_package?(:view_work_packages, in_project: project) ``` +The `allowed_in_?` and `allowed_in_any_?` methods are generated for every entity that can have memberships (`Member::ALLOWED_ENTITIES`), so there are also `allowed_in_project_query?` and `allowed_in_any_project_query?`. + The same is true for global permissions using `user.allowed_globally?(permission)`. This will test for a global permission such as `:add_project`. Those methods will also aid the developer, when they are trying to request a permission in the wrong context. For example, when you try to check a global permission on a project, it will raise an `IllegalPermissionContextError` ```ruby -user.allowed_in_project(:create_user, project) +user.allowed_in_project?(:create_user, project) # => Authorization::IllegalPermissionContextError: Used permission "create_user" which maps to create_user in project context. Correct contexts for this permission are: global. ``` @@ -149,9 +168,9 @@ user.allowed_globally?(:create_users) # => Authorization::UnknownPermissionError: Used permission "create_users" that is not defined. It will never return true. ``` -### Deprecated methods +### Removed methods -Previously, we used to check permissions using the `allowed_to?`, `allowed_to_globally?` and `allowed_to_in_project?` methods. Because of some ambiguity in them (i.e. you could check for project permissions globally, which then returned what is now `allowed_in_any_project?`) and the lack of the checks that are mentioned above, those methods have been deprecated and will be removed in a future version of OpenProject. If you are using those methods in a module, please migrate to the new methods. +Previously, we used to check permissions using the `allowed_to?`, `allowed_to_globally?` and `allowed_to_in_project?` methods. Because of some ambiguity in them (i.e. you could check for project permissions globally, which then returned what is now `allowed_in_any_project?`) and the lack of the checks that are mentioned above, those methods were removed in OpenProject 14.0. If you maintain a module that still uses them, migrate to `allowed_in_project?`, `allowed_in_any_project?`, `allowed_globally?`, or the entity methods such as `allowed_in_work_package?`. ### Permissions in tests @@ -204,7 +223,7 @@ before do end ``` -This hooks into all permission checks (including the deprecated `allowed_to?` methods and methods like `allowed_in_any_project?`) that are executed on the user. **Scopes like `Project.visible`, `Project.allowed_to` and methods like `Authorization.roles` are _not_ mocked by this.** +This hooks into all permission checks (such as `allowed_in_project?`, `allowed_in_any_project?`, `allowed_globally?` and the entity methods) that are executed on the user. The mock also provides `allow_in_work_package`, `allow_in_project_query`, `allow_everything` and `forbid_everything`. **Scopes like `Project.visible`, `Project.allowed_to` and methods like `Authorization.roles` are _not_ mocked by this.** ## Checking of permissions in Frontend @@ -215,7 +234,7 @@ For example, if the user has the permission to create work packages in the OpenP To check these links, one can use the [`ModelAuthService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/core/model-auth/model-auth.service.ts) that gets initialized with the resources being loaded: ```typescript -const modelAuth = injector.get(ModelAuthService); +const modelAuth = inject(ModelAuthService); modelAuth.initModelAuth('work_packages', { createWorkPackage: () => 'foo' }); modelAuth.can('work_packages', 'createWorkPackage'); // true From 5c1c01785ab578a4cb28adacf161c966ec7d5f2a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:21:02 +0000 Subject: [PATCH 13/17] [OP-20572] Refresh secure coding guidelines Updates tooling and CSP details. https://community.openproject.org/wp/OP-20572 --- .../concepts/secure-coding/README.md | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/development/concepts/secure-coding/README.md b/docs/development/concepts/secure-coding/README.md index 40457cb9250f..460d2fbf00d7 100644 --- a/docs/development/concepts/secure-coding/README.md +++ b/docs/development/concepts/secure-coding/README.md @@ -2,16 +2,16 @@ sidebar_navigation: title: Secure coding guidelines description: An introduction and description of guidelines for writing secure code at OpenProject and preventing common security vulnerabilities. -keywords: infrastructure, security, coding, guidelines +keywords: security, coding, guidelines --- -# Secure coding Guidelines +# Secure coding guidelines -This document provides secure coding development guidelines for developers working on OpenProject. The objective is to help identify and mitigate potential security vulnerabilities early in the development process. This document is based on the best practices following the [Open Web Application Security Project (OWASP)](https://www.owasp.org). +This document provides secure coding development guidelines for developers working on OpenProject. The objective is to help identify and mitigate potential security vulnerabilities early in the development process. This document is based on the best practices following the [Open Web Application Security Project (OWASP)](https://owasp.org). By following these guidelines, developers can contribute to OpenProject while ensuring the security of OpenProject and reduce the risk of vulnerabilities being released into production. -The following guidelines are a starting point for developers interesting in contributing in OpenProject to ensure they are developing secure code. We recommend to refer to the [OWASP cheat sheets](https://cheatsheetseries.owasp.org) as well as the [OWASP Top Ten](https://owasp.org/www-project-top-ten/) for the most recent and detailed guidelines. The following sections are heavily inspired and cross-referencing the well-known recommendations from the the OWASP, each section providing links for further references to generic, Rails-centered as well as OpenProject-specific information when available. +The following guidelines are a starting point for developers interested in contributing to OpenProject to ensure they are developing secure code. We recommend referring to the [OWASP cheat sheets](https://cheatsheetseries.owasp.org) as well as the [OWASP Top Ten](https://owasp.org/www-project-top-ten/) for the most recent and detailed guidelines. The following sections are heavily inspired by and cross-reference the well-known recommendations from the OWASP, each section providing links for further references to generic, Rails-centered as well as OpenProject-specific information when available. By adhering to these secure coding development guidelines, contributors to OpenProject can help to significantly reduce the risk of adding potentially unsecure code. Regardless of these guidelines, be mindful when reviewing pull requests of features touching any of these guidelines, keep security in mind whenever you write new code for OpenProject to ensure we deliver a secure and trustworthy software. @@ -29,7 +29,7 @@ Implement strong authentication mechanisms for any sensitive credentials to be u - _Unauthorized Access:_ Attackers could gain unauthorized access on improper authentication mechanisms to protected resources, user accounts, or administrative functions. Resulting consequences are data breaches, unauthorized actions, and potential exposure of sensitive information. - _Authentication Bypass:_ Flaws in authentication logic can permit attackers to bypass the authentication process. Resulting consequences are the same as _Unauthorized Access_. -- _Credential Theft or Stuffing:_ Weak authentication methods may lead to attackers stealing user credentials (e.g., usernames and passwords) or reusing from credentials exploited on other services. Consequences are unauthorized access, account hijacking, and potential misuse of user accounts. +- _Credential Theft or Stuffing:_ Weak authentication methods may lead to attackers stealing user credentials (e.g., usernames and passwords) or reusing from credentials exploited on other services. Consequences are unauthorized access, account hijacking, and potential misuse of user accounts. - _Brute Force Attacks:_ Insufficient protection against brute force attacks can allow attackers to guess or crack passwords. This could result in account lockouts or takeovers despite otherwise sound mechanisms. - _Insecure Password Storage_: Storing passwords improperly (e.g., in plaintext or in outdated or incorrectly constructed cryptographic hash functions) can expose them to theft in case of data breaches. This in turn could result in a mass compromise of account data. - _Insufficient Multi-Factor Authentication (MFA):_ Lack of MFA support makes it easier for attackers to compromise accounts with stolen credentials. This results in reduced account security and higher risk of unauthorized access. @@ -38,7 +38,7 @@ Implement strong authentication mechanisms for any sensitive credentials to be u - Ensure uniqueness and case-insensitivity of user logins. - Use cryptographic hashes for password or credentials storage -- Allow administrators to enforce strong password policies with a combination of characters, numbers, and special symbols. Implement password expiration and account lockout mechanisms. +- Allow administrators to enforce strong password policies with a combination of characters, numbers, and special symbols. Implement password expiration and account lockout mechanisms. - Implement mechanisms to protect against brute force attacks, such as account lockouts, rate limiting, or increasing delays after multiple failed login attempts. - Use strong password controls and validations - Provide secure mechanisms for account recovery, such as sending a reset link to the user's registered email address. Avoid leaking the existence of user accounts by always returning the same response. @@ -61,16 +61,16 @@ OpenProject uses industry standard authentication mechanisms that follow the bes OpenProject recommends these authentication mechanisms: - All connections to and from OpenProject should be secured through TLS/SSL transport encryption. OpenProject assumes connections are secured through TLS/SSL by default in all production systems. Note that OpenProject does not provide TLS/SSL termination itself for Docker-based installations. The customer's IT department needs to configure and maintain the TLS certificates at the load balancer or proxying server before connections reach the application server. -- For any external connection (Database, LDAP, etc.), OpenProject uses openssl library for the host or container's openssl certificate store. Use your distribution's mechanisms to add verified certificate or certificate chains. For more information, see the [Ruby OpenSSL X509 Store documentation](https://ruby-doc.org/stdlib-2.4.0/libdoc/openssl/rdoc/OpenSSL/X509/Store.html). +- For any external connection (Database, LDAP, etc.), OpenProject uses openssl library for the host or container's openssl certificate store. Use your distribution's mechanisms to add verified certificate or certificate chains. For more information, see the [Ruby OpenSSL X509 Store documentation](https://docs.ruby-lang.org/en/master/OpenSSL/X509/Store.html). - For smaller to medium organizations with no centralized authentication mechanism, use the internal username / password authentication mechanism for secure storing of your user's credentials using BCrypt salted cryptographic hash function. - For organizations with a centralized and accessible LDAP server, [OpenProject provides LDAP userbind authentication](../../../system-admin-guide/authentication/ldap-connections/) to forward the authentication request to your LDAP server. Use TLS or LDAPS encrypted connections to the LDAP server to ensure transport level security. Optionally, synchronize roles and permissions using the [LDAP Group sync functionality](../../../system-admin-guide/authentication/ldap-connections/ldap-group-synchronization/). -- If your organization operates a central authentication services, it is very likely it supports one of the standard remote authentication mechanisms for single sign-on, such as [OpenID connect](../../../system-admin-guide/authentication/openid-providers/), [SAML](../../../system-admin-guide/authentication/saml/), or [Kerberos](../../../system-admin-guide/authentication/kerberos/). Use these mechanisms to ensure a standardized and secure authentication of users without requiring the storage of any credentials at OpenProject while providing a high level of usability due to centralized logins. +- If your organization operates a central authentication services, it is very likely it supports one of the standard remote authentication mechanisms for single sign-on, such as [OpenID connect](../../../system-admin-guide/authentication/openid-providers/), [SAML](../../../system-admin-guide/authentication/saml/), or [Kerberos](../../../system-admin-guide/authentication/kerberos/). Use these mechanisms to ensure a standardized and secure authentication of users without requiring the storage of any credentials at OpenProject while providing a high level of usability due to centralized logins. **References** -- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html -- https://guides.rubyonrails.org/security.html +- +- ### Session Management @@ -78,7 +78,7 @@ As OpenProject is a web application, the web session is the central mechanism of **Risks and Impacts** -- _Session Hijacking:_ Attackers may steal or manipulate active user session identifiers, gaining unauthorized access to user accounts or sensitive areas of the application. Resulting consequences are unauthorized access, data exposure, and potential misuse of user accounts. +- _Session Hijacking:_ Attackers may steal or manipulate active user session identifiers, gaining unauthorized access to user accounts or sensitive areas of the application. Resulting consequences are unauthorized access, data exposure, and potential misuse of user accounts. - _Session Fixation:_ Vulnerabilities that allow session fixation attacks can enable attackers to set a user's session ID and take control of their session. Resulting consequences are unauthorized access, data manipulation, and session theft. @@ -88,7 +88,7 @@ As OpenProject is a web application, the web session is the central mechanism of - _Insufficient Session Revocation:_ Lack of proper mechanisms to revoke or terminate user sessions can result in prolonged access for users who should no longer have it. Potential impacts are unauthorized access to protected resources and data. -- _Cross-Site Scripting (XSS) and Session Theft:_ If an application is vulnerable to XSS attacks, attackers can steal session identifiers and impersonate users. +- _Cross-Site Scripting (XSS) and Session Theft:_ If an application is vulnerable to XSS attacks, attackers can steal session identifiers and impersonate users. - _Lack of Session Regeneration:_ Failure to regenerate session identifiers upon login or privilege changes can expose users to session fixation attacks. @@ -108,7 +108,7 @@ As OpenProject is a web application, the web session is the central mechanism of **References** -- https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html +- ## Authorization and Access Control @@ -117,7 +117,7 @@ At its core, permissions in OpenProject are the central key to determine who can **Risks and Impact** - _Unauthorized access_: Users gaining or exploiting access to sensitive resources or functionalities they are not supposed to have access to. Potential consequences in data breaches, unauthorized actions, and potential exposure of confidential information. -- _Over-Privileged Users_: Users receiving more permissions than necessary for their role, leading to potential misuse of privileges. Potential consequences are unauthorized data modifications, data leaks, or abuse of system capabilities. +- _Over-Privileged Users_: Users receiving more permissions than necessary for their role, leading to potential misuse of privileges. Potential consequences are unauthorized data modifications, data leaks, or abuse of system capabilities. **Guidelines** @@ -130,8 +130,8 @@ At its core, permissions in OpenProject are the central key to determine who can **References** -- https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html -- https://guides.rubyonrails.org/security.html +- +- ## User Input Validation @@ -149,17 +149,17 @@ OpenProject is a form-driven application, meaning that users input a lot of data - Understand and use the [Rails framework's mechanisms](https://guides.rubyonrails.org/security.html#injection) to prevent injection and CSRF attacks - Understand and use the Rails framework to use its built-in security measures such as proper encoding of HTML output, CSRF tokens in all state-changing requests, and automatic escaping of user input in ActiveRecord SQL queries. -- Implement a strict [content security policy](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html) to mitigate common XSS, CSRF and similar cross-site attack vectors. OpenProject uses the Rails framework defaults to define the CSP. +- Implement a strict [content security policy](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html) to mitigate common XSS, CSRF and similar cross-site attack vectors. OpenProject defines a strict, nonce-based CSP using Rails' `content_security_policy` DSL in [`config/initializers/content_security_policy.rb`](https://github.com/opf/openproject/blob/dev/config/initializers/content_security_policy.rb). - Learn about the [different types of XSS](https://owasp.org/www-community/Types_of_Cross-Site_Scripting#stored-xss-aka-persistent-or-type-i) and their impacts: Reflected XSS, Stored XSS, Dom-based XSS and server vs client side XSS - Implement file upload filters based on file type, and ensure user-provided files cannot be executed as code. - Ensure transmission of confidential data does not happen through GET requests, but use POST/PUT/PATCH requests instead. **References** -- https://guides.rubyonrails.org/security.html#injection -- https://owasp.org/www-community/Types_of_Cross-Site_Scripting#stored-xss-aka-persistent-or-type-i -- https://cheatsheetseries.owasp.org/cheatsheets/Injection_Prevention_Cheat_Sheet.html -- https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html +- +- +- +- ## Virus and Malware protection @@ -177,12 +177,12 @@ As OpenProject may handle and distribute sensitive user data, attack vectors suc - Whitelist for uploads can be provided by MIME type, rejecting any non-matching files - OpenProject currently does not provide a built-in virus scanner. However, using [webhooks](../../../system-admin-guide/api-and-webhooks/#webhooks) and the [attachments API](../../../api/endpoints/attachments/), users can plug existing virus scanning tools and scrub any uploaded files. - _Malware in software_: - - OpenProject uses statical code analysis on every change provided to the application as well as code scanners on the artifacts generated from the source code (such as Snyk vulnerability scanner for Docker images). - - We recommend users to perform their own + - OpenProject uses static code analysis (CodeQL and Brakeman) on every change provided to the application, checks dependencies with `npm audit` and GitHub dependency review, and scans the artifacts generated from the source code (such as Docker Scout for Docker images). See [security tests](../../testing/#security-tests). + - We recommend users to perform their own vulnerability scanning of the deployed images and their dependencies. ## Logging and Error Handling -Inconsiderate use of error handling, logging, and monitoring mechanisms of a web frameworks can lead to the following risks and impacts. +Inconsiderate use of error handling, logging, and monitoring mechanisms of a web framework can lead to the following risks and impacts. **Risks and Impacts** @@ -202,15 +202,15 @@ Inconsiderate use of error handling, logging, and monitoring mechanisms of a web **Usage at OpenProject** -- Exception handlers catch all StandardErrors whenever your controller inherits from ApplicationController +- Exception handlers catch all StandardErrors in production whenever your controller inherits from ApplicationController - Exception responses are disconnected from the actual errors and provide user-friendly messages without error details -- Database transaction wrapping for any actions is wrapped in the [BaseContracted services](https://github.com/opf/openproject/blob/dev/app/services/base_services/base_contracted.rb#L54). Transactions are automatically rolled back in [Rails when exceptions occur](https://api.rubyonrails.org/v5.0.1/classes/ActiveRecord/Transactions/ClassMethods.html). +- Actions of the [BaseContracted services](https://github.com/opf/openproject/blob/dev/app/services/base_services/base_contracted.rb) are wrapped in a database transaction by the [service context](https://github.com/opf/openproject/blob/dev/app/services/shared/service_context.rb). Transactions are automatically rolled back in [Rails when exceptions occur](https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html). - OpenProject uses a LogRage formatter for flexible, yet easily parsable formats **References** -- https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html -- https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html +- +- ## External dependencies @@ -233,17 +233,17 @@ OpenProject includes a number of external dependencies both in Ruby as well as i - _Lockfile integrity_: Use `package-lock.json` and `Gemfile.lock` to pin exact version for a released version of OpenProject, ensuring that all environments use the same versions. - _Stay Informed_: Subscribe to mailing lists, newsletters, or vulnerability databases to receive timely information on crucial updates or security patches so that updates can be performed as fast as possible. - _Vet new dependencies_: Before adding a new gem or package, research its maintenance history, last update, known vulnerabilities, and community reviews. Check if it's actively maintained, and evaluate all the alternatives. -- _Remove outdated dependencies_ :Only include gems and packages that are absolutely necessary for your project. Less dependencies mean a reduced attack surface. Remove libraries if they become unused. +- _Remove outdated dependencies_: Only include gems and packages that are absolutely necessary for your project. Fewer dependencies mean a reduced attack surface. Remove libraries if they become unused. **References** -- https://cheatsheetseries.owasp.org/cheatsheets/Vulnerable_Dependency_Management_Cheat_Sheet.html +- ## Packaging and containerization Packaging and containerization are critical artifacts in the delivery pipeline of OpenProject. They encapsulate the application and its environment, ensuring consistent operation across different systems and infrastructures. These artifacts need to provide a secure and stable default for maintaining and upgrading OpenProject. -Properly managed packaging and containerization pipelines ensure smooth installations, upgrades, and scaling, enhancing the deployment process and - as a result - the overall user experience. This section highlights risks connected to improper containerization or packaging as well as our main objectives and best practices to provide a secure, efficient, and reliable software delivery process. +Properly managed packaging and containerization pipelines ensure smooth installations, upgrades, and scaling, enhancing the deployment process and - as a result - the overall user experience. This section highlights risks connected to improper containerization or packaging as well as our main objectives and best practices to provide a secure, efficient, and reliable software delivery process. OpenProject provides several installation mechanisms: @@ -251,7 +251,7 @@ OpenProject provides several installation mechanisms: - [Slim and all-in-one docker images](../../../installation-and-operations/installation/docker/) for manual operation with docker -- [OpenProject helm chart](../../../installation-and-operations/installation/helm-chart/) , as a "package" for kubernetes clusters +- [OpenProject helm chart](../../../installation-and-operations/installation/helm-chart/), as a "package" for kubernetes clusters **Risks and Impact** @@ -266,8 +266,8 @@ OpenProject provides several installation mechanisms: **Guidelines** - _Use Immutable Tags_: Provide specific, immutable tags for Docker images and fixed packaged versions to ensure consistency and provide clear upgrade paths instead of automatically pulling the latest image -- _Scan for Vulnerabilities_: Regularly scan container images for vulnerabilities and promptly update them. On top of statical code analysis for code and dependencies (see above), OpenProject uses Snyk and Docker scout for its public docker images to be informed of which vulnerabilities exist in the base images. -- _Minimal base Images_: Use minimal base images for Docker containers to reduce the attack surface. OpenProject currently uses a `ruby-${version}-${debianversion}` image as its base. +- _Scan for Vulnerabilities_: Regularly scan container images for vulnerabilities and promptly update them. On top of static code analysis for code and dependencies (see above), OpenProject uses Docker Scout for its public docker images to be informed of which vulnerabilities exist in the base images. +- _Minimal base Images_: Use minimal base images for Docker containers to reduce the attack surface. OpenProject currently uses a slim `ruby:${version}-slim-${debian}` image as its base. - _Configuration Management_: Ensure configuration management is consistent between deployments. OpenProject provides an interface to ENV-based configuration for packages and Docker to ensure both can be configured similarly. Where necessary, different configuration mechanisms are documented for the different installation mechanisms. - _Resource Limits_: Set resource limits and requests in container definitions to prevent resource contention. The packaged installation provides a set of default scaling services. The OpenProject helm-charts define limits and resource requirements in the helm values. - _Monitor and Logging_: Implement robust monitoring and logging to track the health and performance of containers. [OpenProject provides individually pluggable health checks for various services as well as flexible logging](../../../installation-and-operations/operation/monitoring/). From aabe25ee6e0881ed158c09a972231873459cb06d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:21:15 +0000 Subject: [PATCH 14/17] [OP-20572] Tidy infrastructure concept page Removes the empty heading and fixes grammar. https://community.openproject.org/wp/OP-20572 --- docs/development/concepts/infrastructure/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development/concepts/infrastructure/README.md b/docs/development/concepts/infrastructure/README.md index 9198b0a5272a..193c297c0afe 100644 --- a/docs/development/concepts/infrastructure/README.md +++ b/docs/development/concepts/infrastructure/README.md @@ -1,15 +1,15 @@ --- sidebar_navigation: title: Infrastructure overview -description: An introduction of the infrastructure architecture used at OpenProject. +description: An overview of the distributions and infrastructure models OpenProject is available in. keywords: infrastructure --- # Infrastructure architecture -OpenProject is an open-source core and available for users and customers as three different distributions available under two different infrastructure: +OpenProject is an open-source core, available to users and customers as three distributions across two infrastructure models: -- the on-premises [Community edition](https://www.openproject.org/download-and-installation/) and [Enterprise on-premises edition with Enterprise support](https://www.openproject.org/enterprise-edition/) which runs on your own hardware +- the on-premises [Community edition](https://www.openproject.org/download-and-installation/) and [Enterprise on-premises edition with Enterprise support](https://www.openproject.org/enterprise-edition/), which run on your own hardware - the [Enterprise cloud](https://www.openproject.org/enterprise-edition/#hosting-options) hosted distribution which is hosted and maintained by OpenProject -## Infrastructure architecture +For the architecture of the application itself, see the [application architecture](../../application-architecture) guide. From cba2dd8957baf9e45cdb62bfacf6aff01e6fea5a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 25 Oct 2026 22:21:40 +0000 Subject: [PATCH 15/17] [OP-20572] List all pages in the concepts index Adds the six missing pages. https://community.openproject.org/wp/OP-20572 --- docs/development/concepts/README.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/development/concepts/README.md b/docs/development/concepts/README.md index 784312f9d056..257197111448 100644 --- a/docs/development/concepts/README.md +++ b/docs/development/concepts/README.md @@ -12,14 +12,20 @@ This guide will introduce some concepts and give you a big picture of the develo Please choose an area that you would like to read about: -| Topic | Content | -|---------------------------------------------------------|:------------------------------------------------------------------------------| -| [Application architecture](../application-architecture) | An introduction of the application architecture used at OpenProject. | -| [State management](state-management) | How does the frontend handle state and react to changes? | -| [HAL resources](hal-resources) | What are HAL resources and how are they used in the frontend? | -| [Permissions](permissions) | How are roles and permissions handled in OpenProject code? | -| [Translations](translations) | How are translations used and built? | -| [Resource schemas](resource-schemas) | What is a schema and how is it tied to an editable resource? | -| [Resource changesets](resource-changesets) | How is change tracked to resources in the frontend? How to save the changes. | -| [Inline editing](inline-editing) | How does inline editing and the edit field functionality work in OpenProject? | -| [Queries and QuerySpace](queries) | What is the Query API concept and how is it used in the frontend? | +| Topic | Content | +|---------------------------------------------------------|:-----------------------------------------------------------------------------------| +| [Application architecture](../application-architecture) | An introduction of the application architecture used at OpenProject. | +| [Infrastructure](infrastructure) | Which distributions and infrastructure models is OpenProject available in? | +| [Hotwire with ViewComponents](hotwire-view-components) | How do we use Hotwire and Turbo Streams alongside ViewComponents? | +| [Stimulus](stimulus) | How do we add interactivity to server-rendered pages with Stimulus? | +| [State management](state-management) | How does the frontend handle state and react to changes? | +| [HAL resources](hal-resources) | What are HAL resources and how are they used in the frontend? | +| [Permissions](permissions) | How are roles and permissions handled in OpenProject code? | +| [Translations](translations) | How are translations used and built? | +| [Resource schemas](resource-schemas) | What is a schema and how is it tied to an editable resource? | +| [Resource changesets](resource-changesets) | How is change tracked to resources in the frontend? How to save the changes. | +| [Inline editing](inline-editing) | How does inline editing and the edit field functionality work in OpenProject? | +| [Queries and QuerySpace](queries) | What is the Query API concept and how is it used in the frontend? | +| [WYSIWYG editor](wysiwyg-editor) | How does the CKEditor-based editor convert between HTML and Markdown? | +| [Detail tables](has-details-table) | How to extend a model with extra columns in a separate table with HasDetailsTable? | +| [Secure coding guidelines](secure-coding) | How to write secure code and prevent common vulnerabilities? | From ed25a79dff0776fd290c0994ea8e820e240e8b2d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 27 Oct 2026 22:07:02 +0000 Subject: [PATCH 16/17] [OP-20572] Add alerts to concept docs Highlights existing caveats and editor guidance so readers can find constraints and debugging advice while scanning the concept pages. https://community.openproject.org/wp/OP-20572 --- docs/development/concepts/hal-resources/README.md | 7 +++---- docs/development/concepts/has-details-table/README.md | 3 ++- docs/development/concepts/state-management/README.md | 5 ++++- docs/development/concepts/wysiwyg-editor/README.md | 10 +++++++--- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/development/concepts/hal-resources/README.md b/docs/development/concepts/hal-resources/README.md index 197a5f96f90a..84eba2a50be5 100644 --- a/docs/development/concepts/hal-resources/README.md +++ b/docs/development/concepts/hal-resources/README.md @@ -194,11 +194,10 @@ console.log(project.name); // Demo Project console.log(wp.project.$loaded); // true ``` -On first glance, it might look nice to be able to `$load()` the embedded project on the fly and use the returning promise. However, this request will not be cached anywhere, thus loading the same project on multiple work packages will result in multiple requests. +> [!CAUTION] +> `$load()` is not cached and mutates the shared resource. Loading the same linked resource from several places sends repeated requests. Prefer a resource store where one exists. -Also, the `workPackage` state will be constantly mutated whenever these requests happen. You will always have to check whether the resource was loaded. - -Instead of explicitly loading linked resources, prefer a resource store that loads and caches resources of a specific type. For example, `ProjectsResourceService#requireEntity(href)` returns an observable of the project, fetched from the API only if it is not already cached. This does not mutate the work package resource. See [Choosing an approach for new code](#choosing-an-approach-for-new-code). +For example, `ProjectsResourceService#requireEntity(href)` returns an observable of the project, fetched from the API only if it is not already cached. This does not mutate the work package resource. See [Choosing an approach for new code](#choosing-an-approach-for-new-code). However, there are still use cases where `.$load()` is used and the resource is mutated. diff --git a/docs/development/concepts/has-details-table/README.md b/docs/development/concepts/has-details-table/README.md index 1b6c4d79ebe4..485fa636f1c9 100644 --- a/docs/development/concepts/has-details-table/README.md +++ b/docs/development/concepts/has-details-table/README.md @@ -131,7 +131,8 @@ The detail table must follow these conventions: | Required columns | FK (non-null), `created_at`, `updated_at` | | Unique index | On the FK column (enforces 1:1) | -The concern reads the detail table's columns at load time to set up delegation, so **the migration should run before the model is loaded**. If it hasn't, delegation is deferred to `after_initialize` (see [Gotchas](#gotchas)). In practice this means the migration should exist before or alongside the code change — standard Rails migration ordering. +> [!IMPORTANT] +> Run the detail-table migration before relying on column delegation at class-load time. If the table is absent, delegation is deferred to `after_initialize` (see [Gotchas](#gotchas)). Include the migration before or alongside the code change. ## Adding associations to the detail diff --git a/docs/development/concepts/state-management/README.md b/docs/development/concepts/state-management/README.md index 6e5fba6f8142..006a76e28666 100644 --- a/docs/development/concepts/state-management/README.md +++ b/docs/development/concepts/state-management/README.md @@ -20,9 +20,12 @@ _State management in OpenProject frontend..._ ## Choosing an approach for new code +> [!NOTE] +> This page describes `reactivestates` for existing code. For new code, follow the choices below. Akita entity stores remain the current option for shared API resources in Angular code, but Akita is no longer maintained and the frontend is moving towards Hotwire. + - **Server-rendered UI with Hotwire and Primer.** For new screens and features, render HTML in Rails with Primer ViewComponents and add behaviour with Stimulus. State then lives on the server and in the DOM, not in a frontend store. See [Using Hotwire with ViewComponents](../hotwire-view-components) and [Stimulus](../stimulus). - **Signals in existing Angular screens.** New Angular components are still fine where they integrate with existing Angular screens, such as the work package table or work package editing. Hold component and local state in Signals (`signal`, `computed`, signal inputs) wherever RxJS is not the right mental model, and keep RxJS for streams of events and requests. -- **Akita entity stores in `core/state`.** For API resources shared across Angular components, use a [`ResourceStoreService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/core/state/resource-store.service.ts) subclass such as `ProjectsResourceService`, following the entity store rules in the [frontend style guide](../../style-guide/frontend/). These are the current option for Angular code, not a long-term direction: Akita is no longer maintained, and the frontend is moving towards Hotwire. +- **Akita entity stores in `core/state`.** For API resources shared across Angular components, use a [`ResourceStoreService`](https://github.com/opf/openproject/tree/dev/frontend/src/app/core/state/resource-store.service.ts) subclass such as `ProjectsResourceService`, following the entity store rules in the [frontend style guide](../../style-guide/frontend/). - **`reactivestates`.** Use it only when working on existing code built around it, such as the `States` cache or the work package query space. ## InputState diff --git a/docs/development/concepts/wysiwyg-editor/README.md b/docs/development/concepts/wysiwyg-editor/README.md index 0e3dc0f9cc9e..1805fd73629e 100644 --- a/docs/development/concepts/wysiwyg-editor/README.md +++ b/docs/development/concepts/wysiwyg-editor/README.md @@ -19,9 +19,12 @@ In the frontend, the [`CKEditorSetupService`](https://github.com/opf/openproject ## TypeScript declarations in OpenProject core -Core imports CKEditor declarations with `import type` from version-pinned development dependencies. The runtime remains the vendored build in `frontend/src/vendor/ckeditor/`; do not add runtime imports from the npm packages. +> [!IMPORTANT] +> Import CKEditor declarations with `import type` from version-pinned development dependencies. Runtime code must continue using the vendored build in `frontend/src/vendor/ckeditor/`; do not add runtime imports from the npm packages. +> +> When updating the vendored build, update all direct `@ckeditor/ckeditor5-*` development dependencies in `frontend/package.json` to the exact CKEditor version used by that build and regenerate `frontend/package-lock.json`. -When updating the vendored build, update all direct `@ckeditor/ckeditor5-*` development dependencies in `frontend/package.json` to the exact CKEditor version used by that build and regenerate `frontend/package-lock.json`. The version is also recorded in the vendored source map's `ckeditor5-utils` source. Run application/spec typechecks and the editor browser specs after updating. +The version is also recorded in the vendored source map's `ckeditor5-utils` source. Run application/spec typechecks and the editor browser specs after updating. `ckeditor.types.ts` retains only the OpenProject configuration and `createCustomized` and `onEditorError` extensions alongside an alias for the upstream editor type. This is an interim integration until [OP-18993](https://community.openproject.org/wp/OP-18993) provides declarations from the custom build itself. @@ -108,7 +111,8 @@ editor.conversion.for('dataDowncast').add((dispatcher) => { ## Debugging -CKEditor comes with a debugger inspector that will make the development process easier, showing in real time the view and model representation that the editor has: +> [!TIP] +> Use CKEditor Inspector to inspect the editor's model and view while debugging plugins. ![ckeditor-inspector](ckeditor-inspector.png) From d9082d369dde29c259cf9899852a13f620e0f327 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 27 Oct 2026 22:10:41 +0000 Subject: [PATCH 17/17] Link CKEditor and Primer source repositories Makes the maintained build and design-system forks discoverable from their development documentation. --- docs/development/concepts/wysiwyg-editor/README.md | 2 +- docs/development/design-system/README.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/development/concepts/wysiwyg-editor/README.md b/docs/development/concepts/wysiwyg-editor/README.md index 1805fd73629e..6d24d79c0802 100644 --- a/docs/development/concepts/wysiwyg-editor/README.md +++ b/docs/development/concepts/wysiwyg-editor/README.md @@ -7,7 +7,7 @@ keywords: WYSIWYG, CKEditor, Markdown, editor, mentions # Development concept: WYSIWYG editor -OpenProject uses the CKEditor 5 WYSIWYG editor. CKEditor is a WYSIWYG framework with a lot of functionality and great flexibility (custom builds, plugins...). All the OpenProject code related to CKEditor is hosted in a separate [repository](https://github.com/opf/commonmark-ckeditor-build). +OpenProject uses the CKEditor 5 WYSIWYG editor. CKEditor is a WYSIWYG framework with a lot of functionality and great flexibility (custom builds, plugins...). OpenProject's custom CKEditor build and plugins are maintained in [opf/commonmark-ckeditor-build](https://github.com/opf/commonmark-ckeditor-build). CKEditor is used in OpenProject as an input for string fields that require formatting, for example the description of a task, a comment or a wiki page. The collaborative editor of the _Documents_ module is a separate editor based on [BlockNote](https://www.blocknotejs.org/) and is not covered here. diff --git a/docs/development/design-system/README.md b/docs/development/design-system/README.md index 812763f929e2..5723239439d4 100644 --- a/docs/development/design-system/README.md +++ b/docs/development/design-system/README.md @@ -8,3 +8,10 @@ keywords: Design system, Primer, styles, design, components # Design System and Component Libraries Starting in OpenProject 13.0., the [Primer Design System](https://primer.style/design/) is being used in OpenProject. Relevant reusable components from Primer as well as common patterns and compositions of these components will be documented in our [Lookbook](https://qa.openproject-edge.com/lookbook/). + +## Source repositories + +OpenProject maintains forks of the Primer libraries: + +- [opf/primer_view_components](https://github.com/opf/primer_view_components): ViewComponents for the Primer Design System used in OpenProject. +- [opf/openproject-octicons](https://github.com/opf/openproject-octicons): SVG icons and their Ruby and Angular libraries.