АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: устойчивое хранение layout-блоков карточки в detail_layout

This commit is contained in:
DCCONSTRUCTIONS
2026-04-25 18:06:05 +03:00
parent 4ed63cac4e
commit eac010d3d4
16 changed files with 693 additions and 136 deletions
@@ -28,10 +28,22 @@ type Props = {
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
compactView?: boolean;
onOpenChecker?: () => void;
onOpenTextBlock?: () => void;
};
export function IssueDetailWidgetActionButtons(props: Props) {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets, compactView = false } = props;
const {
workspaceSlug,
projectId,
issueId,
disabled,
issueServiceType,
hideWidgets,
compactView = false,
onOpenChecker,
onOpenTextBlock,
} = props;
// translation
const { t } = useTranslation();
@@ -50,6 +62,8 @@ export function IssueDetailWidgetActionButtons(props: Props) {
}
disabled={disabled}
issueServiceType={issueServiceType}
onOpenChecker={onOpenChecker}
onOpenTextBlock={onOpenTextBlock}
/>
)}
{!hideWidgets?.includes("relations") && (
@@ -8,10 +8,18 @@ import React from "react";
// plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// local imports
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { IssueAttachmentsCollapsibleContent } from "./attachments/content";
import { IssueDetailWidgetActionButtons } from "./action-buttons";
import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles";
import { IssueDetailWidgetModals } from "./issue-detail-widget-modals";
import { IssueStructuredContentBlocks } from "./structured-content-blocks";
import {
createIssueStructuredBlock,
extractIssueStructuredContent,
serializeIssueStructuredContent,
} from "./structured-content.helpers";
import type { TIssueOperations } from "../issue-detail/root";
type Props = {
workspaceSlug: string;
@@ -22,6 +30,7 @@ type Props = {
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
compactView?: boolean;
issueOperations?: TIssueOperations;
};
export function IssueDetailWidgets(props: Props) {
@@ -34,11 +43,26 @@ export function IssueDetailWidgets(props: Props) {
issueServiceType,
hideWidgets,
compactView = false,
issueOperations,
} = props;
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
const issue = getIssueById(issueId);
const hideWidgetsWithInlineAttachments = hideWidgets?.includes("attachments")
? hideWidgets
: ([...(hideWidgets ?? []), "attachments"] as TWorkItemWidgets[]);
const addStructuredBlock = async (type: "checker" | "text") => {
if (!issueOperations || disabled || !issue) return;
const { blocks } = extractIssueStructuredContent(issue.detail_layout, issue.description_html);
await issueOperations.update(workspaceSlug, issue.project_id ?? projectId, issueId, {
detail_layout: serializeIssueStructuredContent(issue.detail_layout, [createIssueStructuredBlock(type), ...blocks]),
});
};
return (
<>
<div className="flex flex-col space-y-4">
@@ -50,7 +74,19 @@ export function IssueDetailWidgets(props: Props) {
issueServiceType={issueServiceType}
hideWidgets={hideWidgetsWithInlineAttachments}
compactView={compactView}
onOpenChecker={issueOperations ? () => addStructuredBlock("checker") : undefined}
onOpenTextBlock={issueOperations ? () => addStructuredBlock("text") : undefined}
/>
{issueOperations && (
<IssueStructuredContentBlocks
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
issueOperations={issueOperations}
/>
)}
{!hideWidgets?.includes("attachments") && (
<IssueAttachmentsCollapsibleContent
workspaceSlug={workspaceSlug}
@@ -0,0 +1,224 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useMemo, useState } from "react";
import { Check, MoreHorizontal, Plus, Trash2 } from "lucide-react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import type { TIssueOperations } from "../issue-detail/root";
import {
createIssueStructuredCheckerItem,
extractIssueStructuredContent,
serializeIssueStructuredContent,
type TIssueStructuredBlock,
} from "./structured-content.helpers";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType: TIssueServiceType;
issueOperations: TIssueOperations;
};
export const IssueStructuredContentBlocks = observer(function IssueStructuredContentBlocks(props: Props) {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, issueOperations } = props;
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
const issue = getIssueById(issueId);
const parsedContent = useMemo(
() => extractIssueStructuredContent(issue?.detail_layout, issue?.description_html),
[issue?.description_html, issue?.detail_layout]
);
const [draftBlocks, setDraftBlocks] = useState<TIssueStructuredBlock[]>(parsedContent.blocks);
useEffect(() => {
setDraftBlocks(parsedContent.blocks);
}, [parsedContent.blocks]);
if (!issue || parsedContent.blocks.length === 0) return null;
const saveBlocks = async (nextBlocks: TIssueStructuredBlock[]) => {
setDraftBlocks(nextBlocks);
await issueOperations.update(workspaceSlug, issue.project_id ?? projectId, issueId, {
detail_layout: serializeIssueStructuredContent(issue.detail_layout, nextBlocks),
});
};
const updateBlockDraft = (blockId: string, patch: Partial<TIssueStructuredBlock>) => {
setDraftBlocks((currentBlocks) =>
currentBlocks.map((block) => (block.id === blockId ? ({ ...block, ...patch } as TIssueStructuredBlock) : block))
);
};
const getLatestBlock = (blockId: string) => draftBlocks.find((block) => block.id === blockId);
const removeBlock = (blockId: string) => saveBlocks(draftBlocks.filter((block) => block.id !== blockId));
const getBlockMenuItems = (blockId: string): TContextMenuItem[] => [
{
key: "delete",
title: "Удалить блок",
icon: Trash2,
action: () => removeBlock(blockId),
},
];
return (
<div className="nodedc-structured-inline-stack">
{draftBlocks.map((block) => {
if (block.type === "text") {
return (
<section key={block.id} className="nodedc-structured-inline-block">
<div className="nodedc-structured-inline-header nodedc-structured-inline-header--menu-only">
<ActionDropdown
items={getBlockMenuItems(block.id)}
button={<MoreHorizontal className="h-4 w-4" />}
buttonClassName="nodedc-structured-menu-button"
placement="bottom-end"
disabled={disabled}
/>
</div>
<div className="space-y-3">
<input
type="text"
value={block.title}
onChange={(event) => updateBlockDraft(block.id, { title: event.target.value })}
onBlur={() => {
const latestBlock = getLatestBlock(block.id);
if (latestBlock) saveBlocks(draftBlocks.map((item) => (item.id === block.id ? latestBlock : item)));
}}
placeholder="Заголовок"
className="nodedc-modal-input h-11 w-full px-4 text-13 text-primary placeholder:text-placeholder"
disabled={disabled}
/>
<textarea
value={block.body}
onChange={(event) => updateBlockDraft(block.id, { body: event.target.value })}
onBlur={() => {
const latestBlock = getLatestBlock(block.id);
if (latestBlock) saveBlocks(draftBlocks.map((item) => (item.id === block.id ? latestBlock : item)));
}}
placeholder="Текст"
className="nodedc-modal-input min-h-28 w-full resize-y px-4 py-3 text-13 text-primary placeholder:text-placeholder"
disabled={disabled}
/>
</div>
</section>
);
}
return (
<section key={block.id} className="nodedc-structured-inline-block">
<div className="nodedc-structured-inline-header nodedc-structured-inline-header--menu-only">
<ActionDropdown
items={getBlockMenuItems(block.id)}
button={<MoreHorizontal className="h-4 w-4" />}
buttonClassName="nodedc-structured-menu-button"
placement="bottom-end"
disabled={disabled}
/>
</div>
<div className="nodedc-structured-checklist">
{block.items.map((item, index) => (
<div key={item.id} className="nodedc-structured-check-row">
<button
type="button"
className={cn("nodedc-structured-check-dot", item.checked && "is-checked")}
onClick={() => {
const nextBlocks = draftBlocks.map((draftBlock) =>
draftBlock.id === block.id && draftBlock.type === "checker"
? {
...draftBlock,
items: draftBlock.items.map((draftItem) =>
draftItem.id === item.id ? { ...draftItem, checked: !draftItem.checked } : draftItem
),
}
: draftBlock
);
saveBlocks(nextBlocks);
}}
aria-label="Отметить пункт"
disabled={disabled}
>
{item.checked && <Check className="h-3.5 w-3.5" />}
</button>
<input
type="text"
value={item.text}
onChange={(event) =>
setDraftBlocks((currentBlocks) =>
currentBlocks.map((draftBlock) =>
draftBlock.id === block.id && draftBlock.type === "checker"
? {
...draftBlock,
items: draftBlock.items.map((draftItem) =>
draftItem.id === item.id ? { ...draftItem, text: event.target.value } : draftItem
),
}
: draftBlock
)
)
}
onBlur={() => saveBlocks(draftBlocks)}
placeholder={`Пункт ${index + 1}`}
className="nodedc-modal-input h-11 flex-1 px-4 text-13 text-primary placeholder:text-placeholder"
disabled={disabled}
/>
<button
type="button"
className="nodedc-structured-delete"
onClick={() => {
const nextBlocks = draftBlocks.map((draftBlock) =>
draftBlock.id === block.id && draftBlock.type === "checker"
? { ...draftBlock, items: draftBlock.items.filter((draftItem) => draftItem.id !== item.id) }
: draftBlock
);
saveBlocks(nextBlocks);
}}
aria-label="Удалить пункт"
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
<button
type="button"
className="nodedc-structured-add-zone"
onClick={() => {
const nextBlocks = draftBlocks.map((draftBlock) =>
draftBlock.id === block.id && draftBlock.type === "checker"
? { ...draftBlock, items: [...draftBlock.items, createIssueStructuredCheckerItem()] }
: draftBlock
);
saveBlocks(nextBlocks);
}}
disabled={disabled}
>
<span className="nodedc-structured-add-icon">
<Plus className="h-4 w-4" />
</span>
<span className="text-left text-13 font-medium text-primary">Добавить пункт чекера</span>
</button>
</section>
);
})}
</div>
);
});
@@ -0,0 +1,157 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
const MARKER_REGEX = /<!--NODEDC_STRUCTURED_BLOCKS:([\s\S]*?):NODEDC_STRUCTURED_BLOCKS-->/;
const DETAIL_LAYOUT_BLOCKS_KEY = "nodedc_structured_blocks";
export type TIssueStructuredTextBlock = {
id: string;
type: "text";
title: string;
body: string;
};
export type TIssueStructuredCheckerItem = {
id: string;
text: string;
checked: boolean;
};
export type TIssueStructuredCheckerBlock = {
id: string;
type: "checker";
items: TIssueStructuredCheckerItem[];
};
export type TIssueStructuredBlock = TIssueStructuredTextBlock | TIssueStructuredCheckerBlock;
export type TIssueDetailLayout = Record<string, unknown> & {
[DETAIL_LAYOUT_BLOCKS_KEY]?: TIssueStructuredBlock[];
};
const createLocalId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const normalizeDescriptionBody = (value: string | null | undefined) => {
const cleanValue = `${value ?? ""}`.replace(MARKER_REGEX, "").trim();
return cleanValue || "<p></p>";
};
const normalizeDetailLayout = (value: unknown): TIssueDetailLayout =>
isRecord(value) ? ({ ...value } as TIssueDetailLayout) : {};
const sanitizeBlocks = (value: unknown): TIssueStructuredBlock[] => {
if (!Array.isArray(value)) return [];
return value.flatMap((block) => {
if (!isRecord(block)) return [];
if (block.type === "text") {
return [
{
id: typeof block.id === "string" ? block.id : createLocalId(),
type: "text",
title: typeof block.title === "string" ? block.title : "",
body: typeof block.body === "string" ? block.body : "",
},
];
}
if (block.type === "checker") {
const items = Array.isArray(block.items)
? block.items.flatMap((item) => {
if (!isRecord(item)) return [];
return [
{
id: typeof item.id === "string" ? item.id : createLocalId(),
text: typeof item.text === "string" ? item.text : "",
checked: Boolean(item.checked),
},
];
})
: [];
return [
{
id: typeof block.id === "string" ? block.id : createLocalId(),
type: "checker",
items,
},
];
}
return [];
});
};
export const createIssueStructuredBlock = (type: "checker" | "text"): TIssueStructuredBlock => {
if (type === "checker") {
return {
id: createLocalId(),
type: "checker",
items: [{ id: createLocalId(), text: "", checked: false }],
};
}
return {
id: createLocalId(),
type: "text",
title: "",
body: "",
};
};
export const createIssueStructuredCheckerItem = (): TIssueStructuredCheckerItem => ({
id: createLocalId(),
text: "",
checked: false,
});
const extractLegacyDescriptionBlocks = (descriptionHtml: string | null | undefined) => {
const value = `${descriptionHtml ?? ""}`;
const markerMatch = value.match(MARKER_REGEX);
if (!markerMatch?.[1]) return [] as TIssueStructuredBlock[];
try {
const parsedBlocks = JSON.parse(decodeURIComponent(markerMatch[1]));
return sanitizeBlocks(parsedBlocks);
} catch {
return [] as TIssueStructuredBlock[];
}
};
export const extractIssueStructuredContent = (
detailLayout: unknown,
descriptionHtml: string | null | undefined
) => {
const layout = normalizeDetailLayout(detailLayout);
const hasPersistedBlocks = Object.prototype.hasOwnProperty.call(layout, DETAIL_LAYOUT_BLOCKS_KEY);
const persistedBlocks = sanitizeBlocks(layout[DETAIL_LAYOUT_BLOCKS_KEY]);
return {
bodyHtml: normalizeDescriptionBody(descriptionHtml),
detailLayout: layout,
blocks: hasPersistedBlocks ? persistedBlocks : extractLegacyDescriptionBlocks(descriptionHtml),
};
};
export const serializeIssueStructuredContent = (detailLayout: unknown, blocks: TIssueStructuredBlock[]) => {
const layout = normalizeDetailLayout(detailLayout);
layout[DETAIL_LAYOUT_BLOCKS_KEY] = blocks;
return layout;
};
export const mergeIssueDescriptionWithStructuredBlocks = (
nextBodyHtml: string | null | undefined,
_currentDescriptionHtml?: string | null | undefined
) => normalizeDescriptionBody(nextBodyHtml);
@@ -5,6 +5,7 @@
*/
import React from "react";
import { ListChecks, TextCursorInput } from "lucide-react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
@@ -21,10 +22,19 @@ type Props = {
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
onOpenChecker?: () => void;
onOpenTextBlock?: () => void;
};
export const SubIssuesActionButton = observer(function SubIssuesActionButton(props: Props) {
const { issueId, customButton, disabled = false, issueServiceType } = props;
const {
issueId,
customButton,
disabled = false,
issueServiceType,
onOpenChecker,
onOpenTextBlock,
} = props;
// translation
const { t } = useTranslation();
// store hooks
@@ -69,15 +79,29 @@ export const SubIssuesActionButton = observer(function SubIssuesActionButton(pro
// options
const optionItems: TContextMenuItem[] = [
{
key: "create-text-block",
title: "Создать текстовый блок",
icon: TextCursorInput,
action: () => onOpenTextBlock?.(),
shouldRender: Boolean(onOpenTextBlock),
},
{
key: "create-checker",
title: "Создать чекер",
icon: ListChecks,
action: () => onOpenChecker?.(),
shouldRender: Boolean(onOpenChecker),
},
{
key: "create-new",
title: t("common.create_new"),
title: "Создать новую подзадачу",
icon: PlusIcon,
action: handleCreateNew,
},
{
key: "add-existing",
title: t("common.add_existing"),
title: "Добавить существующую подзадачу",
icon: WorkItemsIcon,
action: handleAddExisting,
},
@@ -76,14 +76,14 @@ export const IssueLinkCreateUpdateModal = observer(function IssueLinkCreateUpdat
return (
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<form className="nodedc-link-modal" onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-5 p-5">
<h3 className="text-h4-medium text-secondary">
{preloadedData?.id ? t("common.update_link") : t("common.add_link")}
</h3>
<div className="mt-2 space-y-3">
<div>
<label htmlFor="url" className="mb-2 text-secondary">
<label htmlFor="url" className="mb-2 block text-secondary">
{t("common.url")}
</label>
<Controller
@@ -101,7 +101,7 @@ export const IssueLinkCreateUpdateModal = observer(function IssueLinkCreateUpdat
ref={ref}
hasError={Boolean(errors.url)}
placeholder={t("common.type_or_paste_a_url")}
className="w-full"
className="nodedc-modal-input h-10 w-full px-4"
/>
)}
/>
@@ -110,9 +110,9 @@ export const IssueLinkCreateUpdateModal = observer(function IssueLinkCreateUpdat
)}
</div>
<div>
<label htmlFor="title" className="mb-2 text-secondary">
<label htmlFor="title" className="mb-3 block text-secondary">
{t("common.display_title")}
<span className="block text-caption-xs-regular">{t("common.optional")}</span>
<span className="mt-1 block text-caption-xs-regular">{t("common.optional")}</span>
</label>
<Controller
control={control}
@@ -126,18 +126,18 @@ export const IssueLinkCreateUpdateModal = observer(function IssueLinkCreateUpdat
ref={ref}
hasError={Boolean(errors.title)}
placeholder={t("common.link_title_placeholder")}
className="w-full"
className="nodedc-modal-input h-10 w-full px-4"
/>
)}
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-subtle px-5 py-4">
<Button variant="secondary" size="lg" onClick={onClose}>
<div className="flex items-center justify-end gap-2 px-5 pb-5 pt-3">
<Button variant="secondary" size="lg" className="nodedc-modal-secondary-button" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="lg" type="submit" loading={isSubmitting}>
<Button variant="primary" size="lg" className="nodedc-modal-primary-button" type="submit" loading={isSubmitting}>
{`${
preloadedData?.id
? isSubmitting
@@ -29,6 +29,10 @@ import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-dup
import { WorkItemVersionService } from "@/services/issue";
// local imports
import { IssueDetailWidgets } from "../issue-detail-widgets";
import {
extractIssueStructuredContent,
mergeIssueDescriptionWithStructuredBlocks,
} from "../issue-detail-widgets/structured-content.helpers";
import { NameDescriptionUpdateStatus } from "../issue-update-status";
import { PeekOverviewProperties } from "../peek-overview/properties";
import { IssueTitleInput } from "../title-input";
@@ -67,6 +71,7 @@ export const IssueMainContent = observer(function IssueMainContent(props: Props)
// derived values
const projectDetails = getProjectById(projectId);
const issue = issueId ? getIssueById(issueId) : undefined;
const issueDescription = extractIssueStructuredContent(issue?.detail_layout, issue?.description_html).bodyHtml;
// debounced duplicate issues swr
const { duplicateIssues } = useDebouncedDuplicateIssues(
workspaceSlug,
@@ -139,12 +144,12 @@ export const IssueMainContent = observer(function IssueMainContent(props: Props)
editorRef={editorRef}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={issue.description_html}
initialValue={issueDescription}
key={issue.id}
onSubmit={async (value, isMigrationUpdate) => {
if (!issue.id || !issue.project_id) return;
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
description_html: value.description_html,
description_html: mergeIssueDescriptionWithStructuredBlocks(value.description_html, issue.description_html),
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
});
}}
@@ -191,6 +196,7 @@ export const IssueMainContent = observer(function IssueMainContent(props: Props)
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isEditable || isArchived}
renderWidgetModals={!isPeekModeActive}
issueServiceType={EIssueServiceType.ISSUES}
@@ -32,6 +32,10 @@ import { WorkItemVersionService } from "@/services/issue";
import type { TIssueOperations } from "../issue-detail";
import { IssueParentDetail } from "../issue-detail/parent";
import { IssueReaction } from "../issue-detail/reactions";
import {
extractIssueStructuredContent,
mergeIssueDescriptionWithStructuredBlocks,
} from "../issue-detail-widgets/structured-content.helpers";
import { IssueTitleInput } from "../title-input";
// services init
const workItemVersionService = new WorkItemVersionService();
@@ -89,12 +93,7 @@ export const PeekOverviewIssueDetails = observer(function PeekOverviewIssueDetai
if (!issue || !issue.project_id) return <></>;
const issueDescription =
issue.description_html !== undefined || issue.description_html !== null
? issue.description_html != ""
? issue.description_html
: "<p></p>"
: undefined;
const issueDescription = extractIssueStructuredContent(issue.detail_layout, issue.description_html).bodyHtml;
return (
<div className="space-y-2">
@@ -143,7 +142,7 @@ export const PeekOverviewIssueDetails = observer(function PeekOverviewIssueDetai
onSubmit={async (value, isMigrationUpdate) => {
if (!issue.id || !issue.project_id) return;
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
description_html: value.description_html,
description_html: mergeIssueDescriptionWithStructuredBlocks(value.description_html, issue.description_html),
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
});
}}
@@ -345,6 +345,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
issueId={issueId}
disabled={disabled || is_archived}
compactView
issueOperations={issueOperations}
issueServiceType={EIssueServiceType.ISSUES}
/>
</div>
@@ -387,6 +388,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueOperations={issueOperations}
issueServiceType={EIssueServiceType.ISSUES}
/>
</div>
@@ -145,6 +145,7 @@ export class IssueStore implements IIssueStore {
sequence_id: issue?.sequence_id,
name: issue?.name,
description_html: issue?.description_html,
detail_layout: issue?.detail_layout,
sort_order: issue?.sort_order,
state_id: issue?.state_id,
priority: issue?.priority,