UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: quick-add, composer внешнего контура и range-календарь

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 19:22:25 +03:00
parent 85bd24c45b
commit 312fc1eca4
21 changed files with 268 additions and 104 deletions

View File

@ -360,6 +360,10 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
<DateDropdown
value={issue.target_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={(targetDate) =>
void handleCardUpdate({
target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null,

View File

@ -344,7 +344,7 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
</div>
<div className="nodedc-external-section overflow-visible px-4 py-4">
<IssueActivity workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} />
<IssueActivity workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} compactComposer />
</div>
</div>
);

View File

@ -34,11 +34,17 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
const updateDockBounds = () => {
const { left, width } = main.getBoundingClientRect();
const height = container?.getBoundingClientRect().height ?? 0;
setDockStyle({
left,
width,
});
document.documentElement.style.setProperty(
"--nodedc-bottom-dock-offset",
`${Math.max(height, 0)}px`
);
};
updateDockBounds();
@ -50,12 +56,18 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updateDockBounds);
document.documentElement.style.removeProperty("--nodedc-bottom-dock-offset");
};
}, []);
return (
<div ref={containerRef} className={cn("fixed right-0 bottom-0 z-[18]", className)} style={dockStyle}>
<Row className={cn("nodedc-bottom-dock flex h-11 w-full items-center gap-2", rowClassName)}>
<Row
className={cn(
"nodedc-bottom-dock flex h-[var(--nodedc-bottom-dock-height)] w-full items-center gap-2",
rowClassName
)}
>
<ExtendedAppHeader header={header} />
</Row>
{mobileHeader && mobileHeader}

View File

@ -72,7 +72,7 @@ export function DateFilterModal({ title, handleClose, isOpen, onSelect }: Props)
const date2Value = getDate(watch("date2"));
return (
<Calendar
className="rounded-md border border-subtle p-3"
className="nodedc-calendar-shell"
captionLayout="dropdown"
selected={dateValue}
defaultMonth={dateValue}
@ -95,7 +95,7 @@ export function DateFilterModal({ title, handleClose, isOpen, onSelect }: Props)
const date1Value = getDate(watch("date1"));
return (
<Calendar
className="rounded-md border border-subtle p-3"
className="nodedc-calendar-shell"
captionLayout="dropdown"
selected={dateValue}
defaultMonth={dateValue}

View File

@ -263,15 +263,16 @@ export const DateRangeDropdown = observer(function DateRangeDropdown(props: Prop
const comboOptions = (
<Combobox.Options data-prevent-outside-click static>
<div
className="z-30 my-1 overflow-hidden rounded-md border-[0.5px] border-subtle-1 bg-surface-1"
className="nodedc-dropdown-surface z-30 my-1 overflow-hidden"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<Calendar
className="rounded-md border border-subtle p-3 text-12"
className="nodedc-calendar-shell"
captionLayout="dropdown"
selected={dateRange}
defaultMonth={dateRange.from ?? dateRange.to}
onSelect={(val: DateRange | undefined) => {
onSelect?.(val);
}}

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import React, { useRef, useState } from "react";
import React, { useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
@ -38,6 +38,10 @@ type Props = TDropdownProps & {
maxDate?: Date;
onChange: (val: Date | null) => void;
onClose?: () => void;
rangePreview?: {
from?: Date | string | null;
to?: Date | string | null;
};
value: Date | string | null;
closeOnSelect?: boolean;
formatToken?: string;
@ -64,6 +68,7 @@ export const DateDropdown = observer(function DateDropdown(props: Props) {
maxDate,
onChange,
onClose,
rangePreview,
placeholder = "Date",
placement,
showTooltip = false,
@ -122,6 +127,15 @@ export const DateDropdown = observer(function DateDropdown(props: Props) {
if (minDate) disabledDays.push({ before: minDate });
if (maxDate) disabledDays.push({ after: maxDate });
const rangePreviewValue = useMemo(() => {
const from = getDate(rangePreview?.from ?? null);
const to = getDate(rangePreview?.to ?? null);
if (!from || !to) return undefined;
if (from <= to) return { from, to };
return { from: to, to: from };
}, [rangePreview?.from, rangePreview?.to]);
const comboButton = (
<>
{button ? (
@ -207,6 +221,24 @@ export const DateDropdown = observer(function DateDropdown(props: Props) {
style={styles.popper}
{...attributes.popper}
>
{rangePreviewValue ? (
<Calendar
className="nodedc-calendar-shell"
captionLayout="dropdown"
selected={rangePreviewValue}
defaultMonth={getDate(value) ?? rangePreviewValue.to ?? rangePreviewValue.from}
onDayClick={(date: Date, modifiers) => {
if (modifiers.disabled) return;
dropdownOnChange(date ?? null);
}}
showOutsideDays
initialFocus
disabled={disabledDays}
mode="range"
fixedWeeks
weekStartsOn={startOfWeek}
/>
) : (
<Calendar
className="nodedc-calendar-shell"
captionLayout="dropdown"
@ -222,6 +254,7 @@ export const DateDropdown = observer(function DateDropdown(props: Props) {
fixedWeeks
weekStartsOn={startOfWeek}
/>
)}
</div>
</Combobox.Options>,
document.body

View File

@ -169,6 +169,10 @@ export const SubIssuesListItemProperties = observer(function SubIssuesListItemPr
<div className="h-5">
<DateDropdown
value={issue.start_date ?? null}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={handleStartDate}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
@ -190,6 +194,10 @@ export const SubIssuesListItemProperties = observer(function SubIssuesListItemPr
<div className="h-5">
<DateDropdown
value={issue?.target_date ?? null}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={handleTargetDate}
minDate={minDate}
placeholder={t("common.order_by.due_date")}

View File

@ -146,6 +146,10 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props:
<DateDropdown
placeholder={t("issue.add.start_date")}
value={issue.start_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
start_date: val ? renderFormattedPayloadDate(val) : null,
@ -167,6 +171,10 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props:
<DateDropdown
placeholder={t("issue.add.due_date")}
value={issue.target_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
target_date: val ? renderFormattedPayloadDate(val) : null,

View File

@ -172,6 +172,10 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
<div className="flex items-center justify-end">
<DateDropdown
value={issue.target_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={(targetDate) =>
updateIssue?.(issue.project_id ?? null, issue.id, {
target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null,

View File

@ -264,7 +264,7 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
<KanbanIssueBlockLoader />
) : (
<div
className="sticky bottom-0 w-full cursor-pointer p-3 text-13 font-medium text-accent-primary hover:text-accent-secondary hover:underline"
className="nodedc-bottom-dock-sticky-offset sticky z-[1] w-full cursor-pointer p-3 text-13 font-medium text-accent-primary hover:text-accent-secondary hover:underline"
onClick={loadMoreIssuesInThisGroup}
>
{t("common.load_more")} &darr;
@ -278,6 +278,10 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
!!group_by &&
DRAG_ALLOWED_GROUPS.includes(group_by) &&
(sub_group_by ? DRAG_ALLOWED_GROUPS.includes(sub_group_by) : true);
const shouldShowQuickAdd =
!!enableQuickIssueCreate &&
!disableIssueCreation &&
!getIsWorkflowWorkItemCreationDisabled(groupId, sub_group_id);
return (
<div
@ -299,6 +303,7 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
isDraggingOverColumn={isDraggingOverColumn}
isEpic={isEpic}
/>
<div className={cn({ "nodedc-bottom-dock-aware-padding": shouldShowQuickAdd })}>
<KanbanIssueBlocksList
sub_group_id={sub_group_id}
groupId={groupId}
@ -326,11 +331,10 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
<KanbanIssueBlockLoader ref={setIntersectionElement} />
</div>
))}
</div>
{enableQuickIssueCreate &&
!disableIssueCreation &&
!getIsWorkflowWorkItemCreationDisabled(groupId, sub_group_id) && (
<div className="sticky bottom-0 w-full bg-surface-2 py-0.5">
{shouldShowQuickAdd && (
<div className="nodedc-bottom-dock-sticky-offset sticky z-[2] w-full bg-surface-2 py-0.5">
<QuickAddIssueRoot
layout={EIssueLayoutTypes.KANBAN}
QuickAddButton={KanbanQuickAddIssueButton}

View File

@ -329,7 +329,7 @@ export const ListGroup = observer(function ListGroup(props: Props) {
!isGroupByCreatedBy &&
!isCompletedCycle &&
!isWorkflowIssueCreationDisabled && (
<div className="sticky bottom-0 z-[1] w-full flex-shrink-0">
<div className="nodedc-bottom-dock-sticky-offset sticky z-[1] w-full flex-shrink-0">
<QuickAddIssueRoot
layout={EIssueLayoutTypes.LIST}
QuickAddButton={ListQuickAddIssueButton}

View File

@ -268,6 +268,10 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue.start_date ?? null}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={handleStartDate}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
@ -291,6 +295,10 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue?.target_date ?? null}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={handleTargetDate}
minDate={minDate}
placeholder={t("common.order_by.due_date")}

View File

@ -34,6 +34,10 @@ export const SpreadsheetDueDateColumn = observer(function SpreadsheetDueDateColu
<div className="h-11 border-b-[0.5px] border-subtle">
<DateDropdown
value={issue.target_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
minDate={getDate(issue.start_date)}
onChange={(data) => {
const targetDate = data ? renderFormattedPayloadDate(data) : null;

View File

@ -28,6 +28,10 @@ export const SpreadsheetStartDateColumn = observer(function SpreadsheetStartDate
<div className="h-11 border-b-[0.5px] border-subtle">
<DateDropdown
value={issue.start_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
maxDate={getDate(issue.target_date)}
onChange={(data) => {
const startDate = data ? renderFormattedPayloadDate(data) : null;

View File

@ -110,7 +110,7 @@ export const SpreadsheetView = observer(function SpreadsheetView(props: Props) {
/>
</div>
<div className="border-t border-subtle">
<div className="sticky bottom-0 left-0 z-5">
<div className="nodedc-bottom-dock-sticky-offset sticky bottom-0 left-0 z-5">
{enableQuickCreateIssue && !disableIssueCreation && (
<QuickAddIssueRoot
layout={EIssueLayoutTypes.SPREADSHEET}

View File

@ -191,6 +191,10 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
<div className="h-7">
<DateDropdown
value={value}
rangePreview={{
from: startDate,
to: targetDate,
}}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();
@ -211,6 +215,10 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
<div className="h-7">
<DateDropdown
value={value}
rangePreview={{
from: startDate,
to: targetDate,
}}
onChange={(date) => {
onChange(date ? renderFormattedPayloadDate(date) : null);
handleFormChange();

View File

@ -146,6 +146,10 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p
<SidebarPropertyListItem icon={StartDatePropertyIcon} label={t("common.order_by.start_date")}>
<DateDropdown
value={issue.start_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
start_date: val ? renderFormattedPayloadDate(val) : null,
@ -167,6 +171,10 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p
<div className="flex w-full items-center gap-2">
<DateDropdown
value={issue.target_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
target_date: val ? renderFormattedPayloadDate(val) : null,

View File

@ -38,25 +38,25 @@ export const WorkItemPreviewCard = observer(function WorkItemPreviewCard(props:
const stateName = stateDetails?.name ?? fallbackStateDetails?.name;
return (
<div className="w-72 space-y-2 rounded-lg border-[0.5px] border-strong bg-surface-1 p-3 shadow-raised-200">
<div className="flex items-center justify-between gap-3 text-secondary">
<div className="nodedc-dropdown-surface w-80 space-y-3 p-4 shadow-[0_24px_48px_rgba(0,0,0,0.32)]">
<div className="flex items-center justify-between gap-3 text-tertiary">
<IssueIdentifier
size="xs"
variant="secondary"
variant="tertiary"
projectId={projectId}
projectIdentifier={projectIdentifier}
issueSequenceId={workItem.sequence_id}
issueTypeId={workItem.type_id}
/>
<div className="flex shrink-0 items-center gap-1">
<div className="flex shrink-0 items-center gap-1 rounded-full bg-black/20 px-2 py-1">
<StateGroupIcon stateGroup={stateGroup} className="size-3 shrink-0" />
<p className="text-11 font-medium">{stateName}</p>
</div>
</div>
<div>
<h6 className="text-13 wrap-break-word">{workItem.name}</h6>
<h6 className="wrap-break-word text-[15px] font-semibold leading-5 text-primary">{workItem.name}</h6>
</div>
<div className="flex h-5 items-center gap-1">
<div className="flex min-h-8 items-center gap-1.5 rounded-full bg-black/15 px-2.5 py-1.5 text-secondary">
<PriorityIcon priority={workItem.priority} withContainer />
<WorkItemPreviewCardDate
startDate={workItem.start_date}

View File

@ -180,6 +180,10 @@ export const DraftIssueProperties = observer(function DraftIssueProperties(props
<div className="h-5" onClick={handleEventPropagation}>
<DateDropdown
value={issue.start_date ?? null}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={handleStartDate}
maxDate={maxDate}
placeholder={t("start_date")}
@ -195,6 +199,10 @@ export const DraftIssueProperties = observer(function DraftIssueProperties(props
<div className="h-5" onClick={handleEventPropagation}>
<DateDropdown
value={issue?.target_date ?? null}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={handleTargetDate}
minDate={minDate}
placeholder={t("due_date")}

View File

@ -33,6 +33,10 @@
--nodedc-on-card-passive-rgb: 245 247 251;
--nodedc-card-active-rgb: 195 255 102;
--nodedc-on-card-active-rgb: 11 17 23;
--nodedc-bottom-dock-height: 2.75rem;
--nodedc-bottom-dock-offset: 2.75rem;
--nodedc-bottom-dock-visual-overlap: 0.625rem;
--nodedc-quick-add-reserve: 2.5rem;
--brand-default: rgb(var(--nodedc-accent-rgb));
--brand-300: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 65%, white);
--brand-700: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 75%, black);
@ -276,6 +280,20 @@
backdrop-filter: blur(34px);
}
.nodedc-bottom-dock-aware-padding {
padding-bottom: calc(var(--nodedc-quick-add-reserve, 2.5rem) + 0.5rem);
}
.nodedc-bottom-dock-sticky-offset {
bottom: max(
calc(
var(--nodedc-bottom-dock-offset, var(--nodedc-bottom-dock-height, 2.75rem)) -
var(--nodedc-bottom-dock-visual-overlap, 0.625rem)
),
0px
);
}
.nodedc-bottom-dock [class~="bg-surface-1"] {
background: transparent !important;
}
@ -1042,7 +1060,6 @@
@apply bg-transparent;
}
.nodedc-calendar-shell .rdp-day_button,
.nodedc-calendar-shell .rdp-button_previous,
.nodedc-calendar-shell .rdp-button_next,
.nodedc-calendar-shell .rdp-dropdown_root {
@ -1052,6 +1069,13 @@
border-radius: 0.95rem !important;
}
.nodedc-calendar-shell .rdp-day_button {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 999px !important;
}
.nodedc-auth-shell {
width: 100%;
max-width: 32rem;

View File

@ -8,15 +8,17 @@
/* Font size for the caption labels. */
--rdp-caption-navigation-size: 1.25rem;
/* Font size for the caption labels. */
--rdp-accent-color: var(--background-color-accent-primary);
--rdp-accent-color: rgb(var(--nodedc-accent-rgb, 51 163 255));
/* Accent color for the background of selected days. */
--rdp-background-color: --alpha(var(--background-color-accent-primary) / 50%);
--rdp-background-color: color-mix(in srgb, var(--rdp-accent-color) 18%, transparent);
/* Background color for the hovered/focused elements. */
--rdp-dark-background-color: var(--background-color-accent-primary-hover);
--rdp-dark-background-color: color-mix(in srgb, var(--rdp-accent-color) 82%, white);
--rdp-range-background-color: var(--rdp-accent-color);
/* Background color for the hovered/focused, already selected elements. */
--rdp-outline: 2px solid var(--rdp-accent-color);
/* Outline border for focused elements */
--rdp-selected-color: var(--text-color-on-color);
--rdp-selected-color: rgb(var(--nodedc-on-card-active-rgb, 11 17 23));
--rdp-today-outline-color: rgb(var(--nodedc-on-card-active-rgb, 11 17 23));
/* Color of selected day text */
background: transparent;
@ -34,6 +36,7 @@
.rdp-day_button {
display: flex;
overflow: hidden;
position: relative;
align-items: center;
justify-content: center;
box-sizing: border-box;
@ -43,6 +46,20 @@
margin: 0;
border: 2px solid transparent;
border-radius: 50%;
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.rdp-month_grid {
border-collapse: collapse;
border-spacing: 0;
}
.rdp-day {
padding: 0;
}
.rdp-day.rdp-outside:not(.rdp-selected) .rdp-day_button {
@ -87,22 +104,25 @@
.rdp-week {
margin: 0;
padding: 0;
height: var(--rdp-cell-size);
}
.rdp-today:not(.rdp-outside) {
position: relative;
.rdp-today:not(.rdp-outside) .rdp-day_button {
border-radius: 999px;
box-shadow: none;
}
.rdp-today:not(.rdp-outside)::after {
.rdp-today.rdp-selected .rdp-day_button {
box-shadow: none;
}
.rdp-today:not(.rdp-outside) .rdp-day_button::after {
content: "";
position: absolute;
left: 50%;
bottom: 2px;
width: 0.5em;
height: 0.5em;
background-color: var(--rdp-background-color);
border-radius: 100%;
transform: translate(-50%, 0);
inset: 4px;
border: 1.5px solid var(--rdp-today-outline-color);
border-radius: 999px;
pointer-events: none;
}
.rdp-selected .rdp-day_button:focus-visible,
@ -274,40 +294,46 @@
.rdp-range_end::before {
content: "";
position: absolute;
background-color: var(--rdp-background-color);
top: 50%;
height: 100%;
width: 50%;
transform: translate(0, -50%);
background-color: var(--rdp-range-background-color);
top: -1px;
bottom: -1px;
left: 0;
width: 100%;
z-index: -1;
border-radius: 0;
}
.rdp-range_start::before {
left: 50%;
border-radius: 999px 0 0 999px;
}
.rdp-range_middle::before {
left: 50%;
width: 100%;
transform: translate(-50%, -50%);
.rdp-week .rdp-range_middle:first-child::before {
border-radius: 999px 0 0 999px;
}
.rdp-range_end::before {
right: 50%;
border-radius: 0 999px 999px 0;
}
.rdp-week .rdp-range_middle:last-child::before {
border-radius: 0 999px 999px 0;
}
.rdp-range_start.rdp-range_end::before {
display: none;
border-radius: 999px;
}
.rdp-range_middle .rdp-day_button {
.rdp-range_start .rdp-day_button,
.rdp-range_middle .rdp-day_button,
.rdp-range_end .rdp-day_button {
color: var(--rdp-selected-color);
background-color: transparent;
color: inherit;
font-weight: 600;
}
.rdp-day.rdp-range_middle .rdp-day_button:hover,
.rdp-day.rdp-range_middle .rdp-day_button:focus-visible {
background-color: var(--rdp-background-color);
color: inherit;
background-color: transparent;
color: var(--rdp-selected-color);
}
}