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} + /> +