Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { forwardRef, memo } from 'react';
import type { WorkPackage } from '../../openProjectTypes';
import type { BlockWpSize } from '../WorkPackage/types';
import type { TapActivationProps } from '../../utils/tapActivation';
import type { MenuButtonProps } from '../../utils/a11y';
import { BlockCardM, BlockCardL, BlockCardXL } from './BlockCards';

export interface BlockCardProps {
Expand All @@ -10,14 +11,15 @@ export interface BlockCardProps {
inDropdown?:boolean;
linkTitle?:boolean;
onActivation?:TapActivationProps;
menuButton?:MenuButtonProps;
}

// Memoized: rendered inside popovers that re-render on hover/selection, while
// the work package reference stays stable (cached by useWorkPackage).
export const BlockCard = memo(
forwardRef<HTMLDivElement, BlockCardProps>(
({ workPackage, size = 'm', inDropdown, linkTitle, onActivation }, ref) => {
const shared = { workPackage, inDropdown, linkTitle, onActivation, cardRef: ref };
({ workPackage, size = 'm', inDropdown, linkTitle, onActivation, menuButton }, ref) => {
const shared = { workPackage, inDropdown, linkTitle, onActivation, menuButton, cardRef: ref };

if (size === 'xl') return <BlockCardXL {...shared} />;
if (size === 'l') return <BlockCardL {...shared} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '../../services/colors';
import { formatWorkPackageId } from '../../utils/id';
import type { TapActivationProps } from '../../utils/tapActivation';
import type { MenuButtonProps } from '../../utils/a11y';

const DESCRIPTION_MAX_CHARS = 300;

Expand All @@ -30,6 +31,7 @@ export interface BlockCardSharedProps {
inDropdown?:boolean;
linkTitle?:boolean;
onActivation?:TapActivationProps;
menuButton?:MenuButtonProps;
}

function buildTitle(workPackage:WorkPackage, linkTitle:boolean) {
Expand Down Expand Up @@ -98,13 +100,14 @@ const DescriptionSnippet = styled.p`
const CardMeta = ({
workPackage,
withRelations = false,
}:{ workPackage:WorkPackage; withRelations?:boolean }) => (
menuButton,
}:{ workPackage:WorkPackage; withRelations?:boolean; menuButton?:MenuButtonProps }) => (
<>
<WorkPackageType $color={typeColor(workPackage)}>
{workPackage._links?.type?.title}
</WorkPackageType>
{WRAP_OPPORTUNITY}
<WorkPackageId>{formatWorkPackageId(workPackage.displayId)}</WorkPackageId>
<WorkPackageId {...menuButton}>{formatWorkPackageId(workPackage.displayId)}</WorkPackageId>
{WRAP_OPPORTUNITY}
<WorkPackageStatus
$baseColor={statusColor(workPackage)}
Expand Down Expand Up @@ -135,19 +138,19 @@ export const BlockCardM = ({
inDropdown = false,
linkTitle = false,
onActivation,
menuButton,
cardRef,
}:BlockCardSharedProps & { cardRef?:React.Ref<HTMLDivElement> }) => (
<CardBase
ref={cardRef}
className="op-bn-work-package op-bn-work-package--m"
$inDropdown={inDropdown}
{...onActivation}
role={onActivation ? 'button' : undefined}
data-testid="block-card"
style={onActivation ? { cursor: 'pointer' } : undefined}
>
<CardDetails>
<CardMeta workPackage={workPackage} />
<CardMeta workPackage={workPackage} menuButton={menuButton} />
</CardDetails>
<WorkPackageTitle>{buildTitle(workPackage, linkTitle)}</WorkPackageTitle>
</CardBase>
Expand All @@ -160,19 +163,19 @@ export const BlockCardL = ({
inDropdown = false,
linkTitle = false,
onActivation,
menuButton,
cardRef,
}:BlockCardSharedProps & { cardRef?:React.Ref<HTMLDivElement> }) => (
<CardBase
ref={cardRef}
className="op-bn-work-package op-bn-work-package--l"
$inDropdown={inDropdown}
{...onActivation}
role={onActivation ? 'button' : undefined}
data-testid="block-card"
style={onActivation ? { cursor: 'pointer' } : undefined}
>
<CardDetailsSpaced>
<CardMeta workPackage={workPackage} withRelations />
<CardMeta workPackage={workPackage} withRelations menuButton={menuButton} />
</CardDetailsSpaced>
<WorkPackageTitle>{buildTitle(workPackage, linkTitle)}</WorkPackageTitle>
</CardBase>
Expand All @@ -184,6 +187,7 @@ export const BlockCardXL = ({
inDropdown = false,
linkTitle = false,
onActivation,
menuButton,
cardRef,
}:BlockCardSharedProps & { cardRef?:React.Ref<HTMLDivElement> }) => {
const rawDescription = workPackage.description?.raw;
Expand All @@ -200,12 +204,11 @@ export const BlockCardXL = ({
className="op-bn-work-package op-bn-work-package--xl"
$inDropdown={inDropdown}
{...onActivation}
role={onActivation ? 'button' : undefined}
data-testid="block-card"
style={onActivation ? { cursor: 'pointer' } : undefined}
>
<CardDetailsSpaced>
<CardMeta workPackage={workPackage} withRelations />
<CardMeta workPackage={workPackage} withRelations menuButton={menuButton} />
</CardDetailsSpaced>
<WorkPackageTitle>{buildTitle(workPackage, linkTitle)}</WorkPackageTitle>
{snippetText && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import { hideSafariPhantomSelection, selectBlockNode } from '../../utils/selecti
import { pendingBlockRegistry } from './pendingBlockRegistry';
import { useSuppressFormattingToolbar } from '../../hooks/useSuppressFormattingToolbar';
import { useTapActivation } from '../../utils/tapActivation';
import { useOptionsHost } from '../../utils/optionsHost';
import { isKeyboardClick, menuButtonProps } from '../../utils/a11y';
import { useOptionsMenu } from '../../hooks/useOptionsMenu';
import { useSelectionAnnouncement } from '../../hooks/useSelectionAnnouncement';
import { describeLinkedWorkPackage, unavailableKindOf, workPackageLabel } from '../WorkPackage/description';
import { useTranslation } from 'react-i18next';

const Block = styled.div.attrs({ className: 'op-bn-extensions', 'data-testid': 'block-wp-wrapper' })<{ $pending?:boolean; $selected?:boolean }>`
${defaultWpVariables}
Expand Down Expand Up @@ -55,6 +61,7 @@ export const BlockWorkPackageComponent = ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor:BlockNoteEditor<any>;
}) => {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement>(null);
const [blockEl, setBlockEl] = useState<HTMLDivElement | null>(null);
// Fetch and cache colors.
Expand All @@ -67,21 +74,21 @@ export const BlockWorkPackageComponent = ({
// and the outline never appears. So we set the outline ourselves.
const selectedBlocks = useSelectedBlocks(editor);
const isBlockSelected = selectedBlocks.some((b) => b.id === block.id);
const [isOptionsOpen, setIsOptionsOpen] = useState(false);
const options = useOptionsMenu();

useEffect(() => {
if (!isBlockSelected) return;
hideSafariPhantomSelection(editor);
return editor.onSelectionChange(() => hideSafariPhantomSelection(editor));
}, [isBlockSelected, editor]);

useSuppressFormattingToolbar(editor, isOptionsOpen);
useSuppressFormattingToolbar(editor, options.isOpen);

const tapProps = useTapActivation();
const toggleOptions = (event?:React.MouseEvent) => {
event?.stopPropagation();
selectBlockNode(editor, block.id);
setIsOptionsOpen((prev) => !prev);
options.toggle(isKeyboardClick(event));
};

const workPackageResult = useWorkPackage(block.props.wpid);
Expand All @@ -100,6 +107,17 @@ export const BlockWorkPackageComponent = ({
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const displayId = block.props.displayId || String(block.props.wpid);

const unavailableKind = unavailableKindOf(workPackageResult);
const hasOptions = Boolean(block.props.wpid && !workPackageResult.loading && (selectedWorkPackage ?? unavailableKind));

const optionsHostRef = useOptionsHost(() => {
if (hasOptions) options.open(true);
});

useSelectionAnnouncement(cardRef, editor, describeLinkedWorkPackage(t, displayId, selectedWorkPackage, unavailableKind));

const menuButton = menuButtonProps(workPackageLabel(t, displayId), options.isOpen);

// Read once into state: the registry entry is dropped on unmount, which
// StrictMode simulates, and a remount must not lose a filled form.
const [pendingMode, setPendingMode] = useState(() => pendingBlockRegistry.mode(block.id));
Expand Down Expand Up @@ -141,12 +159,13 @@ export const BlockWorkPackageComponent = ({

// Touch is listened for in its own right: a tap another element answers
// never becomes a mousedown.
const { isOpen: isOptionsOpen, close: closeOptions } = options;
useEffect(() => {
if (!isOptionsOpen) return;
const handlePressOutside = (e:Event) => {
const path = e.composedPath();
if (cardRef.current && !path.includes(cardRef.current)) {
setIsOptionsOpen(false);
closeOptions();
}
};
document.addEventListener('mousedown', handlePressOutside);
Expand All @@ -155,7 +174,7 @@ export const BlockWorkPackageComponent = ({
document.removeEventListener('mousedown', handlePressOutside);
document.removeEventListener('touchstart', handlePressOutside);
};
}, [isOptionsOpen]);
}, [isOptionsOpen, closeOptions]);

const handleConvertToInline = (size:InlineWpSize) => {
if (!block.props.wpid) return;
Expand All @@ -174,6 +193,7 @@ export const BlockWorkPackageComponent = ({

const trackBlockElement = (node:HTMLDivElement | null) => {
if (pendingMode !== undefined || node === null) setBlockEl(node);
optionsHostRef(node);
};

const optionsPopover = (
Expand All @@ -184,7 +204,9 @@ export const BlockWorkPackageComponent = ({
currentBlockSize={cardSize}
// eslint-disable-next-line react-hooks/refs
anchorEl={cardRef.current}
onClose={() => setIsOptionsOpen(false)}
autoFocus={options.takesFocus}
onClose={options.close}
onRestoreFocus={() => editor.focus()}
onConvertToInline={handleConvertToInline}
onConvertToBlock={handleResizeBlock}
onResizeBlock={handleResizeBlock}
Expand Down Expand Up @@ -215,7 +237,6 @@ export const BlockWorkPackageComponent = ({
{!workPackageResult.loading && (workPackageResult.error ?? workPackageResult.unauthorized) && (
<UnavailableCardWrapper
ref={cardRef}
role="button"
{...tapProps(toggleOptions)}
>
{workPackageResult.error ? (
Expand All @@ -224,17 +245,19 @@ export const BlockWorkPackageComponent = ({
headerKey="unavailableWorkPackage.error.header"
messageKey="unavailableWorkPackage.error.message"
displayId={displayId}
menuButton={menuButton}
/>
) : (
<UnavailableCard
icon={<EyeClosedIcon size={16} />}
headerKey="unavailableWorkPackage.unauthorized.header"
messageKey="unavailableWorkPackage.unauthorized.message"
displayId={displayId}
menuButton={menuButton}
linkHeader
/>
)}
{isOptionsOpen && optionsPopover}
{options.isOpen && optionsPopover}
</UnavailableCardWrapper>
)}
{!workPackageResult.loading &&
Expand All @@ -248,8 +271,9 @@ export const BlockWorkPackageComponent = ({
size={cardSize}
linkTitle
onActivation={tapProps(toggleOptions)}
menuButton={menuButton}
/>
{isOptionsOpen && optionsPopover}
{options.isOpen && optionsPopover}
</BlockCardWrapper>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
parseWorkPackageBlockExternalHTML,
} from './externalHtml';
import { PasteWorkPackageLinkExtension } from '../../plugins/pasteWorkPackageLinkExtension';
import { WorkPackageAccessibilityExtension } from '../../plugins/workPackageAccessibilityExtension';

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 should also be imported on the inline work package link spec.
I am aware that it most likely is ignored, but once that is fixed, it should be there so if someone only uses inline work package links, but not the block ones, it should still work.
It seems like a fix for that is already merged on BlockNote side (but not yet released)


export { blockConfig };

Expand Down Expand Up @@ -33,5 +34,6 @@ export const openProjectWorkPackageBlockSpec = createReactBlockSpec(

[
PasteWorkPackageLinkExtension,
WorkPackageAccessibilityExtension(),
]
);
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '../WorkPackage/atoms';
import { formatWorkPackageId } from '../../utils/id';
import { PreviewIndicator } from './PreviewIndicator';
import { buttonActivationProps } from '../../utils/a11y';
import type { MenuButtonProps } from '../../utils/a11y';
import type { WorkPackagePreview } from '../../hooks/useWorkPackagePreview';

const resolvedDisplayId = (wp:WorkPackage) => wp.displayId ?? String(wp.id);
Expand All @@ -28,28 +28,25 @@ const titleLinkProps = (wp:WorkPackage) => ({
$compact: true,
});

// XXS — "#ID"
export const WpChipXXS = ({ wp, preview, actionLabel }:{
interface WpChipProps {
wp:WorkPackage;
preview:WorkPackagePreview;
actionLabel:string;
}) => (
menuButton?:MenuButtonProps;
}

// XXS — "#ID"
export const WpChipXXS = ({ wp, menuButton, preview }:WpChipProps & { preview:WorkPackagePreview }) => (
<ChipBaseXXS>
<WorkPackageId
as="span"
$compact
{...(preview.indicatorProps ? buttonActivationProps(actionLabel) : {})}
>
<WorkPackageId as="span" $compact {...menuButton}>
{formatWorkPackageId(resolvedDisplayId(wp))}
</WorkPackageId>
<PreviewIndicator preview={preview} displayId={resolvedDisplayId(wp)} />
</ChipBaseXXS>
);

// XS — "#ID TYPE subject"
export const WpChipXS = ({ wp }:{ wp:WorkPackage }) => (
export const WpChipXS = ({ wp, menuButton }:WpChipProps) => (
<ChipBaseXS>
<WorkPackageId as="span" $compact>{formatWorkPackageId(resolvedDisplayId(wp))}</WorkPackageId>
<WorkPackageId as="span" $compact {...menuButton}>{formatWorkPackageId(resolvedDisplayId(wp))}</WorkPackageId>
{WRAP_OPPORTUNITY}
{wp._links?.type?.title && (
<WorkPackageType as="span" $compact $color={typeColor(wp)}>
Expand All @@ -64,9 +61,9 @@ export const WpChipXS = ({ wp }:{ wp:WorkPackage }) => (
);

// S — "#ID TYPE [Status] subject"
export const WpChipS = ({ wp }:{ wp:WorkPackage }) => (
export const WpChipS = ({ wp, menuButton }:WpChipProps) => (
<ChipBaseS>
<WorkPackageId as="span" $compact>{formatWorkPackageId(resolvedDisplayId(wp))}</WorkPackageId>
<WorkPackageId as="span" $compact {...menuButton}>{formatWorkPackageId(resolvedDisplayId(wp))}</WorkPackageId>
{WRAP_OPPORTUNITY}
{wp._links?.type?.title && (
<WorkPackageType as="span" $compact $color={typeColor(wp)}>
Expand Down
Loading
Loading