diff --git a/HDESIGN-CODE.md b/HDESIGN-CODE.md
index 23963b2..f512472 100644
--- a/HDESIGN-CODE.md
+++ b/HDESIGN-CODE.md
@@ -46,6 +46,10 @@
## Кнопки
- Все кнопки без жёсткого outline.
+- Текстовые кнопки в модалках не сжимают текст:
+ - минимальный горизонтальный отступ от текста до края кнопки: `10px`
+ - для CTA предпочтительно использовать общий padding `1.25rem` или больше
+ - текст не должен визуально прилипать к радиусу кнопки
- Primary button:
- фон: акцентный или `active_card_rgb`
- текст: определяется автоматически по контрасту заливки
@@ -89,6 +93,14 @@
- круг на мягком `white/10`
- без внешнего outline и без синей browser-рамки
- Текстовый статус рядом с checker может дублировать состояние, но сам визуальный якорь должен оставаться круглым, а не квадратным checkbox.
+- В деталях задачи структурные блоки создаются из меню `Добавить подэлемент` прямо в карточке, без отдельной модалки:
+ - порядок меню: `Создать текстовый блок`, `Создать чекер`, `Создать новую подзадачу`, `Добавить существующую подзадачу`
+ - текстовый блок содержит два поля: необязательный заголовок и текст
+ - чекер отображается без внешней подложки и без заголовка: только строки с круглым checker-якорем и plus-зона добавления строки
+ - первые 10 строк чекера видны сразу, дальше включается внутренний скролл списка
+ - у каждого структурного блока справа есть меню `...` с удалением блока
+ - блок хранится в штатном JSON-поле задачи `detail_layout`, а не в `description_html`: описание проходит HTML-sanitizer и не должно нести layout-состояние
+ - `detail_layout` является частью самой задачи, поэтому кастомные поля мультиплеерны и восстанавливаются после закрытия/повторного открытия карточки
## Toolbar и верхние панели
- Элементы верхней панели центрируются по одной горизонтальной оси.
diff --git a/plane-src/apps/api/plane/app/serializers/issue.py b/plane-src/apps/api/plane/app/serializers/issue.py
index 261ef76..4600d1d 100644
--- a/plane-src/apps/api/plane/app/serializers/issue.py
+++ b/plane-src/apps/api/plane/app/serializers/issue.py
@@ -941,6 +941,7 @@ class IssueDetailSerializer(IssueSerializer):
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
+ "detail_layout",
"is_subscribed",
"is_intake",
]
diff --git a/plane-src/apps/api/plane/db/migrations/0127_issue_detail_layout.py b/plane-src/apps/api/plane/db/migrations/0127_issue_detail_layout.py
new file mode 100644
index 0000000..fb51fb3
--- /dev/null
+++ b/plane-src/apps/api/plane/db/migrations/0127_issue_detail_layout.py
@@ -0,0 +1,18 @@
+# Generated by Codex on 2026-04-25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("db", "0126_workspace_storage_limits"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="issue",
+ name="detail_layout",
+ field=models.JSONField(blank=True, default=dict),
+ ),
+ ]
diff --git a/plane-src/apps/api/plane/db/models/issue.py b/plane-src/apps/api/plane/db/models/issue.py
index d24efc8..3e0ed94 100644
--- a/plane-src/apps/api/plane/db/models/issue.py
+++ b/plane-src/apps/api/plane/db/models/issue.py
@@ -134,6 +134,7 @@ class Issue(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="Issue Name")
description_json = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="
")
+ detail_layout = models.JSONField(blank=True, default=dict)
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
priority = models.CharField(
diff --git a/plane-src/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx b/plane-src/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx
index 815df3b..59c2a26 100644
--- a/plane-src/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx
+++ b/plane-src/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx
@@ -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") && (
diff --git a/plane-src/apps/web/core/components/issues/issue-detail-widgets/root.tsx b/plane-src/apps/web/core/components/issues/issue-detail-widgets/root.tsx
index 8b091c5..24aaaf3 100644
--- a/plane-src/apps/web/core/components/issues/issue-detail-widgets/root.tsx
+++ b/plane-src/apps/web/core/components/issues/issue-detail-widgets/root.tsx
@@ -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 (
<>
@@ -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 && (
+
+ )}
{!hideWidgets?.includes("attachments") && (
extractIssueStructuredContent(issue?.detail_layout, issue?.description_html),
+ [issue?.description_html, issue?.detail_layout]
+ );
+ const [draftBlocks, setDraftBlocks] = useState(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) => {
+ 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 (
+
+ {draftBlocks.map((block) => {
+ if (block.type === "text") {
+ return (
+
+
+
}
+ buttonClassName="nodedc-structured-menu-button"
+ placement="bottom-end"
+ disabled={disabled}
+ />
+
+
+ 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}
+ />
+
+
+ );
+ }
+
+ return (
+
+
+
}
+ buttonClassName="nodedc-structured-menu-button"
+ placement="bottom-end"
+ disabled={disabled}
+ />
+
+
+
+ {block.items.map((item, index) => (
+
+ {
+ 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 && }
+
+
+ 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}
+ />
+ {
+ 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}
+ >
+
+
+
+ ))}
+
+
+ {
+ const nextBlocks = draftBlocks.map((draftBlock) =>
+ draftBlock.id === block.id && draftBlock.type === "checker"
+ ? { ...draftBlock, items: [...draftBlock.items, createIssueStructuredCheckerItem()] }
+ : draftBlock
+ );
+ saveBlocks(nextBlocks);
+ }}
+ disabled={disabled}
+ >
+
+
+
+ Добавить пункт чекера
+
+
+ );
+ })}
+
+ );
+});
diff --git a/plane-src/apps/web/core/components/issues/issue-detail-widgets/structured-content.helpers.ts b/plane-src/apps/web/core/components/issues/issue-detail-widgets/structured-content.helpers.ts
new file mode 100644
index 0000000..c489024
--- /dev/null
+++ b/plane-src/apps/web/core/components/issues/issue-detail-widgets/structured-content.helpers.ts
@@ -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 = //;
+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 & {
+ [DETAIL_LAYOUT_BLOCKS_KEY]?: TIssueStructuredBlock[];
+};
+
+const createLocalId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const normalizeDescriptionBody = (value: string | null | undefined) => {
+ const cleanValue = `${value ?? ""}`.replace(MARKER_REGEX, "").trim();
+
+ return cleanValue || "
";
+};
+
+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);
diff --git a/plane-src/apps/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx b/plane-src/apps/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx
index abb910b..edaccb8 100644
--- a/plane-src/apps/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx
+++ b/plane-src/apps/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx
@@ -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,
},
diff --git a/plane-src/apps/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx b/plane-src/apps/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx
index ecc11dd..5ecc5be 100644
--- a/plane-src/apps/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx
+++ b/plane-src/apps/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx
@@ -76,14 +76,14 @@ export const IssueLinkCreateUpdateModal = observer(function IssueLinkCreateUpdat
return (
-