Skip to content

Commit 78876df

Browse files
authored
Merge pull request #110 from opf/feature/72817-inline-wp-links-within-text-paragraphs
feat: add inline chips for WP with configurable size and type
2 parents 98cf461 + b612074 commit 78876df

58 files changed

Lines changed: 4458 additions & 764 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Include the following entry to your _package.json_.
2626
First thing is to initialize the library configuration...
2727

2828
```js
29-
initOpenProjectApi({ baseUrl: "https://my.openproject.url" });
29+
initOpenProjectApi({baseUrl: 'https://my.openproject.url'});
3030
```
3131

3232
... then setup a blocknote schema extending it with blocks offered by this library...
@@ -48,7 +48,7 @@ First thing is to initialize the library configuration...
4848
const getCustomSlashMenuItems = (editor: EditorType) => {
4949
return [
5050
...getDefaultReactSlashMenuItems(editor),
51-
openProjectWorkPackageSlashMenu(editor),
51+
workPackageSlashMenu(editor),
5252
];
5353
};
5454
```
@@ -81,16 +81,16 @@ Which will start a vite server with a BlockNote editor instance including the av
8181
This project uses `styledComponents` to define styles. This means that styles are, by default, injected onto the page header. To be able to use styles onto a shadow dom root it is necessary to use our `ShadowDomWrapper` component targeting the root for the styles.
8282

8383
```tsx
84-
<ShadowDomWrapper target={targetHtmlElementOrShadowRoot}>
85-
<MyBlockNoteView />
86-
</ShadowDomWrapper>
84+
<ShadowDomWrapper target={targetHtmlElementOrShadowRoot}>
85+
<MyBlockNoteView />
86+
</ShadowDomWrapper>
8787
```
8888

8989
### To run locally with valid API requests to an OpenProject instance
9090

9191
Step 1: Make sure that the OpenProject instance URL is correct in App.tsx
9292

93-
> initOpenProjectApi({ baseUrl: "https://" });
93+
> initOpenProjectApi({ baseUrl: "https://" });
9494
9595
Step 2: Enable CORS and set the local address of this application at https://openproject.local/admin/settings/api
9696

@@ -104,10 +104,10 @@ Step 5: Start the development server - `npm run dev`
104104

105105
## Components in this library
106106

107-
|Component|Description|
108-
|--|--|
109-
|WorkPackage block|Search and display elegantly work package links|
110-
|...|...|
107+
| Component | Description |
108+
| ----------------- | ----------------------------------------------- |
109+
| WorkPackage block | Search and display elegantly work package links |
110+
| ... | ... |
111111

112112
## Build
113113

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { forwardRef } from "react";
2+
import type { WorkPackage } from "../../openProjectTypes";
3+
import type { BlockWpSize } from "../WorkPackage/types";
4+
import { BlockCardM, BlockCardL, BlockCardXL } from "./BlockCards";
5+
6+
export interface BlockCardProps {
7+
workPackage: WorkPackage;
8+
size?: BlockWpSize;
9+
inDropdown?: boolean;
10+
linkTitle?: boolean;
11+
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
12+
}
13+
14+
export const BlockCard = forwardRef<HTMLDivElement, BlockCardProps>(
15+
({ workPackage, size = "m", inDropdown, linkTitle, onClick }, ref) => {
16+
const shared = { workPackage, inDropdown, linkTitle, onClick, cardRef: ref };
17+
18+
if (size === "xl") return <BlockCardXL {...shared} />;
19+
if (size === "l") return <BlockCardL {...shared} />;
20+
return <BlockCardM {...shared} />;
21+
}
22+
);
23+
24+
BlockCard.displayName = "BlockCard";
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import styled from "styled-components";
2+
import type { WorkPackage } from "../../openProjectTypes";
3+
import { linkToWorkPackage } from "../../services/openProjectApi";
4+
import {
5+
defaultWpVariables,
6+
WorkPackageId,
7+
WorkPackageType,
8+
WorkPackageStatus,
9+
WorkPackageTitle,
10+
WorkPackageTitleLink,
11+
} from "../WorkPackage/atoms";
12+
import {
13+
typeColor,
14+
statusColor,
15+
statusBorderColor,
16+
statusTextColor,
17+
statusBackgroundColor,
18+
} from "../../services/colors";
19+
20+
const DESCRIPTION_MAX_CHARS = 300;
21+
22+
export interface BlockCardSharedProps {
23+
workPackage: WorkPackage;
24+
inDropdown?: boolean;
25+
linkTitle?: boolean;
26+
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
27+
}
28+
29+
function buildTitle(workPackage: WorkPackage, linkTitle: boolean) {
30+
const href = linkToWorkPackage(workPackage.id);
31+
if (!linkTitle) return workPackage.subject;
32+
return (
33+
<WorkPackageTitleLink
34+
href={href}
35+
onClick={(e) => {
36+
e.preventDefault();
37+
e.stopPropagation();
38+
window.open(href, "_blank", "noopener,noreferrer");
39+
}}
40+
>
41+
{workPackage.subject}
42+
</WorkPackageTitleLink>
43+
);
44+
}
45+
46+
const CardBase = styled.div<{ $inDropdown: boolean }>`
47+
${defaultWpVariables}
48+
padding: var(--spacer-m) var(--spacer-l);
49+
background-color: var(--highlight-wp-background);
50+
border-radius: var(--bn-border-radius-small);
51+
52+
${({ $inDropdown }) =>
53+
$inDropdown &&
54+
`
55+
padding: var(--spacer-s) 0;
56+
background-color: transparent;
57+
`}
58+
`;
59+
60+
const CardDetails = styled.div.attrs({
61+
className: "op-bn-work-package--details",
62+
})`
63+
display: flex;
64+
flex-wrap: wrap;
65+
gap: 0 10px;
66+
width: 100%;
67+
font-size: 0.86em;
68+
`;
69+
70+
const CardDetailsSpaced = styled(CardDetails)`
71+
margin-bottom: var(--spacer-s);
72+
`;
73+
74+
const MetaItem = styled.span`
75+
color: var(--bn-colors-highlights-gray-text);
76+
font-size: 0.9em;
77+
`;
78+
79+
// Description snippet used in XL — clamped to 3 lines via CSS.
80+
const DescriptionSnippet = styled.p`
81+
margin: var(--spacer-s) 0 0;
82+
padding: 0;
83+
font-size: 0.85em;
84+
color: var(--bn-colors-highlights-gray-text);
85+
line-height: 1.5;
86+
overflow: hidden;
87+
display: -webkit-box;
88+
-webkit-line-clamp: 3;
89+
-webkit-box-orient: vertical;
90+
`;
91+
92+
// M — Compact card: Type, ID, Status + Subject
93+
export const BlockCardM = ({
94+
workPackage,
95+
inDropdown = false,
96+
linkTitle = false,
97+
onClick,
98+
cardRef,
99+
}: BlockCardSharedProps & { cardRef?: React.Ref<HTMLDivElement> }) => (
100+
<CardBase
101+
ref={cardRef}
102+
className="op-bn-work-package op-bn-work-package--m"
103+
$inDropdown={inDropdown}
104+
onClick={onClick}
105+
data-testid="block-card"
106+
style={onClick ? { cursor: "pointer" } : undefined}
107+
>
108+
<CardDetails>
109+
<WorkPackageType $color={typeColor(workPackage)}>
110+
{workPackage._links?.type?.title}
111+
</WorkPackageType>
112+
<WorkPackageId>#{workPackage.id}</WorkPackageId>
113+
<WorkPackageStatus
114+
$baseColor={statusColor(workPackage)}
115+
$borderColor={statusBorderColor()}
116+
$textColor={statusTextColor()}
117+
$bgColor={statusBackgroundColor()}
118+
>
119+
{workPackage._links?.status?.title}
120+
</WorkPackageStatus>
121+
</CardDetails>
122+
<WorkPackageTitle>{buildTitle(workPackage, linkTitle)}</WorkPackageTitle>
123+
</CardBase>
124+
);
125+
126+
127+
// L — Regular card: Type, ID, Status, Parent, Project + Subject
128+
export const BlockCardL = ({
129+
workPackage,
130+
inDropdown = false,
131+
linkTitle = false,
132+
onClick,
133+
cardRef,
134+
}: BlockCardSharedProps & { cardRef?: React.Ref<HTMLDivElement> }) => (
135+
<CardBase
136+
ref={cardRef}
137+
className="op-bn-work-package op-bn-work-package--l"
138+
$inDropdown={inDropdown}
139+
onClick={onClick}
140+
data-testid="block-card"
141+
style={onClick ? { cursor: "pointer" } : undefined}
142+
>
143+
<CardDetailsSpaced>
144+
<WorkPackageType $color={typeColor(workPackage)}>
145+
{workPackage._links?.type?.title}
146+
</WorkPackageType>
147+
<WorkPackageId>#{workPackage.id}</WorkPackageId>
148+
<WorkPackageStatus
149+
$baseColor={statusColor(workPackage)}
150+
$borderColor={statusBorderColor()}
151+
$textColor={statusTextColor()}
152+
$bgColor={statusBackgroundColor()}
153+
>
154+
{workPackage._links?.status?.title}
155+
</WorkPackageStatus>
156+
{workPackage._links?.parent?.title && (
157+
<MetaItem>{workPackage._links.parent.title}</MetaItem>
158+
)}
159+
{workPackage._links?.project?.title && (
160+
<MetaItem>{workPackage._links.project.title}</MetaItem>
161+
)}
162+
</CardDetailsSpaced>
163+
<WorkPackageTitle>{buildTitle(workPackage, linkTitle)}</WorkPackageTitle>
164+
</CardBase>
165+
);
166+
167+
// XL — Full card: Type, ID, Status, Parent, Project + Subject + Description
168+
export const BlockCardXL = ({
169+
workPackage,
170+
inDropdown = false,
171+
linkTitle = false,
172+
onClick,
173+
cardRef,
174+
}: BlockCardSharedProps & { cardRef?: React.Ref<HTMLDivElement> }) => {
175+
const rawDescription = workPackage.description?.raw;
176+
const snippetText = rawDescription
177+
? rawDescription.slice(0, DESCRIPTION_MAX_CHARS)
178+
: undefined;
179+
const isTruncated = rawDescription
180+
? rawDescription.length > DESCRIPTION_MAX_CHARS
181+
: false;
182+
183+
return (
184+
<CardBase
185+
ref={cardRef}
186+
className="op-bn-work-package op-bn-work-package--xl"
187+
$inDropdown={inDropdown}
188+
onClick={onClick}
189+
data-testid="block-card"
190+
style={onClick ? { cursor: "pointer" } : undefined}
191+
>
192+
<CardDetailsSpaced>
193+
<WorkPackageType $color={typeColor(workPackage)}>
194+
{workPackage._links?.type?.title}
195+
</WorkPackageType>
196+
<WorkPackageId>#{workPackage.id}</WorkPackageId>
197+
<WorkPackageStatus
198+
$baseColor={statusColor(workPackage)}
199+
$borderColor={statusBorderColor()}
200+
$textColor={statusTextColor()}
201+
$bgColor={statusBackgroundColor()}
202+
>
203+
{workPackage._links?.status?.title}
204+
</WorkPackageStatus>
205+
{workPackage._links?.parent?.title && (
206+
<MetaItem>{workPackage._links.parent.title}</MetaItem>
207+
)}
208+
{workPackage._links?.project?.title && (
209+
<MetaItem>{workPackage._links.project.title}</MetaItem>
210+
)}
211+
</CardDetailsSpaced>
212+
<WorkPackageTitle>{buildTitle(workPackage, linkTitle)}</WorkPackageTitle>
213+
{snippetText && (
214+
<DescriptionSnippet>
215+
{snippetText}
216+
{isTruncated && "…"}
217+
</DescriptionSnippet>
218+
)}
219+
</CardBase>
220+
);
221+
};

0 commit comments

Comments
 (0)