АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: устойчивое хранение layout-блоков карточки в detail_layout
This commit is contained in:
parent
4ed63cac4e
commit
eac010d3d4
|
|
@ -46,6 +46,10 @@
|
||||||
|
|
||||||
## Кнопки
|
## Кнопки
|
||||||
- Все кнопки без жёсткого outline.
|
- Все кнопки без жёсткого outline.
|
||||||
|
- Текстовые кнопки в модалках не сжимают текст:
|
||||||
|
- минимальный горизонтальный отступ от текста до края кнопки: `10px`
|
||||||
|
- для CTA предпочтительно использовать общий padding `1.25rem` или больше
|
||||||
|
- текст не должен визуально прилипать к радиусу кнопки
|
||||||
- Primary button:
|
- Primary button:
|
||||||
- фон: акцентный или `active_card_rgb`
|
- фон: акцентный или `active_card_rgb`
|
||||||
- текст: определяется автоматически по контрасту заливки
|
- текст: определяется автоматически по контрасту заливки
|
||||||
|
|
@ -89,6 +93,14 @@
|
||||||
- круг на мягком `white/10`
|
- круг на мягком `white/10`
|
||||||
- без внешнего outline и без синей browser-рамки
|
- без внешнего outline и без синей browser-рамки
|
||||||
- Текстовый статус рядом с checker может дублировать состояние, но сам визуальный якорь должен оставаться круглым, а не квадратным checkbox.
|
- Текстовый статус рядом с checker может дублировать состояние, но сам визуальный якорь должен оставаться круглым, а не квадратным checkbox.
|
||||||
|
- В деталях задачи структурные блоки создаются из меню `Добавить подэлемент` прямо в карточке, без отдельной модалки:
|
||||||
|
- порядок меню: `Создать текстовый блок`, `Создать чекер`, `Создать новую подзадачу`, `Добавить существующую подзадачу`
|
||||||
|
- текстовый блок содержит два поля: необязательный заголовок и текст
|
||||||
|
- чекер отображается без внешней подложки и без заголовка: только строки с круглым checker-якорем и plus-зона добавления строки
|
||||||
|
- первые 10 строк чекера видны сразу, дальше включается внутренний скролл списка
|
||||||
|
- у каждого структурного блока справа есть меню `...` с удалением блока
|
||||||
|
- блок хранится в штатном JSON-поле задачи `detail_layout`, а не в `description_html`: описание проходит HTML-sanitizer и не должно нести layout-состояние
|
||||||
|
- `detail_layout` является частью самой задачи, поэтому кастомные поля мультиплеерны и восстанавливаются после закрытия/повторного открытия карточки
|
||||||
|
|
||||||
## Toolbar и верхние панели
|
## Toolbar и верхние панели
|
||||||
- Элементы верхней панели центрируются по одной горизонтальной оси.
|
- Элементы верхней панели центрируются по одной горизонтальной оси.
|
||||||
|
|
|
||||||
|
|
@ -941,6 +941,7 @@ class IssueDetailSerializer(IssueSerializer):
|
||||||
class Meta(IssueSerializer.Meta):
|
class Meta(IssueSerializer.Meta):
|
||||||
fields = IssueSerializer.Meta.fields + [
|
fields = IssueSerializer.Meta.fields + [
|
||||||
"description_html",
|
"description_html",
|
||||||
|
"detail_layout",
|
||||||
"is_subscribed",
|
"is_subscribed",
|
||||||
"is_intake",
|
"is_intake",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -134,6 +134,7 @@ class Issue(ProjectBaseModel):
|
||||||
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||||
description_json = models.JSONField(blank=True, default=dict)
|
description_json = models.JSONField(blank=True, default=dict)
|
||||||
description_html = models.TextField(blank=True, default="<p></p>")
|
description_html = models.TextField(blank=True, default="<p></p>")
|
||||||
|
detail_layout = models.JSONField(blank=True, default=dict)
|
||||||
description_stripped = models.TextField(blank=True, null=True)
|
description_stripped = models.TextField(blank=True, null=True)
|
||||||
description_binary = models.BinaryField(null=True)
|
description_binary = models.BinaryField(null=True)
|
||||||
priority = models.CharField(
|
priority = models.CharField(
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,22 @@ type Props = {
|
||||||
issueServiceType: TIssueServiceType;
|
issueServiceType: TIssueServiceType;
|
||||||
hideWidgets?: TWorkItemWidgets[];
|
hideWidgets?: TWorkItemWidgets[];
|
||||||
compactView?: boolean;
|
compactView?: boolean;
|
||||||
|
onOpenChecker?: () => void;
|
||||||
|
onOpenTextBlock?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function IssueDetailWidgetActionButtons(props: Props) {
|
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
|
// translation
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -50,6 +62,8 @@ export function IssueDetailWidgetActionButtons(props: Props) {
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
issueServiceType={issueServiceType}
|
issueServiceType={issueServiceType}
|
||||||
|
onOpenChecker={onOpenChecker}
|
||||||
|
onOpenTextBlock={onOpenTextBlock}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!hideWidgets?.includes("relations") && (
|
{!hideWidgets?.includes("relations") && (
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,18 @@ import React from "react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||||
// local imports
|
// local imports
|
||||||
|
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||||
import { IssueAttachmentsCollapsibleContent } from "./attachments/content";
|
import { IssueAttachmentsCollapsibleContent } from "./attachments/content";
|
||||||
import { IssueDetailWidgetActionButtons } from "./action-buttons";
|
import { IssueDetailWidgetActionButtons } from "./action-buttons";
|
||||||
import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles";
|
import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles";
|
||||||
import { IssueDetailWidgetModals } from "./issue-detail-widget-modals";
|
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 = {
|
type Props = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
|
@ -22,6 +30,7 @@ type Props = {
|
||||||
issueServiceType: TIssueServiceType;
|
issueServiceType: TIssueServiceType;
|
||||||
hideWidgets?: TWorkItemWidgets[];
|
hideWidgets?: TWorkItemWidgets[];
|
||||||
compactView?: boolean;
|
compactView?: boolean;
|
||||||
|
issueOperations?: TIssueOperations;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function IssueDetailWidgets(props: Props) {
|
export function IssueDetailWidgets(props: Props) {
|
||||||
|
|
@ -34,11 +43,26 @@ export function IssueDetailWidgets(props: Props) {
|
||||||
issueServiceType,
|
issueServiceType,
|
||||||
hideWidgets,
|
hideWidgets,
|
||||||
compactView = false,
|
compactView = false,
|
||||||
|
issueOperations,
|
||||||
} = props;
|
} = props;
|
||||||
|
const {
|
||||||
|
issue: { getIssueById },
|
||||||
|
} = useIssueDetail(issueServiceType);
|
||||||
|
const issue = getIssueById(issueId);
|
||||||
const hideWidgetsWithInlineAttachments = hideWidgets?.includes("attachments")
|
const hideWidgetsWithInlineAttachments = hideWidgets?.includes("attachments")
|
||||||
? hideWidgets
|
? hideWidgets
|
||||||
: ([...(hideWidgets ?? []), "attachments"] as TWorkItemWidgets[]);
|
: ([...(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
|
|
@ -50,7 +74,19 @@ export function IssueDetailWidgets(props: Props) {
|
||||||
issueServiceType={issueServiceType}
|
issueServiceType={issueServiceType}
|
||||||
hideWidgets={hideWidgetsWithInlineAttachments}
|
hideWidgets={hideWidgetsWithInlineAttachments}
|
||||||
compactView={compactView}
|
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") && (
|
{!hideWidgets?.includes("attachments") && (
|
||||||
<IssueAttachmentsCollapsibleContent
|
<IssueAttachmentsCollapsibleContent
|
||||||
workspaceSlug={workspaceSlug}
|
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 React from "react";
|
||||||
|
import { ListChecks, TextCursorInput } from "lucide-react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
|
@ -21,10 +22,19 @@ type Props = {
|
||||||
customButton?: React.ReactNode;
|
customButton?: React.ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
issueServiceType: TIssueServiceType;
|
issueServiceType: TIssueServiceType;
|
||||||
|
onOpenChecker?: () => void;
|
||||||
|
onOpenTextBlock?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SubIssuesActionButton = observer(function SubIssuesActionButton(props: Props) {
|
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
|
// translation
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// store hooks
|
// store hooks
|
||||||
|
|
@ -69,15 +79,29 @@ export const SubIssuesActionButton = observer(function SubIssuesActionButton(pro
|
||||||
|
|
||||||
// options
|
// options
|
||||||
const optionItems: TContextMenuItem[] = [
|
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",
|
key: "create-new",
|
||||||
title: t("common.create_new"),
|
title: "Создать новую подзадачу",
|
||||||
icon: PlusIcon,
|
icon: PlusIcon,
|
||||||
action: handleCreateNew,
|
action: handleCreateNew,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "add-existing",
|
key: "add-existing",
|
||||||
title: t("common.add_existing"),
|
title: "Добавить существующую подзадачу",
|
||||||
icon: WorkItemsIcon,
|
icon: WorkItemsIcon,
|
||||||
action: handleAddExisting,
|
action: handleAddExisting,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -76,14 +76,14 @@ export const IssueLinkCreateUpdateModal = observer(function IssueLinkCreateUpdat
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
|
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
<form className="nodedc-link-modal" onSubmit={handleSubmit(handleFormSubmit)}>
|
||||||
<div className="space-y-5 p-5">
|
<div className="space-y-5 p-5">
|
||||||
<h3 className="text-h4-medium text-secondary">
|
<h3 className="text-h4-medium text-secondary">
|
||||||
{preloadedData?.id ? t("common.update_link") : t("common.add_link")}
|
{preloadedData?.id ? t("common.update_link") : t("common.add_link")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 space-y-3">
|
<div className="mt-2 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="url" className="mb-2 text-secondary">
|
<label htmlFor="url" className="mb-2 block text-secondary">
|
||||||
{t("common.url")}
|
{t("common.url")}
|
||||||
</label>
|
</label>
|
||||||
<Controller
|
<Controller
|
||||||
|
|
@ -101,7 +101,7 @@ export const IssueLinkCreateUpdateModal = observer(function IssueLinkCreateUpdat
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.url)}
|
hasError={Boolean(errors.url)}
|
||||||
placeholder={t("common.type_or_paste_a_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>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="title" className="mb-2 text-secondary">
|
<label htmlFor="title" className="mb-3 block text-secondary">
|
||||||
{t("common.display_title")}
|
{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>
|
</label>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
|
|
@ -126,18 +126,18 @@ export const IssueLinkCreateUpdateModal = observer(function IssueLinkCreateUpdat
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.title)}
|
hasError={Boolean(errors.title)}
|
||||||
placeholder={t("common.link_title_placeholder")}
|
placeholder={t("common.link_title_placeholder")}
|
||||||
className="w-full"
|
className="nodedc-modal-input h-10 w-full px-4"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-subtle px-5 py-4">
|
<div className="flex items-center justify-end gap-2 px-5 pb-5 pt-3">
|
||||||
<Button variant="secondary" size="lg" onClick={onClose}>
|
<Button variant="secondary" size="lg" className="nodedc-modal-secondary-button" onClick={onClose}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</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
|
preloadedData?.id
|
||||||
? isSubmitting
|
? isSubmitting
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-dup
|
||||||
import { WorkItemVersionService } from "@/services/issue";
|
import { WorkItemVersionService } from "@/services/issue";
|
||||||
// local imports
|
// local imports
|
||||||
import { IssueDetailWidgets } from "../issue-detail-widgets";
|
import { IssueDetailWidgets } from "../issue-detail-widgets";
|
||||||
|
import {
|
||||||
|
extractIssueStructuredContent,
|
||||||
|
mergeIssueDescriptionWithStructuredBlocks,
|
||||||
|
} from "../issue-detail-widgets/structured-content.helpers";
|
||||||
import { NameDescriptionUpdateStatus } from "../issue-update-status";
|
import { NameDescriptionUpdateStatus } from "../issue-update-status";
|
||||||
import { PeekOverviewProperties } from "../peek-overview/properties";
|
import { PeekOverviewProperties } from "../peek-overview/properties";
|
||||||
import { IssueTitleInput } from "../title-input";
|
import { IssueTitleInput } from "../title-input";
|
||||||
|
|
@ -67,6 +71,7 @@ export const IssueMainContent = observer(function IssueMainContent(props: Props)
|
||||||
// derived values
|
// derived values
|
||||||
const projectDetails = getProjectById(projectId);
|
const projectDetails = getProjectById(projectId);
|
||||||
const issue = issueId ? getIssueById(issueId) : undefined;
|
const issue = issueId ? getIssueById(issueId) : undefined;
|
||||||
|
const issueDescription = extractIssueStructuredContent(issue?.detail_layout, issue?.description_html).bodyHtml;
|
||||||
// debounced duplicate issues swr
|
// debounced duplicate issues swr
|
||||||
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
const { duplicateIssues } = useDebouncedDuplicateIssues(
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
|
|
@ -139,12 +144,12 @@ export const IssueMainContent = observer(function IssueMainContent(props: Props)
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
entityId={issue.id}
|
entityId={issue.id}
|
||||||
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
|
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
|
||||||
initialValue={issue.description_html}
|
initialValue={issueDescription}
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
onSubmit={async (value, isMigrationUpdate) => {
|
onSubmit={async (value, isMigrationUpdate) => {
|
||||||
if (!issue.id || !issue.project_id) return;
|
if (!issue.id || !issue.project_id) return;
|
||||||
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
|
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" } : {}),
|
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
@ -191,6 +196,7 @@ export const IssueMainContent = observer(function IssueMainContent(props: Props)
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
|
issueOperations={issueOperations}
|
||||||
disabled={!isEditable || isArchived}
|
disabled={!isEditable || isArchived}
|
||||||
renderWidgetModals={!isPeekModeActive}
|
renderWidgetModals={!isPeekModeActive}
|
||||||
issueServiceType={EIssueServiceType.ISSUES}
|
issueServiceType={EIssueServiceType.ISSUES}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ import { WorkItemVersionService } from "@/services/issue";
|
||||||
import type { TIssueOperations } from "../issue-detail";
|
import type { TIssueOperations } from "../issue-detail";
|
||||||
import { IssueParentDetail } from "../issue-detail/parent";
|
import { IssueParentDetail } from "../issue-detail/parent";
|
||||||
import { IssueReaction } from "../issue-detail/reactions";
|
import { IssueReaction } from "../issue-detail/reactions";
|
||||||
|
import {
|
||||||
|
extractIssueStructuredContent,
|
||||||
|
mergeIssueDescriptionWithStructuredBlocks,
|
||||||
|
} from "../issue-detail-widgets/structured-content.helpers";
|
||||||
import { IssueTitleInput } from "../title-input";
|
import { IssueTitleInput } from "../title-input";
|
||||||
// services init
|
// services init
|
||||||
const workItemVersionService = new WorkItemVersionService();
|
const workItemVersionService = new WorkItemVersionService();
|
||||||
|
|
@ -89,12 +93,7 @@ export const PeekOverviewIssueDetails = observer(function PeekOverviewIssueDetai
|
||||||
|
|
||||||
if (!issue || !issue.project_id) return <></>;
|
if (!issue || !issue.project_id) return <></>;
|
||||||
|
|
||||||
const issueDescription =
|
const issueDescription = extractIssueStructuredContent(issue.detail_layout, issue.description_html).bodyHtml;
|
||||||
issue.description_html !== undefined || issue.description_html !== null
|
|
||||||
? issue.description_html != ""
|
|
||||||
? issue.description_html
|
|
||||||
: "<p></p>"
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -143,7 +142,7 @@ export const PeekOverviewIssueDetails = observer(function PeekOverviewIssueDetai
|
||||||
onSubmit={async (value, isMigrationUpdate) => {
|
onSubmit={async (value, isMigrationUpdate) => {
|
||||||
if (!issue.id || !issue.project_id) return;
|
if (!issue.id || !issue.project_id) return;
|
||||||
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
|
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" } : {}),
|
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
disabled={disabled || is_archived}
|
disabled={disabled || is_archived}
|
||||||
compactView
|
compactView
|
||||||
|
issueOperations={issueOperations}
|
||||||
issueServiceType={EIssueServiceType.ISSUES}
|
issueServiceType={EIssueServiceType.ISSUES}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -387,6 +388,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
issueOperations={issueOperations}
|
||||||
issueServiceType={EIssueServiceType.ISSUES}
|
issueServiceType={EIssueServiceType.ISSUES}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ export class IssueStore implements IIssueStore {
|
||||||
sequence_id: issue?.sequence_id,
|
sequence_id: issue?.sequence_id,
|
||||||
name: issue?.name,
|
name: issue?.name,
|
||||||
description_html: issue?.description_html,
|
description_html: issue?.description_html,
|
||||||
|
detail_layout: issue?.detail_layout,
|
||||||
sort_order: issue?.sort_order,
|
sort_order: issue?.sort_order,
|
||||||
state_id: issue?.state_id,
|
state_id: issue?.state_id,
|
||||||
priority: issue?.priority,
|
priority: issue?.priority,
|
||||||
|
|
|
||||||
|
|
@ -227,134 +227,45 @@
|
||||||
|
|
||||||
.nodedc-work-item-card,
|
.nodedc-work-item-card,
|
||||||
.nodedc-external-card {
|
.nodedc-external-card {
|
||||||
--nodedc-priority-card-rgb: var(--nodedc-priority-none-rgb);
|
|
||||||
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-none-mix-rgb);
|
|
||||||
--nodedc-priority-card-border-opacity: 0.08;
|
|
||||||
--nodedc-priority-card-fill-opacity: 0;
|
|
||||||
--nodedc-priority-card-glow-opacity: 0;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
|
border: 0 !important;
|
||||||
|
outline: none !important;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.006) 100%),
|
||||||
|
rgb(var(--nodedc-card-passive-rgb)) !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-border-opacity)),
|
0 14px 34px rgba(0, 0, 0, 0.24),
|
||||||
0 12px 28px rgba(0, 0, 0, 0.2),
|
|
||||||
0 0 18px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-glow-opacity)),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
|
inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-work-item-card[data-priority="low"],
|
|
||||||
.nodedc-external-card[data-priority="low"] {
|
|
||||||
--nodedc-priority-card-rgb: var(--nodedc-priority-low-rgb);
|
|
||||||
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-low-mix-rgb);
|
|
||||||
--nodedc-priority-card-border-opacity: 0.42;
|
|
||||||
--nodedc-priority-card-fill-opacity: 0.46;
|
|
||||||
--nodedc-priority-card-glow-opacity: 0.12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-work-item-card[data-priority="medium"],
|
|
||||||
.nodedc-external-card[data-priority="medium"] {
|
|
||||||
--nodedc-priority-card-rgb: var(--nodedc-priority-medium-rgb);
|
|
||||||
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-medium-mix-rgb);
|
|
||||||
--nodedc-priority-card-border-opacity: 0.46;
|
|
||||||
--nodedc-priority-card-fill-opacity: 0.5;
|
|
||||||
--nodedc-priority-card-glow-opacity: 0.13;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-work-item-card[data-priority="high"],
|
|
||||||
.nodedc-external-card[data-priority="high"] {
|
|
||||||
--nodedc-priority-card-rgb: var(--nodedc-priority-high-rgb);
|
|
||||||
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-high-mix-rgb);
|
|
||||||
--nodedc-priority-card-border-opacity: 0.5;
|
|
||||||
--nodedc-priority-card-fill-opacity: 0.54;
|
|
||||||
--nodedc-priority-card-glow-opacity: 0.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-work-item-card[data-priority="urgent"],
|
|
||||||
.nodedc-external-card[data-priority="urgent"] {
|
|
||||||
--nodedc-priority-card-rgb: var(--nodedc-priority-urgent-rgb);
|
|
||||||
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-urgent-mix-rgb);
|
|
||||||
--nodedc-priority-card-border-opacity: 0.62;
|
|
||||||
--nodedc-priority-card-fill-opacity: 0.62;
|
|
||||||
--nodedc-priority-card-glow-opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-work-item-card[data-active="true"],
|
.nodedc-work-item-card[data-active="true"],
|
||||||
.nodedc-external-card[data-active="true"] {
|
.nodedc-external-card[data-active="true"] {
|
||||||
|
background: rgb(var(--nodedc-card-active-rgb)) !important;
|
||||||
|
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-border-opacity)),
|
|
||||||
0 14px 32px rgba(0, 0, 0, 0.22),
|
0 14px 32px rgba(0, 0, 0, 0.22),
|
||||||
0 0 20px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-glow-opacity)),
|
|
||||||
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.06) !important;
|
inset 0 1px 0 rgba(255, 255, 255, 0.06) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-work-item-card[data-active="true"] .text-primary,
|
||||||
|
.nodedc-work-item-card[data-active="true"] .text-secondary,
|
||||||
|
.nodedc-work-item-card[data-active="true"] .text-tertiary,
|
||||||
|
.nodedc-work-item-card[data-active="true"] .text-placeholder {
|
||||||
|
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-work-item-card::before,
|
.nodedc-work-item-card::before,
|
||||||
.nodedc-external-card::before {
|
.nodedc-external-card::before {
|
||||||
content: "";
|
content: none !important;
|
||||||
position: absolute;
|
display: none !important;
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
border-radius: inherit;
|
|
||||||
background:
|
|
||||||
radial-gradient(
|
|
||||||
120% 90% at 50% 118%,
|
|
||||||
rgb(var(--nodedc-priority-card-rgb) / 0.74) 0%,
|
|
||||||
rgb(var(--nodedc-priority-card-rgb) / 0.28) 30%,
|
|
||||||
transparent 64%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
105% 78% at 50% -26%,
|
|
||||||
rgb(var(--nodedc-priority-card-mix-rgb) / 0.3) 0%,
|
|
||||||
transparent 62%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
62% 70% at -18% 50%,
|
|
||||||
rgb(var(--nodedc-priority-card-rgb) / 0.24) 0%,
|
|
||||||
transparent 68%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
62% 70% at 118% 50%,
|
|
||||||
rgb(var(--nodedc-priority-card-mix-rgb) / 0.22) 0%,
|
|
||||||
transparent 68%
|
|
||||||
);
|
|
||||||
opacity: var(--nodedc-priority-card-fill-opacity);
|
|
||||||
mask: radial-gradient(72% 58% at 50% 50%, transparent 0%, transparent 52%, rgba(0, 0, 0, 0.42) 68%, #000 100%);
|
|
||||||
-webkit-mask: radial-gradient(
|
|
||||||
72% 58% at 50% 50%,
|
|
||||||
transparent 0%,
|
|
||||||
transparent 52%,
|
|
||||||
rgba(0, 0, 0, 0.42) 68%,
|
|
||||||
#000 100%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-work-item-card::after,
|
.nodedc-work-item-card::after,
|
||||||
.nodedc-external-card::after {
|
.nodedc-external-card::after {
|
||||||
content: "";
|
content: none !important;
|
||||||
position: absolute;
|
display: none !important;
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
padding: 1px;
|
|
||||||
pointer-events: none;
|
|
||||||
border-radius: inherit;
|
|
||||||
background:
|
|
||||||
linear-gradient(
|
|
||||||
145deg,
|
|
||||||
rgb(var(--nodedc-priority-card-rgb) / 0.82) 0%,
|
|
||||||
rgb(var(--nodedc-priority-card-mix-rgb) / 0.44) 32%,
|
|
||||||
rgba(255, 255, 255, 0.18) 48%,
|
|
||||||
rgb(var(--nodedc-priority-card-rgb) / 0.34) 100%
|
|
||||||
);
|
|
||||||
opacity: var(--nodedc-priority-card-border-opacity);
|
|
||||||
mask:
|
|
||||||
linear-gradient(#000 0 0) content-box,
|
|
||||||
linear-gradient(#000 0 0);
|
|
||||||
mask-composite: exclude;
|
|
||||||
-webkit-mask:
|
|
||||||
linear-gradient(#000 0 0) content-box,
|
|
||||||
linear-gradient(#000 0 0);
|
|
||||||
-webkit-mask-composite: xor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-work-item-card > *,
|
.nodedc-work-item-card > *,
|
||||||
|
|
@ -904,6 +815,11 @@
|
||||||
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
|
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-link-modal [data-slot="button"] {
|
||||||
|
min-width: 4.25rem;
|
||||||
|
padding-inline: 1.6rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-modal-primary-button,
|
.nodedc-modal-primary-button,
|
||||||
.nodedc-modal-danger-button,
|
.nodedc-modal-danger-button,
|
||||||
.nodedc-modal-primary-button *,
|
.nodedc-modal-primary-button *,
|
||||||
|
|
@ -933,6 +849,145 @@
|
||||||
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
|
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-inline-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-inline-block {
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-inline-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
color: var(--text-color-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-inline-header--menu-only {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
min-height: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-menu-button {
|
||||||
|
display: grid !important;
|
||||||
|
height: 1.8rem !important;
|
||||||
|
width: 1.8rem !important;
|
||||||
|
place-items: center !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 999px !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--text-color-secondary) !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-menu-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
color: var(--text-color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-add-zone {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 0.35rem 0.2rem;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-add-zone:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.035) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-add-icon {
|
||||||
|
display: grid;
|
||||||
|
height: 2rem;
|
||||||
|
width: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: rgb(var(--nodedc-accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-checklist {
|
||||||
|
display: flex;
|
||||||
|
max-height: 32.25rem;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-check-row {
|
||||||
|
display: flex;
|
||||||
|
min-height: 3rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-check-dot {
|
||||||
|
display: grid;
|
||||||
|
height: 1.55rem;
|
||||||
|
width: 1.55rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgb(var(--nodedc-on-accent-rgb));
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-check-dot.is-checked {
|
||||||
|
background: rgb(var(--nodedc-accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-delete {
|
||||||
|
display: grid;
|
||||||
|
height: 2.4rem;
|
||||||
|
width: 2.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent !important;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-structured-delete:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-glass-modal button.bg-danger-primary,
|
.nodedc-glass-modal button.bg-danger-primary,
|
||||||
.nodedc-glass-modal button.border-danger-strong {
|
.nodedc-glass-modal button.border-danger-strong {
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
|
|
@ -1518,7 +1573,6 @@
|
||||||
rgb(var(--nodedc-card-active-rgb)) !important;
|
rgb(var(--nodedc-card-active-rgb)) !important;
|
||||||
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.32),
|
|
||||||
0 12px 32px rgba(0, 0, 0, 0.16) !important;
|
0 12px 32px rgba(0, 0, 0, 0.16) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1535,22 +1589,29 @@
|
||||||
.nodedc-work-item-card,
|
.nodedc-work-item-card,
|
||||||
.nodedc-external-card {
|
.nodedc-external-card {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-border-opacity)),
|
|
||||||
0 12px 28px rgba(0, 0, 0, 0.2),
|
0 12px 28px rgba(0, 0, 0, 0.2),
|
||||||
0 0 18px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-glow-opacity)),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
|
inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-work-item-card[data-active="true"],
|
.nodedc-work-item-card[data-active="true"],
|
||||||
.nodedc-external-card[data-active="true"] {
|
.nodedc-external-card[data-active="true"] {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-border-opacity)),
|
|
||||||
0 14px 32px rgba(0, 0, 0, 0.22),
|
0 14px 32px rgba(0, 0, 0, 0.22),
|
||||||
0 0 20px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-glow-opacity)),
|
|
||||||
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.06) !important;
|
inset 0 1px 0 rgba(255, 255, 255, 0.06) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-work-item-card[data-active="true"] {
|
||||||
|
background: rgb(var(--nodedc-card-active-rgb)) !important;
|
||||||
|
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-work-item-card[data-active="true"] .text-primary,
|
||||||
|
.nodedc-work-item-card[data-active="true"] .text-secondary,
|
||||||
|
.nodedc-work-item-card[data-active="true"] .text-tertiary,
|
||||||
|
.nodedc-work-item-card[data-active="true"] .text-placeholder {
|
||||||
|
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-external-content-shell {
|
.nodedc-external-content-shell {
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ type IssueRelation = {
|
||||||
|
|
||||||
export type TIssue = TBaseIssue & {
|
export type TIssue = TBaseIssue & {
|
||||||
description_html?: string;
|
description_html?: string;
|
||||||
|
detail_layout?: Record<string, unknown> | null;
|
||||||
is_subscribed?: boolean;
|
is_subscribed?: boolean;
|
||||||
created_by_avatar_url?: string | null;
|
created_by_avatar_url?: string | null;
|
||||||
created_by_detail?: Pick<IUserLite, "id" | "display_name" | "avatar_url"> | null;
|
created_by_detail?: Pick<IUserLite, "id" | "display_name" | "avatar_url"> | null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue