UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: верхняя панель, rich filters и runtime-fix DEBUG=release

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 23:46:31 +03:00
parent b1912c64e9
commit ae124e50e5
27 changed files with 384 additions and 149 deletions

7
design.config.json Normal file
View File

@ -0,0 +1,7 @@
{
"nodedc": {
"accent_rgb": [195, 255, 102],
"passive_card_rgb": [42, 43, 46],
"active_card_rgb": [195, 255, 102]
}
}

View File

@ -23,11 +23,25 @@ from plane.utils.url import is_valid_url
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def env_flag(value, default=False):
if value is None:
return default
normalized = str(value).strip().lower()
if normalized in {"1", "true", "yes", "on", "debug"}:
return True
if normalized in {"0", "false", "no", "off", "release", "prod", "production"}:
return False
return default
# Secret Key
SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
DEBUG = env_flag(os.environ.get("DEBUG", "0"))
# Self-hosted mode
IS_SELF_MANAGED = True

View File

@ -9,7 +9,7 @@ import os
from .common import * # noqa
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
DEBUG = env_flag(os.environ.get("DEBUG", 0), default=False)
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

View File

@ -108,7 +108,7 @@ export const IssuesHeader = observer(function IssuesHeader() {
)}
</Header.LeftItem>
<Header.RightItem>
<div className="hidden gap-2 md:flex">
<div className="hidden items-center gap-2 md:flex">
<HeaderFilters
projectId={projectId}
currentProjectDetails={currentProjectDetails}
@ -120,6 +120,7 @@ export const IssuesHeader = observer(function IssuesHeader() {
<Button
variant="primary"
size="lg"
className="nodedc-toolbar-primary"
onClick={() => {
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
}}

View File

@ -94,47 +94,54 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
projectDetails={currentProjectDetails ?? undefined}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
<div className="hidden @4xl:flex">
<LayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
<div className="hidden @4xl:flex">
<LayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</div>
<div className="flex @4xl:hidden">
<MobileLayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
<FiltersDropdown
miniIcon={<SlidersHorizontal className="size-3.5" />}
title={t("common.display")}
placement="bottom-end"
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
{canUserCreateIssue ? (
<Button
className="nodedc-toolbar-pill hidden md:inline-flex"
onClick={() => setAnalyticsModal(true)}
variant="secondary"
size="lg"
>
<div className="hidden @4xl:flex">{t("common.analytics")}</div>
<div className="flex @4xl:hidden">
<ChartNoAxesColumn className="size-3.5" />
</div>
</Button>
) : (
<></>
)}
</div>
<div className="flex @4xl:hidden">
<MobileLayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
<FiltersDropdown
miniIcon={<SlidersHorizontal className="size-3.5" />}
title={t("common.display")}
placement="bottom-end"
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
{canUserCreateIssue ? (
<Button className="hidden px-2 md:block" onClick={() => setAnalyticsModal(true)} variant="secondary" size="lg">
<div className="hidden @4xl:flex">{t("common.analytics")}</div>
<div className="flex @4xl:hidden">
<ChartNoAxesColumn className="size-3.5" />
</div>
</Button>
) : (
<></>
)}
</>
);
});

View File

@ -71,10 +71,10 @@ export const FilterDisplayProperties = observer(function FilterDisplayProperties
<button
key={displayProperty.key}
type="button"
className={`rounded-sm border px-2 py-0.5 text-11 transition-all ${
className={`rounded-full border-0 px-3 py-1.5 text-12 transition-all ${
displayProperties?.[displayProperty.key]
? "border-accent-strong bg-accent-primary text-on-color"
: "border-subtle hover:bg-layer-1"
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]"
: "bg-white/5 text-secondary hover:bg-white/8"
}`}
onClick={() =>
handleUpdate({

View File

@ -61,11 +61,12 @@ export function FiltersDropdown(props: Props) {
variant="secondary"
prependIcon={icon}
tabIndex={tabIndex}
className="relative"
className="nodedc-toolbar-pill relative"
data-active={open}
size="lg"
>
<>
<div className={`${open ? "text-primary" : "text-secondary"}`}>
<div className={`${open ? "text-[rgb(var(--nodedc-accent-rgb))]" : "text-secondary"}`}>
<span>{title}</span>
</div>
{isFiltersApplied && (
@ -80,6 +81,7 @@ export function FiltersDropdown(props: Props) {
ref={setReferenceElement}
variant="secondary"
tabIndex={tabIndex}
className="nodedc-toolbar-pill"
size="lg"
>
{miniIcon || title}
@ -100,7 +102,7 @@ export function FiltersDropdown(props: Props) {
{/** translate-y-0 is a hack to create new stacking context. Required for safari */}
<Popover.Panel className="fixed z-10 translate-y-0">
<div
className="my-1 overflow-hidden rounded-sm border border-subtle bg-surface-1 shadow-raised-100"
className="nodedc-dropdown-surface my-1 overflow-hidden"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}

View File

@ -15,11 +15,11 @@ type Props = {
export function FilterHeader({ title, isPreviewEnabled, handleIsPreviewEnabled }: Props) {
return (
<div className="sticky top-0 flex items-center justify-between gap-2 bg-surface-1">
<div className="sticky top-0 flex items-center justify-between gap-2 bg-transparent pb-1">
<div className="flex-grow truncate text-caption-sm-medium text-placeholder">{title}</div>
<button
type="button"
className="grid h-5 w-5 flex-shrink-0 place-items-center rounded-sm hover:bg-layer-transparent-hover"
className="nodedc-toolbar-icon-button grid h-7 w-7 flex-shrink-0 place-items-center"
onClick={handleIsPreviewEnabled}
>
{isPreviewEnabled ? <ChevronUpIcon height={14} width={14} /> : <ChevronDownIcon height={14} width={14} />}

View File

@ -16,18 +16,20 @@ type Props = {
};
export function FilterOption(props: Props) {
const { icon, isChecked, multiple = true, onClick, title, activePulse = false } = props;
const { icon, isChecked, onClick, title, activePulse = false } = props;
return (
<button
type="button"
className="flex w-full items-center gap-2 rounded-sm p-1.5 hover:bg-layer-transparent-hover"
className="nodedc-dropdown-option"
onClick={onClick}
>
<div
className={`grid h-3 w-3 flex-shrink-0 place-items-center border ${
isChecked ? "border-accent-strong bg-accent-primary text-on-color" : "border-strong"
} ${multiple ? "rounded-xs" : "rounded-full"}`}
className={`grid h-4 w-4 flex-shrink-0 place-items-center border-0 ${
isChecked
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]"
: "bg-white/6 text-transparent"
} rounded-full`}
>
{isChecked && <CheckIcon width={10} height={10} strokeWidth={3} />}
</div>

View File

@ -32,25 +32,25 @@ export function LayoutSelection(props: Props) {
};
return (
<div className="flex items-center gap-1 rounded-md bg-layer-3 p-1">
<div className="nodedc-toolbar-group flex items-center gap-1">
{ISSUE_LAYOUTS.filter((l) => layouts.includes(l.key)).map((layout) => (
<Tooltip key={layout.key} tooltipContent={t(layout.i18n_title)} isMobile={isMobile}>
<button
type="button"
className={cn(
"group grid h-5.5 w-7 place-items-center overflow-hidden rounded-sm transition-all hover:bg-layer-transparent-hover",
{
"bg-layer-transparent-active hover:bg-layer-transparent-active": selectedLayout === layout.key,
}
"nodedc-toolbar-icon-button group grid h-8 w-8 place-items-center overflow-hidden"
)}
data-active={selectedLayout === layout.key}
onClick={() => handleOnChange(layout.key)}
>
<IssueLayoutIcon
layout={layout.key}
size={14}
strokeWidth={2}
className={`size-3.5 ${selectedLayout == layout.key ? "text-primary" : "text-secondary"}`}
/>
<span className="nodedc-toolbar-icon-active-dot">
<IssueLayoutIcon
layout={layout.key}
size={14}
strokeWidth={2}
className={`size-3.5 ${selectedLayout == layout.key ? "text-[#0b1117]" : "text-secondary"}`}
/>
</span>
</button>
</Tooltip>
))}

View File

@ -29,10 +29,12 @@ export function MobileLayoutSelection({
className="flex flex-grow justify-center text-13 text-secondary"
placement="bottom-start"
customButton={
<Button variant="secondary" className="relative px-2">
{activeLayout && (
<IssueLayoutIcon layout={activeLayout} size={14} strokeWidth={2} className={`h-3.5 w-3.5`} />
)}
<Button variant="secondary" className="nodedc-toolbar-pill relative gap-2 px-3">
<span className="nodedc-toolbar-icon-active-dot bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]">
{activeLayout && (
<IssueLayoutIcon layout={activeLayout} size={14} strokeWidth={2} className={`h-3.5 w-3.5`} />
)}
</span>
<ChevronDownIcon className="my-auto size-3 text-secondary" strokeWidth={2} />
</Button>
}

View File

@ -312,12 +312,14 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
: "rounded-lg border border-subtle bg-layer-2 p-3 shadow-raised-100 outline-[0.5px] outline-transparent hover:border-strong hover:shadow-raised-200",
{ "hover:cursor-pointer": isDragAllowed },
{
"bg-[#C3FF66] text-[#111111]": cardVariant === "internal-contour" && isPeeked,
"bg-[#2A2B2E] text-white": cardVariant === "internal-contour" && !isPeeked,
"bg-[rgb(var(--nodedc-card-active-rgb))] text-[#111111]":
cardVariant === "internal-contour" && isPeeked,
"bg-[rgb(var(--nodedc-card-passive-rgb))] text-white":
cardVariant === "internal-contour" && !isPeeked,
"border border-accent-strong hover:border-accent-strong": cardVariant !== "internal-contour" && isPeeked,
},
{ "z-[100] bg-layer-1": isCurrentBlockDragging && cardVariant !== "internal-contour" },
{ "z-[100] bg-[#C3FF66]": isCurrentBlockDragging && cardVariant === "internal-contour" }
{ "z-[100] bg-[rgb(var(--nodedc-card-active-rgb))]": isCurrentBlockDragging && cardVariant === "internal-contour" }
)}
onClick={() => handleIssuePeekOverview(issue)}
disabled={!!issue?.tempId}

View File

@ -76,8 +76,9 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
const foregroundClasses = isActive ? "text-[#111111]" : "text-white";
const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]";
const pillBackgroundClasses = isActive ? "bg-black/10 text-[#111111]" : "bg-[#1B1B1F] text-white";
const iconBubbleClasses = isActive ? "bg-black text-[#C3FF66]" : "bg-[#111214] text-white";
const pillBackgroundClasses =
isActive ? "bg-black/10 text-[#111111]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white";
const statusIconColor = selectedState?.color ?? (isActive ? "#111111" : "var(--text-color-primary)");
const handleEventPropagation = (e: React.MouseEvent) => {
@ -95,7 +96,9 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
ref={menuActionRef}
className={cn(
"flex h-8 w-8 cursor-pointer items-center justify-center rounded-full p-1 transition-colors",
isActive ? "bg-black text-[#C3FF66] hover:bg-black/90" : "bg-[#111214] text-white hover:bg-[#0A0B0C]",
isActive
? "bg-black text-[rgb(var(--nodedc-card-active-rgb))] hover:bg-black/90"
: "bg-[#111214] text-white hover:bg-[#0A0B0C]",
isMenuActive && (isActive ? "bg-black/90" : "bg-[#0A0B0C]")
)}
onClick={() => setIsMenuActive(!isMenuActive)}

View File

@ -68,12 +68,14 @@ export const AddFilterButton = observer(function AddFilterButton<P extends TFilt
{...props}
buttonConfig={{
...buttonConfig,
className: cn(getButtonStyling(variant, size), "py-[5px]", className),
className: cn(getButtonStyling(variant, size), "nodedc-toolbar-filter-toggle", className),
}}
handleFilterSelect={handleFilterSelect}
customButton={
<div className="flex items-center gap-1">
{iconConfig.shouldShowIcon && <FilterIcon className="size-4 text-secondary" />}
<div className={cn("flex items-center", label ? "gap-1" : "justify-center")}>
{iconConfig.shouldShowIcon && (
<FilterIcon className={cn("size-4 text-secondary", !label && "translate-y-[3px]")} />
)}
{label}
</div>
}

View File

@ -12,6 +12,7 @@ import type { IFilterInstance } from "@plane/shared-state";
import type { TExternalFilter, TFilterProperty, TSupportedOperators } from "@plane/types";
import { CustomSearchSelect } from "@plane/ui";
import { getOperatorForPayload } from "@plane/utils";
import { localizeRichFilterLabel } from "../i18n";
export type TAddFilterDropdownProps<P extends TFilterProperty, E extends TExternalFilter> = {
customButton: React.ReactNode;
@ -40,12 +41,12 @@ export const AddFilterDropdown = observer(function AddFilterDropdown<
{config.icon && (
<config.icon className="size-4 text-tertiary transition-transform duration-200 ease-in-out" />
)}
<span>{config.label}</span>
<span>{localizeRichFilterLabel(config.label)}</span>
</div>
{config.rightContent}
</div>
),
query: config.label.toLowerCase(),
query: localizeRichFilterLabel(config.label).toLowerCase(),
}));
// If all filters are applied, show disabled options
@ -54,8 +55,8 @@ export const AddFilterDropdown = observer(function AddFilterDropdown<
? [
{
value: "all_filters_applied",
content: <div className="text-placeholder italic">All filters applied</div>,
query: "all filters applied",
content: <div className="text-placeholder italic">Все фильтры уже применены</div>,
query: "все фильтры уже применены",
disabled: true,
},
]

View File

@ -29,7 +29,7 @@ export const FilterItemCloseButton = observer(function FilterItemCloseButton<
return (
<button
onClick={handleRemoveFilter}
className="bg-layer-transparent px-1.5 text-placeholder hover:bg-layer-transparent-hover hover:text-tertiary focus:outline-none"
className="rounded-r-full bg-transparent px-2 text-placeholder hover:bg-white/6 hover:text-tertiary focus:outline-none"
type="button"
aria-label="Remove filter"
>

View File

@ -58,9 +58,9 @@ export function FilterItemContainer(props: FilterItemContainerProps) {
<Tooltip tooltipContent={tooltipContent} position="bottom" disabled={!tooltipContent}>
<div
ref={itemRef}
className={cn("flex h-7 items-stretch overflow-hidden rounded-sm border transition-all duration-200", {
"border-subtle bg-surface-1": variant === "default",
"border-danger-strong bg-surface-2": variant === "error",
className={cn("flex min-h-9 items-stretch overflow-hidden rounded-full transition-all duration-200", {
"nodedc-filter-chip": variant === "default",
"bg-[#3b1212]/80 text-[#ffd6d6] rounded-full": variant === "error",
})}
>
{children}

View File

@ -12,6 +12,7 @@ import type { IFilterInstance } from "@plane/shared-state";
import type { TExternalFilter, TFilterProperty, TSupportedOperators } from "@plane/types";
// local imports
import { AddFilterDropdown } from "../add-filters/dropdown";
import { localizeRichFilterLabel } from "../i18n";
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../shared";
interface IFilterItemPropertyProps<P extends TFilterProperty, E extends TExternalFilter> {
@ -52,12 +53,13 @@ type TPropertyButtonProps<P extends TFilterProperty, E extends TExternalFilter>
function PropertyButton<P extends TFilterProperty, E extends TExternalFilter>(props: TPropertyButtonProps<P, E>) {
const { icon: Icon, label, tooltipContent, className } = props;
const localizedLabel = localizeRichFilterLabel(label);
return (
<Tooltip tooltipContent={tooltipContent} position="bottom-start" disabled={!tooltipContent}>
<div
className={cn(
"flex h-full min-w-0 items-center gap-1 px-2 py-[5px] text-11 text-tertiary",
"flex h-full min-w-0 items-center gap-1.5 px-3 py-[7px] text-13 text-tertiary",
COMMON_FILTER_ITEM_BORDER_CLASSNAME,
className
)}
@ -67,7 +69,7 @@ function PropertyButton<P extends TFilterProperty, E extends TExternalFilter>(pr
<Icon className="size-3.5" />
</div>
)}
<span className="truncate">{label}</span>
<span className="truncate">{localizedLabel}</span>
</div>
</Tooltip>
);

View File

@ -20,6 +20,7 @@ import { CustomSearchSelect } from "@plane/ui";
import { cn, getOperatorForPayload } from "@plane/utils";
// local imports
import { FilterValueInput } from "../filter-value-input/root";
import { localizeRichFilterOperator } from "../i18n";
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../shared";
import { FilterItemCloseButton } from "./close-button";
import { FilterItemContainer } from "./container";
@ -44,8 +45,8 @@ export const FilterItem = observer(function FilterItem<P extends TFilterProperty
?.getAllDisplayOperatorOptionsByValue(condition.value as TFilterValue)
.map((option) => ({
value: option.value,
content: option.label,
query: option.label.toLowerCase(),
content: localizeRichFilterOperator(option.label),
query: localizeRichFilterOperator(option.label).toLowerCase(),
}));
const selectedOperatorFieldConfig = filterConfig?.getOperatorConfig(condition.operator);
const selectedOperatorOption = filterConfig?.getDisplayOperatorByValue(
@ -102,15 +103,15 @@ export const FilterItem = observer(function FilterItem<P extends TFilterProperty
options={operatorOptions}
className={COMMON_FILTER_ITEM_BORDER_CLASSNAME}
customButtonClassName={cn(
"h-full px-2 text-13 font-regular",
isOperatorSelectionDisabled && "hover:bg-layer-2-hover"
"h-full px-3 text-13 font-regular",
isOperatorSelectionDisabled && "hover:bg-transparent"
)}
optionsClassName="w-48"
maxHeight="2xl"
disabled={isOperatorSelectionDisabled}
customButton={
<div className="flex h-full items-center" aria-disabled={isOperatorSelectionDisabled}>
{filterConfig.getLabelForOperator(selectedOperatorOption)}
{localizeRichFilterOperator(filterConfig.getLabelForOperator(selectedOperatorOption))}
</div>
}
/>

View File

@ -58,7 +58,7 @@ export function SelectedOptionsDisplay<V extends TFilterValue>(props: TSelectedO
enterTo="opacity-100"
className="ml-1 whitespace-nowrap text-tertiary"
>
+{remainingCount} more
+{remainingCount} еще
</Transition>
)}
</div>

View File

@ -51,7 +51,7 @@ export const getFormattedOptions = <V extends TFilterValue>(options: IFilterOpti
export const getCommonCustomSearchSelectProps = (isDisabled?: boolean) => ({
customButtonClassName: cn(
"h-full w-full px-2 text-13 font-regular transition-all duration-300 ease-in-out",
"h-full w-full px-3 text-13 font-regular transition-all duration-300 ease-in-out",
!isDisabled && COMMON_FILTER_ITEM_BORDER_CLASSNAME,
isDisabled && "hover:bg-surface-1"
),

View File

@ -9,6 +9,7 @@ import { observer } from "mobx-react";
import { ListFilterPlus } from "lucide-react";
import { Transition } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IFilterInstance } from "@plane/shared-state";
import type { TExternalFilter, TFilterProperty } from "@plane/types";
@ -40,6 +41,7 @@ export const FiltersRow = observer(function FiltersRow<K extends TFilterProperty
variant = "header",
trackerElements,
} = props;
const { t } = useTranslation();
// states
const [isUpdating, setIsUpdating] = useState(false);
// derived values
@ -53,7 +55,7 @@ export const FiltersRow = observer(function FiltersRow<K extends TFilterProperty
};
const modalButtonConfig: Partial<TAddFilterButtonProps<K, E>["buttonConfig"]> = {
label: !hasAnyConditions ? "Filters" : null,
label: !hasAnyConditions ? t("common.filters") : null,
};
const handleUpdate = useCallback(async () => {
@ -92,44 +94,44 @@ export const FiltersRow = observer(function FiltersRow<K extends TFilterProperty
<ElementTransition show={filter.canClearFilters}>
<Button
variant="secondary"
className={COMMON_OPERATION_BUTTON_CLASSNAME}
className={cn(COMMON_OPERATION_BUTTON_CLASSNAME, "nodedc-filter-clear-button")}
onClick={filter.clearFilters}
data-ph-element={trackerElements?.clearFilter}
>
{filter.clearFilterOptions?.label ?? "Clear all"}
{t("common.clear")}
</Button>
</ElementTransition>
<ElementTransition show={filter.canSaveView}>
<Button
variant="secondary"
className={COMMON_OPERATION_BUTTON_CLASSNAME}
className={cn(COMMON_OPERATION_BUTTON_CLASSNAME, "nodedc-filter-clear-button")}
onClick={filter.saveView}
data-ph-element={trackerElements?.saveView}
>
{filter.saveViewOptions?.label ?? "Save view"}
{filter.saveViewOptions?.label ?? "Сохранить вид"}
</Button>
</ElementTransition>
<ElementTransition show={filter.canUpdateView}>
<Button
variant="secondary"
className={COMMON_OPERATION_BUTTON_CLASSNAME}
className={cn(COMMON_OPERATION_BUTTON_CLASSNAME, "nodedc-filter-clear-button")}
onClick={handleUpdate}
loading={isUpdating}
disabled={isUpdating}
data-ph-element={trackerElements?.updateView}
>
{isUpdating ? "Confirming" : (filter.updateViewOptions?.label ?? "Update view")}
{isUpdating ? "Подтверждение..." : (filter.updateViewOptions?.label ?? "Обновить вид")}
</Button>
</ElementTransition>
</>
);
const mainContent = (
<div className="flex w-full items-start gap-2 rounded-lg bg-layer-1 px-4 py-2">
<div className="nodedc-filter-row-shell flex w-full items-start gap-2 px-3 py-2">
<div className="flex w-full flex-wrap items-center gap-2">{leftContent}</div>
<div
className={cn("flex items-center gap-2 border-l border-subtle pl-4", {
"border-l-transparent pl-0": !hasAvailableOperations,
className={cn("flex items-center gap-2", {
"hidden": !hasAvailableOperations,
})}
>
{rightContent}
@ -137,12 +139,10 @@ export const FiltersRow = observer(function FiltersRow<K extends TFilterProperty
</div>
);
const ModalVariant = (
<div className="flex min-h-11 w-full flex-wrap items-center gap-2 rounded-lg bg-layer-1 p-2">{mainContent}</div>
);
const ModalVariant = <div className="flex min-h-11 w-full flex-wrap items-center gap-2 p-2">{mainContent}</div>;
const HeaderVariant = (
<Header variant={EHeaderVariant.TERNARY} className="min-h-11 bg-surface-1 !px-3">
<Header variant={EHeaderVariant.TERNARY} className="min-h-11 bg-transparent !px-0">
{mainContent}
</Header>
);
@ -160,7 +160,7 @@ export const FiltersRow = observer(function FiltersRow<K extends TFilterProperty
return <RowTransition show={filter.isVisible}>{variant === "modal" ? ModalVariant : HeaderVariant}</RowTransition>;
});
const COMMON_OPERATION_BUTTON_CLASSNAME = "py-1";
const COMMON_OPERATION_BUTTON_CLASSNAME = "min-h-9 px-4 py-1";
type TElementTransitionProps = {
children: React.ReactNode;

View File

@ -19,7 +19,7 @@ type TFiltersToggleProps<P extends TFilterProperty, E extends TExternalFilter> =
};
const COMMON_CLASSNAME =
"grid place-items-center h-7 w-full py-0.5 px-2 rounded-md border border-subtle-1 transition-all duration-200 cursor-pointer";
"nodedc-toolbar-filter-toggle grid h-8 w-8 place-items-center px-0 py-0 transition-all duration-200 cursor-pointer";
export const FiltersToggle = observer(function FiltersToggle<P extends TFilterProperty, E extends TExternalFilter>(
props: TFiltersToggleProps<P, E>
@ -40,26 +40,13 @@ export const FiltersToggle = observer(function FiltersToggle<P extends TFilterPr
filter.toggleVisibility();
};
// Base classes when filter is active
const activeFilterBaseClasses =
"text-accent-primary border border-accent-subtle-1 hover:border-accent-subtle-1 active:border-accent-subtle-1 focus:border-accent-subtle-1";
// State classes that prevent hover/active/focus color changes
const noHoverStateClasses = "hover:text-accent-primary active:text-accent-primary focus:text-accent-primary";
// Background classes based on toggle state (darker when open, lighter when closed)
const backgroundClasses = isFilterRowVisible
? "bg-accent-subtle-hover hover:bg-accent-subtle-hover active:bg-accent-subtle-hover focus:bg-accent-subtle-hover"
: "bg-accent-subtle hover:bg-accent-subtle active:bg-accent-subtle focus:bg-accent-subtle";
const buttonClassName = cn({
[activeFilterBaseClasses]: showFilterRowChangesPill,
[backgroundClasses]: showFilterRowChangesPill,
[noHoverStateClasses]: showFilterRowChangesPill,
"nodedc-toolbar-filter-toggle": true,
"text-[rgb(var(--nodedc-accent-rgb))]": showFilterRowChangesPill,
});
const iconClassName = cn({
"text-accent-primary [&_path]:fill-current": showFilterRowChangesPill,
"text-[rgb(var(--nodedc-accent-rgb))] [&_path]:fill-current": showFilterRowChangesPill,
});
// Show the add filter button when there are no active conditions, the filter row is hidden, and no unsaved changes exist
@ -84,7 +71,8 @@ export const FiltersToggle = observer(function FiltersToggle<P extends TFilterPr
icon={showFilterRowChangesPill ? FilterAppliedIcon : FilterIcon}
onClick={handleToggleFilter}
className={buttonClassName}
iconClassName={iconClassName}
data-active={showFilterRowChangesPill}
iconClassName={cn("translate-y-[3px]", iconClassName)}
/>
);
});

View File

@ -0,0 +1,35 @@
const FILTER_LABEL_MAP: Record<string, string> = {
Assignees: "Назначенные",
Assignee: "Назначенный",
Priority: "Приоритет",
State: "Статус",
"State Group": "Группа статусов",
"State Groups": "Группы статусов",
Mentions: "Упоминания",
Label: "Метка",
Labels: "Метки",
Project: "Проект",
"Created by": "Автор",
"Created at": "Дата создания",
"Updated at": "Дата обновления",
"Start date": "Дата начала",
"Target date": "Срок выполнения",
"Due date": "Срок выполнения",
Parent: "Родительский",
};
const FILTER_OPERATOR_MAP: Record<string, string> = {
is: "равно",
"is any of": "содержит одно из",
between: "между",
};
export const localizeRichFilterLabel = (label: string | undefined) => {
if (!label) return "";
return FILTER_LABEL_MAP[label] ?? label;
};
export const localizeRichFilterOperator = (label: string | undefined) => {
if (!label) return "";
return FILTER_OPERATOR_MAP[label] ?? label;
};

View File

@ -12,7 +12,7 @@ import type {
TSupportedFilterFieldConfigs,
} from "@plane/types";
export const COMMON_FILTER_ITEM_BORDER_CLASSNAME = "border-r border-subtle-1";
export const COMMON_FILTER_ITEM_BORDER_CLASSNAME = "nodedc-filter-chip-separator";
export const EMPTY_FILTER_PLACEHOLDER_TEXT = "--";

View File

@ -27,6 +27,9 @@
--editor-colors-light-blue-background: #c5eff9;
--editor-colors-dark-blue-background: #c9dafb;
--editor-colors-purple-background: #e3d8fd;
--nodedc-accent-rgb: 51 163 255;
--nodedc-card-passive-rgb: 42 43 46;
--nodedc-card-active-rgb: 195 255 102;
/* end background colors */
}
/* background colors */
@ -354,6 +357,169 @@
@apply bg-white/6;
}
.nodedc-toolbar-group {
background: rgba(8, 8, 11, 0.92);
border-radius: 999px;
padding: 0.25rem;
border: 0 !important;
box-shadow: none !important;
isolation: isolate;
overflow: hidden;
}
.nodedc-toolbar-icon-button {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 999px !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.72);
transition:
color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
}
.nodedc-toolbar-icon-button:hover {
background: rgba(255, 255, 255, 0.05) !important;
color: rgba(255, 255, 255, 0.94);
}
.nodedc-toolbar-icon-button[data-active="true"] {
background: transparent !important;
color: rgba(255, 255, 255, 0.98);
}
.nodedc-toolbar-icon-active-dot {
display: grid;
height: 2rem;
width: 2rem;
place-items: center;
border-radius: 999px;
transition:
background-color 160ms ease,
color 160ms ease;
}
.nodedc-toolbar-icon-button[data-active="true"] .nodedc-toolbar-icon-active-dot {
background: rgb(var(--nodedc-accent-rgb));
color: #0b1117;
}
.nodedc-toolbar-pill {
position: relative;
display: inline-flex !important;
align-items: center;
justify-content: center;
flex-shrink: 0;
isolation: isolate;
overflow: hidden;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 999px !important;
min-height: 2.5rem;
padding-inline: 1rem;
background: rgba(18, 18, 22, 0.94) !important;
color: var(--text-color-primary) !important;
transition:
background-color 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.nodedc-toolbar-pill:hover {
background: rgba(24, 24, 29, 0.96) !important;
}
.nodedc-toolbar-pill[data-active="true"] {
color: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-toolbar-primary {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 999px !important;
min-height: 2.5rem;
padding-inline: 1rem;
background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important;
}
.nodedc-toolbar-primary:hover {
background: rgba(var(--nodedc-accent-rgb), 0.92) !important;
}
.nodedc-toolbar-filter-toggle {
display: grid;
height: 2.5rem !important;
width: 2.5rem !important;
place-items: center;
flex-shrink: 0;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 999px !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.72) !important;
transition: color 160ms ease;
}
.nodedc-toolbar-filter-toggle:hover,
.nodedc-toolbar-filter-toggle:focus-visible {
background: transparent !important;
color: rgba(255, 255, 255, 0.9) !important;
}
.nodedc-toolbar-filter-toggle[data-active="true"] {
color: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-filter-row-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%),
rgba(8, 8, 11, 0.84);
border: 0 !important;
border-radius: 1.35rem !important;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
box-shadow: none !important;
outline: none !important;
}
.nodedc-top-toolbar-cluster > * {
flex-shrink: 0;
}
.nodedc-filter-chip {
border: 0 !important;
border-radius: 999px !important;
background: rgba(255, 255, 255, 0.045) !important;
color: var(--text-color-primary) !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
box-shadow: none !important;
outline: none !important;
}
.nodedc-filter-chip-separator {
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.nodedc-filter-clear-button {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 999px !important;
background: rgba(255, 255, 255, 0.05) !important;
color: var(--text-color-primary) !important;
}
.nodedc-filter-clear-button:hover {
background: rgba(255, 255, 255, 0.08) !important;
}
.nodedc-calendar-shell {
@apply rounded-[1.1rem] border-0 bg-transparent p-1 shadow-none outline-none;
}

View File

@ -40,7 +40,7 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
optionsClassName = "",
value,
tabIndex,
noResultsMessage = "No matches found",
noResultsMessage = "Совпадений не найдено",
defaultOpen = false,
} = props;
const [query, setQuery] = useState("");
@ -104,14 +104,14 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
<button
ref={setReferenceElement}
type="button"
className={cn(
"flex w-full items-center justify-between gap-1 text-11",
{
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
},
customButtonClassName
)}
className={cn(
"flex w-full items-center justify-between gap-1 text-11",
{
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer": !disabled,
},
customButtonClassName
)}
onClick={toggleDropdown}
>
{customButton}
@ -122,13 +122,13 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
<button
ref={setReferenceElement}
type="button"
className={cn(
"flex w-full items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong",
className={cn(
"flex w-full items-center justify-between gap-1 rounded-full border-0 outline-none",
{
"px-3 py-2 text-13": input,
"px-2 py-1 text-11": !input,
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
"cursor-pointer": !disabled,
},
buttonClassName
)}
@ -146,25 +146,25 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
<Combobox.Options data-prevent-outside-click static>
<div
className={cn(
"z-30 my-1 min-w-48 overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 py-2.5 text-11 whitespace-nowrap focus:outline-none",
"nodedc-dropdown-surface z-30 my-1 min-w-48 overflow-y-scroll whitespace-nowrap focus:outline-none",
optionsClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="mx-2 flex items-center gap-1.5 rounded-sm border border-subtle px-2">
<div className="nodedc-dropdown-search mx-1">
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
placeholder="Поиск"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div
className={cn("vertical-scrollbar mt-2 scrollbar-xs space-y-1 overflow-y-scroll px-2", {
className={cn("vertical-scrollbar mt-2 scrollbar-xs space-y-1 overflow-y-scroll px-1", {
"max-h-96": maxHeight === "2xl",
"max-h-80": maxHeight === "xl",
"max-h-60": maxHeight === "lg",
@ -181,9 +181,9 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
value={option.value}
className={({ active }) =>
cn(
"flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none",
"nodedc-dropdown-option",
{
"bg-layer-transparent-hover": active,
"bg-white/6": active,
"cursor-not-allowed text-placeholder opacity-60": option.disabled,
}
)