[WEB-1255] chore: public and unlocked views (#4932)

* chore private and public views required changes

* fix slight alignment of view icons

* add feedback for update View button

* fix object reference sharing by using cloneDeep to replicate objects

* addressing review comments
This commit is contained in:
rahulramesha 2024-06-25 18:21:30 +05:30 committed by GitHub
parent 711494b72e
commit 635efeab7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 524 additions and 240 deletions

View file

@ -1,3 +1,4 @@
import { EViewAccess } from "@/constants/views";
import { TLogoProps } from "./common"; import { TLogoProps } from "./common";
import { import {
IIssueDisplayFilterOptions, IIssueDisplayFilterOptions,
@ -7,7 +8,7 @@ import {
export interface IProjectView { export interface IProjectView {
id: string; id: string;
access: string; access: EViewAccess;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
is_favorite: boolean; is_favorite: boolean;
@ -23,4 +24,6 @@ export interface IProjectView {
project: string; project: string;
workspace: string; workspace: string;
logo_props: TLogoProps | undefined; logo_props: TLogoProps | undefined;
is_locked: boolean;
owned_by: string;
} }

View file

@ -1,3 +1,4 @@
import { EViewAccess } from "@/constants/views";
import { import {
IWorkspaceViewProps, IWorkspaceViewProps,
IIssueDisplayFilterOptions, IIssueDisplayFilterOptions,
@ -7,7 +8,7 @@ import {
export interface IWorkspaceView { export interface IWorkspaceView {
id: string; id: string;
access: string; access: EViewAccess;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
is_favorite: boolean; is_favorite: boolean;
@ -22,6 +23,8 @@ export interface IWorkspaceView {
query_data: IWorkspaceViewProps; query_data: IWorkspaceViewProps;
project: string; project: string;
workspace: string; workspace: string;
is_locked: boolean;
owned_by: string;
workspace_detail?: { workspace_detail?: {
id: string; id: string;
name: string; name: string;

View file

@ -31,7 +31,7 @@ export const DropdownButton: React.FC<IMultiSelectDropdownButton | ISingleSelect
)} )}
onClick={handleOnClick} onClick={handleOnClick}
> >
{buttonContent ? <>{buttonContent(isOpen)}</> : <span className={cn("", buttonClassName)}>{value}</span>} {buttonContent ? <>{buttonContent(isOpen, value)}</> : <span className={cn("", buttonClassName)}>{value}</span>}
</button> </button>
</Combobox.Button> </Combobox.Button>
); );

View file

@ -22,6 +22,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
disableSearch, disableSearch,
keyExtractor, keyExtractor,
options, options,
handleClose,
value, value,
renderItem, renderItem,
loader, loader,
@ -46,7 +47,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
options?.map((option) => ( options?.map((option) => (
<Combobox.Option <Combobox.Option
key={keyExtractor(option)} key={keyExtractor(option)}
value={option.data[option.value]} value={keyExtractor(option)}
className={({ active, selected }) => className={({ active, selected }) =>
cn( cn(
"flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5", "flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
@ -58,14 +59,15 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
option.className && option.className({ active, selected }) option.className && option.className({ active, selected })
) )
} }
onClick={handleClose}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
{renderItem ? ( {renderItem ? (
<>{renderItem({ value: option.data[option.value], selected })}</> <>{renderItem({ value: keyExtractor(option), selected })}</>
) : ( ) : (
<> <>
<span className="flex-grow truncate">{value}</span> <span className="flex-grow truncate">{option.value}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</> </>
)} )}

View file

@ -10,7 +10,7 @@ export interface IDropdown {
disabled?: boolean; disabled?: boolean;
// button props // button props
buttonContent?: (isOpen: boolean) => React.ReactNode; buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode;
buttonContainerClassName?: string; buttonContainerClassName?: string;
buttonClassName?: string; buttonClassName?: string;
@ -24,8 +24,8 @@ export interface IDropdown {
// options props // options props
keyExtractor: (option: TDropdownOption) => string; keyExtractor: (option: TDropdownOption) => string;
optionsContainerClassName?: string; optionsContainerClassName?: string;
queryArray: string[]; queryArray?: string[];
sortByKey: string; sortByKey?: string;
firstItem?: (optionValue: string) => boolean; firstItem?: (optionValue: string) => boolean;
renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode;
loader?: React.ReactNode; loader?: React.ReactNode;
@ -52,7 +52,7 @@ export interface ISingleSelectDropdown extends IDropdown {
export interface IDropdownButton { export interface IDropdownButton {
isOpen: boolean; isOpen: boolean;
buttonContent?: (isOpen: boolean) => React.ReactNode; buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode;
buttonClassName?: string; buttonClassName?: string;
buttonContainerClassName?: string; buttonContainerClassName?: string;
handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; handleOnClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
@ -79,6 +79,8 @@ export interface IDropdownOptions {
inputContainerClassName?: string; inputContainerClassName?: string;
disableSearch?: boolean; disableSearch?: boolean;
handleClose?: () => void;
keyExtractor: (option: TDropdownOption) => string; keyExtractor: (option: TDropdownOption) => string;
renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined; renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined;
options: TDropdownOption[] | undefined; options: TDropdownOption[] | undefined;

View file

@ -90,10 +90,12 @@ export const MultiSelectDropdown: FC<IMultiSelectDropdown> = (props) => {
const sortedOptions = useMemo(() => { const sortedOptions = useMemo(() => {
if (!options) return undefined; if (!options) return undefined;
const filteredOptions = (options || []).filter((options) => { const filteredOptions = queryArray
const queryString = queryArray.map((query) => options.data[query]).join(" "); ? (options || []).filter((options) => {
return queryString.toLowerCase().includes(query.toLowerCase()); const queryString = queryArray.map((query) => options.data[query]).join(" ");
}); return queryString.toLowerCase().includes(query.toLowerCase());
})
: options;
if (disableSorting) return filteredOptions; if (disableSorting) return filteredOptions;

View file

@ -90,12 +90,14 @@ export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
const sortedOptions = useMemo(() => { const sortedOptions = useMemo(() => {
if (!options) return undefined; if (!options) return undefined;
const filteredOptions = (options || []).filter((options) => { const filteredOptions = queryArray
const queryString = queryArray.map((query) => options.data[query]).join(" "); ? (options || []).filter((options) => {
return queryString.toLowerCase().includes(query.toLowerCase()); const queryString = queryArray.map((query) => options.data[query]).join(" ");
}); return queryString.toLowerCase().includes(query.toLowerCase());
})
: options;
if (disableSorting) return filteredOptions; if (disableSorting || !sortByKey) return filteredOptions;
return sortBy(filteredOptions, [ return sortBy(filteredOptions, [
(option) => firstItem && firstItem(option.data[option.value]), (option) => firstItem && firstItem(option.data[option.value]),
@ -136,7 +138,7 @@ export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
<Combobox.Options className="fixed z-10" static> <Combobox.Options className="fixed z-10" static>
<div <div
className={cn( className={cn(
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none", "my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg focus:outline-none",
optionsContainerClassName optionsContainerClassName
)} )}
ref={setPopperElement} ref={setPopperElement}
@ -157,6 +159,7 @@ export const Dropdown: FC<ISingleSelectDropdown> = (props) => {
value={value} value={value}
renderItem={renderItem} renderItem={renderItem}
loader={loader} loader={loader}
handleClose={handleClose}
/> />
</div> </div>
</Combobox.Options> </Combobox.Options>

View file

@ -4,10 +4,11 @@ import { useCallback } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Earth, Lock } from "lucide-react";
// types // types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui // ui
import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui"; import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon, Tooltip } from "@plane/ui";
// components // components
import { BreadcrumbLink, Logo } from "@/components/common"; import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
@ -19,6 +20,7 @@ import {
ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_DISPLAY_FILTERS_BY_LAYOUT,
} from "@/constants/issue"; } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
import { EViewAccess } from "@/constants/views";
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
import { truncateText } from "@/helpers/string.helper"; import { truncateText } from "@/helpers/string.helper";
@ -205,54 +207,64 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
} }
/> />
</Breadcrumbs> </Breadcrumbs>
<div className="cursor-default text-custom-text-300">
<Tooltip tooltipContent={viewDetails?.access === EViewAccess.PUBLIC ? "Public" : "Private"}>
{viewDetails?.access === EViewAccess.PUBLIC ? <Earth className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Tooltip>
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LayoutSelection {!viewDetails?.is_locked && (
layouts={[ <>
EIssueLayoutTypes.LIST, <LayoutSelection
EIssueLayoutTypes.KANBAN, layouts={[
EIssueLayoutTypes.CALENDAR, EIssueLayoutTypes.LIST,
EIssueLayoutTypes.SPREADSHEET, EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.GANTT, EIssueLayoutTypes.CALENDAR,
]} EIssueLayoutTypes.SPREADSHEET,
onChange={(layout) => handleLayoutChange(layout)} EIssueLayoutTypes.GANTT,
selectedLayout={activeLayout} ]}
/> onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown <FiltersDropdown
title="Filters" title="Filters"
placement="bottom-end" placement="bottom-end"
disabled={!canUserCreateIssue} disabled={!canUserCreateIssue}
isFiltersApplied={isFiltersApplied} isFiltersApplied={isFiltersApplied}
> >
<FilterSelection <FilterSelection
filters={issueFilters?.filters ?? {}} filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate} handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}} displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters} handleDisplayFiltersUpdate={handleDisplayFilters}
layoutDisplayFiltersOptions={ layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
} }
labels={projectLabels} labels={projectLabels}
memberIds={projectMemberIds ?? undefined} memberIds={projectMemberIds ?? undefined}
states={projectStates} states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view} cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view} moduleViewDisabled={!currentProjectDetails?.module_view}
/> />
</FiltersDropdown> </FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end"> <FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection <DisplayFiltersSelection
layoutDisplayFiltersOptions={ layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
} }
displayFilters={issueFilters?.displayFilters ?? {}} displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters} handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}} displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties} handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view} cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view} moduleViewDisabled={!currentProjectDetails?.module_view}
/> />
</FiltersDropdown> </FiltersDropdown>
</>
)}
{canUserCreateIssue && ( {canUserCreateIssue && (
<Button <Button
onClick={() => { onClick={() => {

View file

@ -33,15 +33,17 @@ const ProjectViewIssuesPage = observer(() => {
); );
if (error) { if (error) {
<EmptyState return (
image={emptyView} <EmptyState
title="View does not exist" image={emptyView}
description="The view you are looking for does not exist or has been deleted." title="View does not exist"
primaryButton={{ description="The view you are looking for does not exist or you don't have permission to view it."
text: "View other views", primaryButton={{
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/views`), text: "View other views",
}} onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/views`),
/>; }}
/>
);
} }
return ( return (

View file

@ -17,7 +17,7 @@ import { EUserWorkspaceRoles } from "@/constants/workspace";
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
import { useLabel, useMember, useUser, useIssues } from "@/hooks/store"; import { useLabel, useMember, useUser, useIssues, useGlobalView } from "@/hooks/store";
export const GlobalIssuesHeader = observer(() => { export const GlobalIssuesHeader = observer(() => {
// states // states
@ -28,6 +28,7 @@ export const GlobalIssuesHeader = observer(() => {
const { const {
issuesFilter: { filters, updateFilters }, issuesFilter: { filters, updateFilters },
} = useIssues(EIssuesStoreType.GLOBAL); } = useIssues(EIssuesStoreType.GLOBAL);
const { getViewDetailsById } = useGlobalView();
const { const {
membership: { currentWorkspaceRole }, membership: { currentWorkspaceRole },
} = useUser(); } = useUser();
@ -38,6 +39,8 @@ export const GlobalIssuesHeader = observer(() => {
const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined; const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined;
const viewDetails = getViewDetailsById(globalViewId.toString());
const handleFiltersUpdate = useCallback( const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => { (key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !globalViewId) return; if (!workspaceSlug || !globalViewId) return;
@ -96,6 +99,7 @@ export const GlobalIssuesHeader = observer(() => {
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
const isLocked = viewDetails?.is_locked;
return ( return (
<> <>
@ -112,28 +116,30 @@ export const GlobalIssuesHeader = observer(() => {
</Breadcrumbs> </Breadcrumbs>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<> {!isLocked && (
<FiltersDropdown title="Filters" placement="bottom-end" isFiltersApplied={isFiltersApplied}> <>
<FilterSelection <FiltersDropdown title="Filters" placement="bottom-end" isFiltersApplied={isFiltersApplied}>
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_LAYOUT.my_issues.spreadsheet} <FilterSelection
filters={issueFilters?.filters ?? {}} layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_LAYOUT.my_issues.spreadsheet}
handleFiltersUpdate={handleFiltersUpdate} filters={issueFilters?.filters ?? {}}
displayFilters={issueFilters?.displayFilters ?? {}} handleFiltersUpdate={handleFiltersUpdate}
handleDisplayFiltersUpdate={handleDisplayFilters} displayFilters={issueFilters?.displayFilters ?? {}}
labels={workspaceLabels ?? undefined} handleDisplayFiltersUpdate={handleDisplayFilters}
memberIds={workspaceMemberIds ?? undefined} labels={workspaceLabels ?? undefined}
/> memberIds={workspaceMemberIds ?? undefined}
</FiltersDropdown> />
<FiltersDropdown title="Display" placement="bottom-end"> </FiltersDropdown>
<DisplayFiltersSelection <FiltersDropdown title="Display" placement="bottom-end">
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_LAYOUT.my_issues.spreadsheet} <DisplayFiltersSelection
displayFilters={issueFilters?.displayFilters ?? {}} layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_LAYOUT.my_issues.spreadsheet}
handleDisplayFiltersUpdate={handleDisplayFilters} displayFilters={issueFilters?.displayFilters ?? {}}
displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayFiltersUpdate={handleDisplayFilters}
handleDisplayPropertiesUpdate={handleDisplayProperties} displayProperties={issueFilters?.displayProperties ?? {}}
/> handleDisplayPropertiesUpdate={handleDisplayProperties}
</FiltersDropdown> />
</> </FiltersDropdown>
</>
)}
{isAuthorizedUser && ( {isAuthorizedUser && (
<Button variant="primary" size="sm" onClick={() => setCreateViewModal(true)}> <Button variant="primary" size="sm" onClick={() => setCreateViewModal(true)}>
Add View Add View

View file

@ -68,7 +68,7 @@ export const ListItem: FC<IListItemProps> = (props) => {
</ControlLink> </ControlLink>
{actionableItems && ( {actionableItems && (
<div className="absolute right-5 bottom-4 flex items-center gap-1.5"> <div className="absolute right-5 bottom-4 flex items-center gap-1.5">
<div className="relative flex items-center gap-4 sm:w-auto sm:flex-shrink-0 sm:justify-end"> <div className="relative flex items-center gap-4 sm:w-auto sm:flex-shrink-0 sm:justify-end items-center">
{actionableItems} {actionableItems}
</div> </div>
</div> </div>

View file

@ -0,0 +1,66 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { Check } from "lucide-react";
// plane packages
import { cn } from "@plane/editor";
import { Dropdown } from "@plane/ui";
// constants
import { EIssueLayoutTypes, ISSUE_LAYOUT_MAP } from "@/constants/issue";
type TLayoutDropDown = {
onChange: (value: EIssueLayoutTypes) => void;
value: EIssueLayoutTypes;
};
export const LayoutDropDown = observer((props: TLayoutDropDown) => {
const { onChange, value = EIssueLayoutTypes.LIST } = props;
const options = useMemo(
() =>
Object.values(ISSUE_LAYOUT_MAP).map((issueLayout) => ({
data: issueLayout.key,
value: issueLayout.key,
})),
[]
);
const buttonContent = useCallback((isOpen: boolean, buttonValue: string | string[] | undefined) => {
const dropdownValue = ISSUE_LAYOUT_MAP[buttonValue as EIssueLayoutTypes];
return (
<div className="flex gap-2 items-center text-custom-text-200">
<dropdownValue.icon strokeWidth={2} className={`size-3.5 text-custom-text-200`} />
<span className="font-medium text-xs">{dropdownValue.label}</span>
</div>
);
}, []);
const itemContent = useCallback((props: { value: string; selected: boolean }) => {
const dropdownValue = ISSUE_LAYOUT_MAP[props.value as EIssueLayoutTypes];
return (
<div className={cn("flex gap-2 items-center text-custom-text-200 w-full justify-between")}>
<div className="flex gap-2 items-center">
<dropdownValue.icon strokeWidth={2} className={`size-3 text-custom-text-200`} />
<span className="font-medium text-xs">{dropdownValue.label}</span>
</div>
{props.selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
);
}, []);
const keyExtractor = useCallback((option: any) => option.value, []);
return (
<Dropdown
onChange={onChange as (value: string) => void}
value={value?.toString()}
keyExtractor={keyExtractor}
options={options}
buttonContainerClassName="bg-custom-background-100 border border-custom-border-200 hover:bg-custom-background-90 focus:text-custom-text-300 focus:bg-custom-background-90 px-2 py-1.5 rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center relative"
buttonContent={buttonContent}
renderItem={itemContent}
disableSearch
/>
);
});

View file

@ -38,5 +38,5 @@ export const ButtonAvatars: React.FC<AvatarProps> = observer((props) => {
} }
} }
return Icon ? <Icon className="h-3 w-3 flex-shrink-0" /> : <Users className="h-3 w-3 flex-shrink-0" />; return Icon ? <Icon className="h-3 w-3 flex-shrink-0" /> : <Users className="h-3 w-3 mx-[4px] flex-shrink-0" />;
}); });

View file

@ -0,0 +1,18 @@
import { Lock } from "lucide-react";
import { Tooltip } from "@plane/ui";
export const LockedComponent = (props: { toolTipContent?: string }) => {
const { toolTipContent } = props;
const lockedComponent = (
<div className="flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<Lock className="h-3 w-3" />
<span>Locked</span>
</div>
);
return (
<>
{toolTipContent ? <Tooltip tooltipContent={toolTipContent}>{lockedComponent}</Tooltip> : <>{lockedComponent}</>}
</>
);
};

View file

@ -28,13 +28,22 @@ type Props = {
labels?: IIssueLabel[] | undefined; labels?: IIssueLabel[] | undefined;
states?: IState[] | undefined; states?: IState[] | undefined;
alwaysAllowEditing?: boolean; alwaysAllowEditing?: boolean;
disableEditing?: boolean;
}; };
const membersFilters = ["assignees", "mentions", "created_by", "subscriber"]; const membersFilters = ["assignees", "mentions", "created_by", "subscriber"];
const dateFilters = ["start_date", "target_date"]; const dateFilters = ["start_date", "target_date"];
export const AppliedFiltersList: React.FC<Props> = observer((props) => { export const AppliedFiltersList: React.FC<Props> = observer((props) => {
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states, alwaysAllowEditing } = props; const {
appliedFilters,
handleClearAllFilters,
handleRemoveFilter,
labels,
states,
alwaysAllowEditing,
disableEditing = false,
} = props;
// store hooks // store hooks
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
@ -44,7 +53,8 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
if (Object.keys(appliedFilters).length === 0) return null; if (Object.keys(appliedFilters).length === 0) return null;
const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER); const isEditingAllowed =
!disableEditing && (alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER));
return ( return (
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100 truncate"> <div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100 truncate">

View file

@ -1,21 +1,25 @@
"use client"; "use client";
import { useState } from "react";
import cloneDeep from "lodash/cloneDeep";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// types
import { cn } from "@plane/editor";
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types"; import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
// hooks
//ui //ui
import { Button } from "@plane/ui";
// components // components
import { AppliedFiltersList } from "@/components/issues"; import { AppliedFiltersList } from "@/components/issues";
// types import { UpdateViewComponent } from "@/components/views/update-view-component";
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace";
// constants
import { GLOBAL_VIEW_UPDATED } from "@/constants/event-tracker"; import { GLOBAL_VIEW_UPDATED } from "@/constants/event-tracker";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "@/constants/workspace"; import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "@/constants/workspace";
// constants // hooks
import { useEventTracker, useGlobalView, useIssues, useLabel, useUser } from "@/hooks/store"; import { useEventTracker, useGlobalView, useIssues, useLabel, useUser } from "@/hooks/store";
import { getAreFiltersEqual } from "../../../utils";
type Props = { type Props = {
globalViewId: string; globalViewId: string;
@ -33,11 +37,15 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
const { globalViewMap, updateGlobalView } = useGlobalView(); const { globalViewMap, updateGlobalView } = useGlobalView();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { const {
data,
membership: { currentWorkspaceRole }, membership: { currentWorkspaceRole },
} = useUser(); } = useUser();
const [isModalOpen, setIsModalOpen] = useState(false);
// derived values // derived values
const userFilters = filters?.[globalViewId]?.filters; const issueFilters = filters?.[globalViewId];
const userFilters = issueFilters?.filters;
const viewDetails = globalViewMap[globalViewId]; const viewDetails = globalViewMap[globalViewId];
// filters whose value not null or empty array // filters whose value not null or empty array
@ -89,14 +97,15 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
); );
}; };
const viewFilters = {
filters: cloneDeep(appliedFilters ?? {}),
display_filters: cloneDeep(issueFilters?.displayFilters),
display_properties: cloneDeep(issueFilters?.displayProperties),
};
const handleUpdateView = () => { const handleUpdateView = () => {
if (!workspaceSlug || !globalViewId) return; if (!workspaceSlug || !globalViewId) return;
updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), { updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), viewFilters).then((res) => {
filters: {
...(appliedFilters ?? {}),
},
}).then((res) => {
if (res) if (res)
captureEvent(GLOBAL_VIEW_UPDATED, { captureEvent(GLOBAL_VIEW_UPDATED, {
view_id: res.id, view_id: res.id,
@ -107,34 +116,56 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
}); });
}; };
const areFiltersEqual = isEqual(appliedFilters ?? {}, viewDetails?.filters ?? {}); const areFiltersEqual = getAreFiltersEqual(appliedFilters, issueFilters, viewDetails);
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => view.key).includes(globalViewId as TStaticViewTypes); const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => view.key).includes(globalViewId as TStaticViewTypes);
const isLocked = viewDetails?.is_locked;
const isOwner = viewDetails?.owned_by === data?.id;
const areAppliedFiltersEmpty = isEmpty(appliedFilters);
// return if no filters are applied // return if no filters are applied
if (isEmpty(appliedFilters) && areFiltersEqual) return null; if (areAppliedFiltersEmpty && areFiltersEqual) return null;
return ( return (
<div className="flex items-start justify-between gap-4 p-4"> <>
<AppliedFiltersList <CreateUpdateWorkspaceViewModal
labels={workspaceLabels ?? undefined} isOpen={isModalOpen}
appliedFilters={appliedFilters ?? {}} onClose={() => setIsModalOpen(false)}
handleClearAllFilters={handleClearAllFilters} preLoadedData={{
handleRemoveFilter={handleRemoveFilter} name: `${viewDetails?.name} 2`,
alwaysAllowEditing description: viewDetails?.description,
...viewFilters,
}}
/> />
<div
className={cn("flex items-start justify-between gap-4 p-4", {
"justify-end": areAppliedFiltersEmpty,
})}
>
<AppliedFiltersList
labels={workspaceLabels ?? undefined}
appliedFilters={appliedFilters ?? {}}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
disableEditing={isLocked}
alwaysAllowEditing
/>
{!isDefaultView && !areFiltersEqual && isAuthorizedUser && ( {!isDefaultView && (
<> <UpdateViewComponent
<div /> isLocked={isLocked}
<Button variant="primary" onClick={handleUpdateView}> areFiltersEqual={!!areFiltersEqual}
Update view isOwner={isOwner}
</Button> isAuthorizedUser={isAuthorizedUser}
</> setIsModalOpen={setIsModalOpen}
)} handleUpdateView={handleUpdateView}
</div> />
)}
</div>
</>
); );
}); });

View file

@ -1,18 +1,22 @@
"use client"; "use client";
import { useState } from "react";
import cloneDeep from "lodash/cloneDeep";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { IIssueFilterOptions } from "@plane/types";
// hooks
import { Button } from "@plane/ui";
import { AppliedFiltersList } from "@/components/issues";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
import { useIssues, useLabel, useProjectState, useProjectView } from "@/hooks/store";
import { getAreFiltersEqual } from "../../../utils";
// components
// ui
// types // types
import { IIssueFilterOptions } from "@plane/types";
// components
import { AppliedFiltersList } from "@/components/issues";
import { CreateUpdateProjectViewModal } from "@/components/views";
import { UpdateViewComponent } from "@/components/views/update-view-component";
// constants
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
import { EUserWorkspaceRoles } from "@/constants/workspace";
// hooks
import { useIssues, useLabel, useProjectView, useUser } from "@/hooks/store";
import { getAreFiltersEqual } from "../../../utils";
export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => { export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
// router // router
@ -22,8 +26,13 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
issuesFilter: { issueFilters, updateFilters }, issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROJECT_VIEW); } = useIssues(EIssuesStoreType.PROJECT_VIEW);
const { projectLabels } = useLabel(); const { projectLabels } = useLabel();
const { projectStates } = useProjectState();
const { viewMap, updateView } = useProjectView(); const { viewMap, updateView } = useProjectView();
const {
data,
membership: { currentWorkspaceRole },
} = useUser();
const [isModalOpen, setIsModalOpen] = useState(false);
// derived values // derived values
const viewDetails = viewId ? viewMap[viewId.toString()] : null; const viewDetails = viewId ? viewMap[viewId.toString()] : null;
const userFilters = issueFilters?.filters; const userFilters = issueFilters?.filters;
@ -81,46 +90,56 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
}; };
const areFiltersEqual = getAreFiltersEqual(appliedFilters, issueFilters, viewDetails); const areFiltersEqual = getAreFiltersEqual(appliedFilters, issueFilters, viewDetails);
const viewFilters = {
filters: cloneDeep(appliedFilters ?? {}),
display_filters: cloneDeep(issueFilters?.displayFilters),
display_properties: cloneDeep(issueFilters?.displayProperties),
};
// return if no filters are applied // return if no filters are applied
if (isEmpty(appliedFilters) && areFiltersEqual) return null; if (isEmpty(appliedFilters) && areFiltersEqual) return null;
const handleUpdateView = () => { const handleUpdateView = () => {
if (!workspaceSlug || !projectId || !viewId || !viewDetails) return; if (!workspaceSlug || !projectId || !viewId || !viewDetails) return;
updateView(workspaceSlug.toString(), projectId.toString(), viewId.toString(), { updateView(workspaceSlug.toString(), projectId.toString(), viewId.toString(), viewFilters);
filters: {
...(appliedFilters ?? {}),
},
display_filters: {
...issueFilters?.displayFilters,
},
display_properties: {
...issueFilters?.displayProperties,
},
});
}; };
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
const isLocked = !!viewDetails?.is_locked;
const isOwner = viewDetails?.owned_by === data?.id;
return ( return (
<div className="flex justify-between gap-4 p-4"> <div className="flex justify-between gap-4 p-4">
<CreateUpdateProjectViewModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
preLoadedData={{
name: `${viewDetails?.name} 2`,
description: viewDetails?.description,
logo_props: viewDetails?.logo_props,
...viewFilters,
}}
/>
<div> <div>
<AppliedFiltersList <AppliedFiltersList
appliedFilters={appliedFilters ?? {}} appliedFilters={appliedFilters ?? {}}
handleClearAllFilters={handleClearAllFilters} handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter} handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []} labels={projectLabels ?? []}
states={projectStates} disableEditing={isLocked}
alwaysAllowEditing
/> />
</div> </div>
<UpdateViewComponent
{!areFiltersEqual && ( isLocked={isLocked}
<div> areFiltersEqual={!!areFiltersEqual}
<Button variant="primary" size="sm" className="flex-shrink-0" onClick={handleUpdateView}> isOwner={isOwner}
Update view isAuthorizedUser={isAuthorizedUser}
</Button> setIsModalOpen={setIsModalOpen}
</div> handleUpdateView={handleUpdateView}
)} />
</div> </div>
); );
}); });

View file

@ -4,6 +4,7 @@ import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tre
import clone from "lodash/clone"; import clone from "lodash/clone";
import concat from "lodash/concat"; import concat from "lodash/concat";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import isNil from "lodash/isNil";
import pull from "lodash/pull"; import pull from "lodash/pull";
import uniq from "lodash/uniq"; import uniq from "lodash/uniq";
import scrollIntoView from "smooth-scroll-into-view-if-needed"; import scrollIntoView from "smooth-scroll-into-view-if-needed";
@ -21,6 +22,7 @@ import {
IIssueFilters, IIssueFilters,
IProjectView, IProjectView,
TGroupedIssues, TGroupedIssues,
IWorkspaceView,
} from "@plane/types"; } from "@plane/types";
// ui // ui
import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui"; import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui";
@ -570,11 +572,24 @@ export const handleGroupDragDrop = async (
export const getAreFiltersEqual = ( export const getAreFiltersEqual = (
appliedFilters: IIssueFilterOptions | undefined, appliedFilters: IIssueFilterOptions | undefined,
issueFilters: IIssueFilters | undefined, issueFilters: IIssueFilters | undefined,
viewDetails: IProjectView | null viewDetails: IProjectView | IWorkspaceView | null
) => ) => {
isEqual(appliedFilters ?? {}, viewDetails?.filters ?? {}) && if (isNil(appliedFilters) || isNil(issueFilters) || isNil(viewDetails)) return true;
isEqual(issueFilters?.displayFilters ?? {}, viewDetails?.display_filters ?? {}) &&
isEqual(issueFilters?.displayProperties ?? {}, viewDetails?.display_properties ?? {}); return (
isEqual(appliedFilters, viewDetails.filters) &&
isEqual(issueFilters.displayFilters, viewDetails.display_filters) &&
isEqual(removeNillKeys(issueFilters.displayProperties), removeNillKeys(viewDetails.display_properties))
);
};
/**
* method that removes Null or undefined Keys from object
* @param obj
* @returns
*/
export const removeNillKeys = <T,>(obj: T) =>
Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value)));
/** /**
* This Method returns if the the grouped values are subGrouped * This Method returns if the the grouped values are subGrouped

View file

@ -2,13 +2,14 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Lock, Sparkle } from "lucide-react"; import { Sparkle } from "lucide-react";
// editor // editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// ui // ui
import { ArchiveIcon } from "@plane/ui"; import { ArchiveIcon } from "@plane/ui";
// components // components
import { GptAssistantPopover } from "@/components/core"; import { GptAssistantPopover } from "@/components/core";
import { LockedComponent } from "@/components/icons/locked-component";
import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages"; import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
// helpers // helpers
import { renderFormattedDate } from "@/helpers/date-time.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper";
@ -40,12 +41,7 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
return ( return (
<div className="flex flex-grow items-center justify-end gap-3"> <div className="flex flex-grow items-center justify-end gap-3">
{is_locked && ( {is_locked && <LockedComponent />}
<div className="flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<Lock className="h-3 w-3" />
<span>Locked</span>
</div>
)}
{archived_at && ( {archived_at && (
<div className="flex h-7 items-center gap-2 rounded-full bg-blue-500/20 px-3 py-0.5 text-xs font-medium text-blue-500"> <div className="flex h-7 items-center gap-2 rounded-full bg-blue-500/20 px-3 py-0.5 text-xs font-medium text-blue-500">
<ArchiveIcon className="h-3 w-3" /> <ArchiveIcon className="h-3 w-3" />

View file

@ -11,11 +11,12 @@ import { Button, EmojiIconPicker, EmojiIconPickerTypes, Input, PhotoFilterIcon,
import { Logo } from "@/components/common"; import { Logo } from "@/components/common";
import { AppliedFiltersList, FilterSelection, FiltersDropdown } from "@/components/issues"; import { AppliedFiltersList, FilterSelection, FiltersDropdown } from "@/components/issues";
// constants // constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers // helpers
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
// hooks // hooks
import { useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; import { useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
import { LayoutDropDown } from "../dropdowns/layout";
type Props = { type Props = {
data?: IProjectView | null; data?: IProjectView | null;
@ -190,7 +191,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
/> />
)} )}
/> />
<span className="text-xs text-red-500">{errors?.name?.message}</span> <span className="text-xs text-red-500">{errors?.name?.message?.toString()}</span>
</div> </div>
</div> </div>
<div> <div>
@ -211,7 +212,17 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
)} )}
/> />
</div> </div>
<div> <div className="flex gap-2">
<Controller
control={control}
name="display_filters.layout"
render={({ field: { onChange, value } }) => (
<LayoutDropDown
onChange={(selectedValue: EIssueLayoutTypes) => onChange(selectedValue)}
value={value}
/>
)}
/>
<Controller <Controller
control={control} control={control}
name="filters" name="filters"

View file

@ -0,0 +1,70 @@
import { SetStateAction, useEffect, useState } from "react";
import { Button } from "@plane/ui";
import { LockedComponent } from "../icons/locked-component";
type Props = {
isLocked: boolean;
areFiltersEqual: boolean;
isOwner: boolean;
isAuthorizedUser: boolean;
setIsModalOpen: (value: SetStateAction<boolean>) => void;
handleUpdateView: () => void;
lockedTooltipContent?: string;
};
export const UpdateViewComponent = (props: Props) => {
const {
isLocked,
areFiltersEqual,
isOwner,
isAuthorizedUser,
setIsModalOpen,
handleUpdateView,
lockedTooltipContent,
} = props;
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (areFiltersEqual) {
setIsUpdating(false);
}
}, [areFiltersEqual]);
// Change state while updating view to have a feedback
const updateButton = isUpdating ? (
<Button variant="primary" size="sm" className="flex-shrink-0">
Updating...
</Button>
) : (
<Button
variant="primary"
size="sm"
className="flex-shrink-0"
onClick={() => {
setIsUpdating(true);
handleUpdateView();
}}
>
Update view
</Button>
);
return (
<div className="flex gap-2">
{isLocked ? (
<LockedComponent toolTipContent={lockedTooltipContent} />
) : (
!areFiltersEqual &&
isAuthorizedUser && (
<>
<Button variant="outline-primary" size="sm" className="flex-shrink-0" onClick={() => setIsModalOpen(true)}>
Save as
</Button>
{isOwner && <>{updateButton}</>}
</>
)
)}
</div>
);
};

View file

@ -1,14 +1,16 @@
import React, { FC, useState } from "react"; import React, { FC, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Earth, Lock } from "lucide-react";
// types // types
import { IProjectView } from "@plane/types"; import { IProjectView } from "@plane/types";
// ui // ui
import { FavoriteStar } from "@plane/ui"; import { Tooltip, FavoriteStar } from "@plane/ui";
// components // components
import { DeleteProjectViewModal, CreateUpdateProjectViewModal, ViewQuickActions } from "@/components/views"; import { DeleteProjectViewModal, CreateUpdateProjectViewModal, ViewQuickActions } from "@/components/views";
// constants // constants
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
import { EViewAccess } from "@/constants/views";
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
@ -39,6 +41,8 @@ export const ViewListItemAction: FC<Props> = observer((props) => {
const totalFilters = calculateTotalFilters(view.filters ?? {}); const totalFilters = calculateTotalFilters(view.filters ?? {});
const access = view.access;
// handlers // handlers
const handleAddToFavorites = () => { const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -70,8 +74,14 @@ export const ViewListItemAction: FC<Props> = observer((props) => {
{totalFilters} {totalFilters === 1 ? "filter" : "filters"} {totalFilters} {totalFilters === 1 ? "filter" : "filters"}
</p> </p>
<div className="cursor-default text-custom-text-300">
<Tooltip tooltipContent={access === EViewAccess.PUBLIC ? "Public" : "Private"}>
{access === EViewAccess.PUBLIC ? <Earth className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Tooltip>
</div>
{/* created by */} {/* created by */}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />} {<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id ?? []} />}
{isEditingAllowed && ( {isEditingAllowed && (
<FavoriteStar <FavoriteStar

View file

@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react"; import { ExternalLink, LinkIcon, Pencil, Trash2, Lock } from "lucide-react";
// types // types
import { IWorkspaceView } from "@plane/types"; import { IWorkspaceView } from "@plane/types";
// ui // ui
@ -12,6 +12,7 @@ import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from
import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "@/components/workspace"; import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "@/components/workspace";
// constants // constants
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
import { EViewAccess } from "@/constants/views";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper";
@ -78,6 +79,34 @@ export const WorkspaceViewQuickActions: React.FC<Props> = observer((props) => {
}, },
]; ];
const isSelected = viewId === globalViewId;
const isPrivateView = view.access === EViewAccess.PRIVATE;
let customButton = (
<div
className={`flex gap-1 items-center flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${
isSelected
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 hover:text-custom-text-400"
} ${isPrivateView ? "pr-2" : ""}`}
>
<span className={`flex min-w-min flex-shrink-0 whitespace-nowrap text-sm font-medium outline-none`}>
{view.name}
</span>
{isPrivateView && (
<Lock className={`${isSelected ? "text-custom-primary-100" : "text-custom-text-400"} h-4 w-4`} />
)}
</div>
);
if (!isSelected) {
customButton = (
<Link key={viewId} id={`global-view-${viewId}`} href={`/${workspaceSlug}/workspace-views/${viewId}`}>
{customButton}
</Link>
);
}
return ( return (
<> <>
<CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} /> <CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} />
@ -85,38 +114,7 @@ export const WorkspaceViewQuickActions: React.FC<Props> = observer((props) => {
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} /> <ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu <CustomMenu customButton={customButton} placement="bottom-end" menuItemsClassName="z-20" closeOnSelect>
customButton={
<>
{viewId === globalViewId ? (
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${
viewId === globalViewId
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 hover:text-custom-text-400"
}`}
>
{view.name}
</span>
) : (
<Link key={viewId} id={`global-view-${viewId}`} href={`/${workspaceSlug}/workspace-views/${viewId}`}>
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${
viewId === globalViewId
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 hover:text-custom-text-400"
}`}
>
{view.name}
</span>
</Link>
)}
</>
}
placement="bottom-end"
menuItemsClassName="z-20"
closeOnSelect
>
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null; if (item.shouldRender === false) return null;
return ( return (

View file

@ -138,17 +138,34 @@ export const ISSUE_EXTRA_OPTIONS: {
{ key: "show_empty_groups", title: "Show empty groups" }, // filter on front-end { key: "show_empty_groups", title: "Show empty groups" }, // filter on front-end
]; ];
export const ISSUE_LAYOUT_MAP = {
[EIssueLayoutTypes.LIST]: { key: EIssueLayoutTypes.LIST, title: "List Layout", label: "List", icon: List },
[EIssueLayoutTypes.KANBAN]: { key: EIssueLayoutTypes.KANBAN, title: "Kanban Layout", label: "Kanban", icon: Kanban },
[EIssueLayoutTypes.CALENDAR]: {
key: EIssueLayoutTypes.CALENDAR,
title: "Calendar Layout",
label: "Calendar",
icon: Calendar,
},
[EIssueLayoutTypes.SPREADSHEET]: {
key: EIssueLayoutTypes.SPREADSHEET,
title: "Spreadsheet Layout",
label: "Spreadsheet",
icon: Sheet,
},
[EIssueLayoutTypes.GANTT]: {
key: EIssueLayoutTypes.GANTT,
title: "Gantt Chart Layout",
label: "Gantt",
icon: GanttChartSquare,
},
};
export const ISSUE_LAYOUTS: { export const ISSUE_LAYOUTS: {
key: EIssueLayoutTypes; key: EIssueLayoutTypes;
title: string; title: string;
icon: any; icon: any;
}[] = [ }[] = Object.values(ISSUE_LAYOUT_MAP);
{ key: EIssueLayoutTypes.LIST, title: "List Layout", icon: List },
{ key: EIssueLayoutTypes.KANBAN, title: "Kanban Layout", icon: Kanban },
{ key: EIssueLayoutTypes.CALENDAR, title: "Calendar Layout", icon: Calendar },
{ key: EIssueLayoutTypes.SPREADSHEET, title: "Spreadsheet Layout", icon: Sheet },
{ key: EIssueLayoutTypes.GANTT, title: "Gantt Chart Layout", icon: GanttChartSquare },
];
export interface ILayoutDisplayFiltersOptions { export interface ILayoutDisplayFiltersOptions {
filters: (keyof IIssueFilterOptions)[]; filters: (keyof IIssueFilterOptions)[];

View file

@ -0,0 +1,4 @@
export enum EViewAccess {
PRIVATE,
PUBLIC,
}

View file

@ -240,10 +240,6 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I
); );
} }
await this.issueFilterService.patchView(workspaceSlug, projectId, viewId, {
display_filters: _filters.displayFilters,
});
break; break;
} }
case EIssueFilterType.DISPLAY_PROPERTIES: { case EIssueFilterType.DISPLAY_PROPERTIES: {
@ -260,9 +256,6 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I
}); });
}); });
await this.issueFilterService.patchView(workspaceSlug, projectId, viewId, {
display_properties: _filters.displayProperties,
});
break; break;
} }
case EIssueFilterType.KANBAN_FILTERS: { case EIssueFilterType.KANBAN_FILTERS: {

View file

@ -249,11 +249,6 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, { this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, {
display_filters: _filters.displayFilters, display_filters: _filters.displayFilters,
}); });
else
await this.issueFilterService.updateView(workspaceSlug, viewId, {
display_filters: _filters.displayFilters,
});
break; break;
} }
case EIssueFilterType.DISPLAY_PROPERTIES: { case EIssueFilterType.DISPLAY_PROPERTIES: {
@ -268,15 +263,11 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
updatedDisplayProperties[_key as keyof IIssueDisplayProperties] updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
); );
}); });
if (["all-issues", "assigned", "created", "subscribed"].includes(viewId))
this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, {
display_properties: _filters.displayProperties,
});
}); });
if (["all-issues", "assigned", "created", "subscribed"].includes(viewId))
this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, {
display_properties: _filters.displayProperties,
});
else
await this.issueFilterService.updateView(workspaceSlug, viewId, {
display_properties: _filters.displayProperties,
});
break; break;
} }