From eac010d3d4a930278c883c2ea5cb2a9297a68be5 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Apr 2026 18:06:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A5=20-=20=D0=9C=D0=95=D0=96?= =?UTF-8?q?=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A?= =?UTF-8?q?=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98?= =?UTF-8?q?=D0=AF:=20=D1=83=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D0=B5=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20layout-=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=B2=20detail=5Flayout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDESIGN-CODE.md | 12 + .../apps/api/plane/app/serializers/issue.py | 1 + .../db/migrations/0127_issue_detail_layout.py | 18 ++ plane-src/apps/api/plane/db/models/issue.py | 1 + .../issue-detail-widgets/action-buttons.tsx | 16 +- .../issues/issue-detail-widgets/root.tsx | 36 +++ .../structured-content-blocks.tsx | 224 ++++++++++++++ .../structured-content.helpers.ts | 157 ++++++++++ .../sub-issues/quick-action-button.tsx | 30 +- .../links/create-update-link-modal.tsx | 18 +- .../issues/issue-detail/main-content.tsx | 10 +- .../issues/peek-overview/issue-detail.tsx | 13 +- .../components/issues/peek-overview/view.tsx | 2 + .../store/issue/issue-details/issue.store.ts | 1 + plane-src/apps/web/styles/globals.css | 289 +++++++++++------- plane-src/packages/types/src/issues/issue.ts | 1 + 16 files changed, 693 insertions(+), 136 deletions(-) create mode 100644 plane-src/apps/api/plane/db/migrations/0127_issue_detail_layout.py create mode 100644 plane-src/apps/web/core/components/issues/issue-detail-widgets/structured-content-blocks.tsx create mode 100644 plane-src/apps/web/core/components/issues/issue-detail-widgets/structured-content.helpers.ts 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} + /> +