Skip to content

feat: add inline chips for WP with configurable size and type#110

Open
ihordubas99 wants to merge 8 commits intodevfrom
feature/72817-inline-wp-links-within-text-paragraphs
Open

feat: add inline chips for WP with configurable size and type#110
ihordubas99 wants to merge 8 commits intodevfrom
feature/72817-inline-wp-links-within-text-paragraphs

Conversation

@ihordubas99
Copy link
Copy Markdown
Collaborator

@ihordubas99 ihordubas99 commented Mar 17, 2026

Ticket

https://community.openproject.org/projects/communicator-stream/work_packages/72817

What are you trying to accomplish?

Add inline work package chips to the BlockNote editor. Users can now insert a WP reference directly inside a line of text (not just as a standalone block card), choose between three display sizes, and convert between inline and block representations.

Screenshots

image

What approach did you choose and why?

Inline content spec (InlineWorkPackage/)
BlockNote's createReactInlineContentSpec was used to register a new
inlineWorkPackage inline node with three props: wpid, instanceId,
and size. The component renders one of three chip variants depending
on size:

  • XXS#ID Title
  • XS#ID TYPE Title
  • S#ID TYPE [Status ▾] Title

The title is rendered as an <a> tag so it opens the WP directly,
while clicking any other part of the chip opens an options popover
(resize, open, remove, convert to block).

Event-based communication (useInlineWpEvents)
BlockNote's inline content render function does not receive the editor
instance, so direct mutations from inside the chip are not possible.
Instead, WpOptionsPopover dispatches custom DOM events
(op-inline-wp-resize, op-inline-wp-delete, etc.) and
useInlineWpEvents — attached once in OpBlockNoteEditor — listens
for them and calls editor.updateBlock / editor.insertBlocks
accordingly. Events are routed by a stable per-chip instanceId so
duplicate WP references in the same document are handled correctly.

Pending state
When the slash menu item is selected, a chip with wpid: "pending:<key>"
is inserted immediately. A search popover renders inside it; once the
user picks a WP the pending wpid is replaced with the real one via
editor.updateBlock.

Alternatives considered

  • Storing editor reference in a module-level singleton rejected because it breaks with multiple editor instances on the same page.

Merge checklist

  • Added/updated tests
  • Added/updated documentation in Lookbook (patterns, previews, etc)
  • Tested major browsers (Chrome, Firefox, Edge, ...)

@ihordubas99 ihordubas99 self-assigned this Mar 17, 2026
@ihordubas99 ihordubas99 marked this pull request as ready for review March 19, 2026 14:03
Copy link
Copy Markdown
Contributor

@judithroth judithroth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Ihor,

I have some troubles with this to be honest. I couldn't make it work on my machine, it would not change the sizes:

Screencast.from.2026-03-20.16-39-38.webm

Also, if you invoke the block with "/link" it does not always work:

Screencast.from.2026-03-20.16-45-50.webm

Also, for each change you're making on a repository of OpenProject, always include tests. Since you improved the test suite capabilities on op-blocknote-extensions, it would be good to have tests here.

I also saw again some code that changed that should probably not have been changed at all. I know this happens when you try out different approaches etc (happens to us all sometimes). Could you review your code for such changes before you file a PR? That would be helpful for me. Thank you!

I also have a couple of comments - please do not feel discouraged. I know it's a lot but the functionality you built is quite cool and will be a great help to our users once we have the code in a good shape!

Comment thread lib/components/InlineWorkPackage/atoms.tsx Outdated
Comment thread lib/components/InlineWorkPackage/atoms.tsx Outdated
Comment thread lib/components/InlineWorkPackage/popovers.tsx Outdated
Comment thread lib/components/InlineWorkPackage/popovers.tsx Outdated
Comment thread lib/components/InlineWorkPackage/popovers.tsx Outdated
Comment thread lib/components/OpenProjectWorkPackageBlock.tsx Outdated
Comment thread src/App.tsx
Comment thread src/App.tsx Outdated
Comment thread lib/components/InlineWorkPackageSlashMenu.tsx Outdated
Comment thread src/App.tsx Outdated
@ihordubas99 ihordubas99 requested a review from judithroth March 25, 2026 14:55
Copy link
Copy Markdown
Contributor

@judithroth judithroth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks a lot better than last time, thank you!

As this is getting more complex, I think it is important to add even more tests, especially integration tests / browser based tests. E.g. inserting a work package, then resizing it to the different styles. See that the information that should be there for the different sizes is there (and no more). Also have a test for the remove button (on inline and on block level work packages)

I have several comments all about how to structure the code / folder / files. I don't want you to go into the pains of refactoring everything just to learn in the next review that I don't agree to it and you have to refactor it all again 🙂
So I want you to read my comments and come up with an idea on how you would like to structure this project (or rather the components / elements / atoms). Just write an example down. After we found something we both find good you can go ahead and rework this to match that. Ok?

Comment thread lib/components/InlineWorkPackage/chipLayouts.tsx
return (
<SearchPopover onMouseDown={(e) => e.stopPropagation()}>
<label style={{ fontWeight: "normal" }}>
{t("search.label")}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for already reusing most of the search styles!

If you open the file OpenProjectWorkPackageBlock.tsx, there's also a Search element at line 131. I believe it to be quite similar - although the code differs a little (Search vs. SearchPopover), one using a label via const the other directly. Do we need both to be separate elements?

Okay, so maybe from a different perspective: How do we want to organize the code in this extension?
I would have imagined it to be something along the lines of:

  1. we have different components (lib/components)
  2. those consist of different elements (I think in your "language" maybe atoms?) which can be shared by the components. I tried to have something like this in lib/elements but I wasn't able to fully refactor it to that due to deadlines and there were less components that were reusing the same elements / atoms, so it didn't make sense to have it all very splitted up.
    From my perspective, we can also get rid of elements if you don't like that and just have everything in components - but with a clear structure. I am open to different ideas as long as they meet the goal of having an easy understandable structure and make it easy to reuse code 🙂 and you probably already have experience in how to structure frontend code in a good way.

So, one suggestion on how to organize the code could be:

  • lib/components/InlineWorkPackage.tsx
  • lib/components/BlockWorkPackage.tsx <--- that's currently called OpenProjectWorkPackageBlock
  • lib/components/WorkPackageSearch.tsx <--- used by inline and block work packages (is that possible?)
  • lib/components/SearchDropdown.tsx <--- you already added that one, thanks!

How would we handle things like WorkPackageStatus and WorkPackageType then which are shared by InlineWorkPackage and BlockWorkPackage? They probably all need to become their own components, right? So maybe:
(continuing from above)

  • lib/components/WorkPackage/Status.tsx
  • lib/components/WorkPackage/Type.tsx
    etc.

Or there could be an extra folder or module for work work package atoms that is included by both, InlineWorkPackage and BlockWorkPackage.
That approach would proabably also be nice for the search.

What do you think?

My motivation for this is that we want to add more functionality in the future. Then the code structure needs to be clear so it does not get confusing, if we e.g. add components for OpenProject documents or OpenProject Attachments etc. (which all will need a search somehow, maybe also different sizes (inline / block) etc.) - we can of course then also refactor a bit, but I would not want to have to support 6 different search boxes for example that all look nearly the same but don't use the same styles.

onConvertToInline?: (size: InlineWpSize) => void;
}

export const WpOptionsPopover = ({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should have a separate component / file for the Options. Rule of thumb: As soon as it returns HTML fragments (not sure what the correct React term is for that) it should be in it's own file.

Also this is shared by InlineWorkPackage and BlockWorkPackage, so it should not be in the InlineWorkPackage namespace / folder, but somehow in a shared folder / namespace (see other long comment about how to structure the code). If I see some code / files inside InlineWorkPackage, I would just change it and not search for usages because I assume it is only used there. If something is in a "shared" namespace, I'd be much more careful and do a search for it in the whole code base before changing it.

}
}

export const inlineWorkPackageSlashMenu = (editor: BlockNoteEditor<any, any, any>) => ({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So as far as I know (but I guess you know more, so please correct me if I am wrong here): We do not plan to have two slash menu entries (one for inline and one for block work packages). So I think this file and method should just be called workPackageSlashMenu.

Comment thread lib/components/OpenProjectWorkPackageBlock.tsx Outdated
Comment thread lib/services/utils.ts
Comment thread lib/services/wpBridge.ts Outdated
}

// Subscription API
onResize(cb: (payload: WpResizePayload) => void): () => void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does "cb" stand for? Please have the long form as variable name here.
(I usually try to avoid abbreviations where possible, because they often make the code harder to read / understand if the reader does not know what they stand for. But I understand that sometimes names get to long if everything is written in full form - but here I guess we can safely use the long form 🙂 )

Comment thread src/App.tsx
Comment thread test/lib/services/utils.test.ts Outdated

it("returns a unique ID on each call", () => {
const ids = new Set(Array.from({ length: 100 }, () => makeInstanceId()));
expect(ids.size).toBe(100);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL Array.from 👍
However, this test does not test what the title says - I think this is still missing here 🙂

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the test - it now explicitly checks that consecutive IDs differ (ids[i] !== ids[i + 1]) and verifies the full set size equals the array length, which actually validates uniqueness rather than just array construction

Comment thread README.md Outdated
@ihordubas99 ihordubas99 requested a review from judithroth April 1, 2026 14:53
Comment thread lib/components/InlineWorkPackage/types.ts
Copy link
Copy Markdown
Contributor

@judithroth judithroth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey,
the code itself already looks very good! The only remarks I have is on the tests - I added one longer comment in a test file as example but it applies to many of the tests you added.
I also tried using this on the vite test app (npm run dev / http://localhost:5173/) and there resizing and deleting does not work:

Screencast.from.2026-04-09.15-27-32.webm

I think we should make it work there as well, since the feedback loop for testing inside OpenProject is more complex. Next I will try it in OpenProject directly with the other PR 🙂

Comment thread lib/services/colors.ts

let theme: OpColorMode;
function getTheme(): OpColorMode {
export function getTheme(): OpColorMode {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This export is not used anywhere, right? Then can you please remove it again?

await userEvent.click(page.getByTitle('Change size'));
await userEvent.click(page.getByRole('button', { name: 'XS', exact: true }));

expect(onResize).toHaveBeenCalledWith('xs');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding so many tests!

This kind of test here is (click a button and ensure the callback is called that the button is wired to) is however not really useful.
In integration tests, always aim to test behaviour instead of implementation. Resizing could still be broken even though the callback is called as it should. In that case, the test does not help and in the contrary - changing this code, these method names now just got harder, because the test also needs to be rewritten.
That's not only for this test but for most of the tests added so far.

The integration tests that add real value are simple and test more how the user would test things:

  • insert an element in the page via the slash menu command
  • click on the element to open the sizing popover
  • click on another size
  • check what's inside the viewport - e.g. when switching from XS to S, before the work package status should be hidden, afterwards the work package status should be shown.

import { InlineWorkPackageChip } from "./InlineWorkPackageChip";
import { linkToWorkPackage } from "../../services/openProjectApi";

export const inlineWorkPackageSpec = createReactInlineContentSpec(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should try to be consistent with the naming here. The Block spec is called openProjectWorkPackageBlockSpec, so this one should be called openProjectWorkPackageInlineSpec (I think keeping the openProject prefix is nice, since this is also needed to be included in the BlockNote config if someone else wants to use this extension).

@ihordubas99 ihordubas99 requested a review from judithroth April 17, 2026 09:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants