[WEB-1255] feat: filters for project views and changes required for advanced views (#4949)

* View with filters and changes required for advanced views

* minor refactoring of views

* minor name change
This commit is contained in:
rahulramesha 2024-06-27 14:40:14 +05:30 committed by GitHub
parent adaf3b15de
commit 82661589fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 922 additions and 146 deletions

View file

@ -27,3 +27,21 @@ export interface IProjectView {
is_locked: boolean;
owned_by: string;
}
export type TViewFiltersSortKey = "name" | "created_at" | "updated_at";
export type TViewFiltersSortBy = "asc" | "desc";
export type TViewFilterProps = {
created_at?: string[] | null;
owned_by?: string[] | null;
favorites?: boolean;
accessTypes?: EViewAccess[];
};
export type TViewFilters = {
searchQuery: string;
sortKey: TViewFiltersSortKey;
sortBy: TViewFiltersSortBy;
filters?: TViewFilterProps;
};

View file

@ -1,16 +1,21 @@
"use client";
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { TViewFilterProps } from "@plane/types";
import { Breadcrumbs, PhotoFilterIcon, Button } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { ViewListHeader } from "@/components/views";
import { ViewAppliedFiltersList } from "@/components/views/filters/applied-filters";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useCommandPalette, useProject, useUser } from "@/hooks/store";
import { useCommandPalette, useProject, useProjectView, useUser } from "@/hooks/store";
export const ProjectViewsHeader = observer(() => {
// router
@ -21,8 +26,28 @@ export const ProjectViewsHeader = observer(() => {
membership: { currentProjectRole },
} = useUser();
const { currentProjectDetails, loader } = useProject();
const { filters, updateFilters, clearAllFilters } = useProjectView();
const canUserCreateIssue =
const handleRemoveFilter = useCallback(
(key: keyof TViewFilterProps, value: string | null) => {
let newValues = filters.filters?.[key];
if (key === "favorites") {
newValues = !!value;
}
if (Array.isArray(newValues)) {
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value) as string[];
}
updateFilters("filters", { [key]: newValues });
},
[filters.filters, updateFilters]
);
const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0;
const canUserCreateView =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return (
@ -56,17 +81,27 @@ export const ProjectViewsHeader = observer(() => {
</Breadcrumbs>
</div>
</div>
{canUserCreateIssue && (
<div className="flex flex-shrink-0 items-center gap-2">
<ViewListHeader />
<div className="flex flex-shrink-0 items-center gap-2">
<ViewListHeader />
{canUserCreateView && (
<div>
<Button variant="primary" size="sm" onClick={() => toggleCreateViewModal(true)}>
Add View
</Button>
</div>
</div>
)}
)}
</div>
</div>
{isFiltersApplied && (
<div className="border-t border-custom-border-200 px-5 py-3">
<ViewAppliedFiltersList
appliedFilters={filters.filters ?? {}}
handleClearAllFilters={clearAllFilters}
handleRemoveFilter={handleRemoveFilter}
alwaysAllowEditing
/>
</div>
)}
</>
);
});

View file

@ -27,11 +27,11 @@ import { useEventTracker, useUser, useUserProfile, useWorkspace } from "@/hooks/
import { useAppRouter } from "@/hooks/use-app-router";
// services
import { AuthenticationWrapper } from "@/lib/wrappers";
import { WorkspaceService } from "@/plane-web/services";
// images
import emptyInvitation from "@/public/empty-state/invitation.svg";
import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
import { WorkspaceService } from "@/services/workspace.service";
const workspaceService = new WorkspaceService();

View file

@ -18,8 +18,8 @@ import { useUser, useWorkspace, useUserProfile, useEventTracker, useUserSettings
import { useAppRouter } from "@/hooks/use-app-router";
// wrappers
import { AuthenticationWrapper } from "@/lib/wrappers";
import { WorkspaceService } from "@/plane-web/services";
// services
import { WorkspaceService } from "@/services/workspace.service";
enum EOnboardingSteps {
PROFILE_SETUP = "PROFILE_SETUP",

View file

@ -17,8 +17,8 @@ import { useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// wrappers
import { AuthenticationWrapper } from "@/lib/wrappers";
import { WorkspaceService } from "@/plane-web/services";
// services
import { WorkspaceService } from "@/services/workspace.service";
// service initialization
const workspaceService = new WorkspaceService();

View file

@ -1 +1,2 @@
export * from "./project";
export * from "./workspace.service";

View file

@ -1 +1,2 @@
export * from "./estimate.service";
export * from "./view.service";

View file

@ -0,0 +1,24 @@
import { EViewAccess } from "@/constants/views";
import { API_BASE_URL } from "@/helpers/common.helper";
import { ViewService as CoreViewService } from "@/services/view.service";
export class ViewService extends CoreViewService {
constructor() {
super(API_BASE_URL);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async updateViewAccess(workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async lockView(workspaceSlug: string, projectId: string, viewId: string) {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async unLockView(workspaceSlug: string, projectId: string, viewId: string) {
return Promise.resolve();
}
}

View file

@ -0,0 +1,24 @@
import { EViewAccess } from "@/constants/views";
import { API_BASE_URL } from "@/helpers/common.helper";
import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service";
export class WorkspaceService extends CoreWorkspaceService {
constructor() {
super(API_BASE_URL);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async updateViewAccess(workspaceSlug: string, viewId: string, access: EViewAccess) {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async lockView(workspaceSlug: string, viewId: string) {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async unLockView(workspaceSlug: string, viewId: string) {
return Promise.resolve();
}
}

View file

@ -6,8 +6,8 @@ import { LogoSpinner } from "@/components/common";
import { WorkspaceLogo } from "@/components/workspace/logo";
// helpers
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
import { WorkspaceService } from "@/plane-web/services";
// services
import { WorkspaceService } from "@/services/workspace.service";
type TAuthHeader = {
workspaceSlug: string | undefined;

View file

@ -30,8 +30,9 @@ import { useAppRouter } from "@/hooks/use-app-router";
import useDebounce from "@/hooks/use-debounce";
import { usePlatformOS } from "@/hooks/use-platform-os";
// services
import { WorkspaceService } from "@/plane-web/services";
import { IssueService } from "@/services/issue";
import { WorkspaceService } from "@/services/workspace.service";
// ui
// components
// types

View file

@ -1,11 +1,11 @@
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
// helpers
// constants
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { capitalizeFirstLetter } from "@/helpers/string.helper";
// constants
type Props = {
editable: boolean | undefined;

View file

@ -0,0 +1,2 @@
export * from "./date";
export * from "./members";

View file

@ -0,0 +1,2 @@
export * from "./created-at";
export * from "./created-by";

View file

@ -11,7 +11,7 @@ import { MarkdownRenderer } from "@/components/ui";
// icons
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
type Props = {
isOpen: boolean;

View file

@ -6,7 +6,7 @@ import { IGithubRepoCollaborator } from "@plane/types";
// services
import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui";
import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys";
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
// ui
// types
import { IUserDetails } from "./root";

View file

@ -8,7 +8,7 @@ import { IJiraImporterForm } from "@plane/types";
// services
import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui";
import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys";
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
// ui
// types
// fetch keys

View file

@ -15,7 +15,7 @@ import { UpdateViewComponent } from "@/components/views/update-view-component";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
import { EUserWorkspaceRoles } from "@/constants/workspace";
// hooks
import { useIssues, useLabel, useProjectView, useUser } from "@/hooks/store";
import { useIssues, useLabel, useProjectState, useProjectView, useUser } from "@/hooks/store";
import { getAreFiltersEqual } from "../../../utils";
export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
@ -26,6 +26,7 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
const { projectLabels } = useLabel();
const { projectStates } = useProjectState();
const { viewMap, updateView } = useProjectView();
const {
data,
@ -129,6 +130,7 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []}
states={projectStates}
disableEditing={isLocked}
/>
</div>

View file

@ -13,7 +13,7 @@ import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace";
// hooks
import { useEventTracker, useUserProfile, useUserSettings, useWorkspace } from "@/hooks/store";
// services
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
type Props = {
stepChange: (steps: Partial<TOnboardingSteps>) => Promise<void>;

View file

@ -16,7 +16,7 @@ import { getUserRole } from "@/helpers/user.helper";
// hooks
import { useEventTracker, useWorkspace } from "@/hooks/store";
// services
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
type Props = {
handleNextStep: () => Promise<void>;

View file

@ -30,10 +30,10 @@ import { getUserRole } from "@/helpers/user.helper";
import { useEventTracker } from "@/hooks/store";
import useDynamicDropdownPosition from "@/hooks/use-dynamic-dropdown";
// services
import { WorkspaceService } from "@/plane-web/services";
// assets
import InviteMembersDark from "@/public/onboarding/invite-members-dark.svg";
import InviteMembersLight from "@/public/onboarding/invite-members-light.svg";
import { WorkspaceService } from "@/services/workspace.service";
// components
import { OnboardingHeader } from "./header";
import { SwitchAccountDropdown } from "./switch-account-dropdown";

View file

@ -1,3 +1 @@
export * from "./date";
export * from "./members";
export * from "./root";

View file

@ -1,7 +1,7 @@
import { X } from "lucide-react";
import { TPageFilterProps } from "@plane/types";
// components
import { AppliedDateFilters, AppliedMembersFilters } from "@/components/pages";
import { AppliedDateFilters, AppliedMembersFilters } from "@/components/common/applied-filters";
// helpers
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
// types

View file

@ -1,3 +1 @@
export * from "./created-at";
export * from "./created-by";
export * from "./root";

View file

@ -3,8 +3,8 @@ import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
import { TPageFilterProps, TPageFilters } from "@plane/types";
// components
import { FilterCreatedBy, FilterCreatedDate } from "@/components/common/filters";
import { FilterOption } from "@/components/issues";
import { FilterCreatedBy, FilterCreatedDate } from "@/components/pages";
type Props = {
filters: TPageFilters;

View file

@ -0,0 +1,81 @@
import { X } from "lucide-react";
import { TViewFilterProps } from "@plane/types";
// components
import { AppliedDateFilters, AppliedMembersFilters } from "@/components/common/applied-filters";
// helpers
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
// types
type Props = {
appliedFilters: TViewFilterProps;
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TViewFilterProps, value: string | null) => void;
alwaysAllowEditing?: boolean;
};
const MEMBERS_FILTERS = ["owned_by"];
const DATE_FILTERS = ["created_at"];
export const ViewAppliedFiltersList: React.FC<Props> = (props) => {
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
if (!appliedFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null;
const isEditingAllowed = alwaysAllowEditing;
return (
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TViewFilterProps;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<div
key={filterKey}
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={Array.isArray(value) ? (value as string[]) : []}
/>
)}
{MEMBERS_FILTERS.includes(filterKey) && (
<AppliedMembersFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={Array.isArray(value) ? (value as string[]) : []}
/>
)}
{isEditingAllowed && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
);
})}
{isEditingAllowed && (
<button
type="button"
onClick={handleClearAllFilters}
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
>
Clear all
<X size={12} strokeWidth={2} />
</button>
)}
</div>
);
};

View file

@ -0,0 +1,101 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
import { TViewFilterProps, TViewFilters } from "@plane/types";
// components
import { FilterCreatedBy, FilterCreatedDate } from "@/components/common/filters";
import { FilterOption } from "@/components/issues";
// constants
import { EViewAccess } from "@/constants/views";
type Props = {
filters: TViewFilters;
handleFiltersUpdate: <T extends keyof TViewFilters>(filterKey: T, filterValue: TViewFilters[T]) => void;
memberIds?: string[] | undefined;
};
export const ViewFiltersSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate, memberIds } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
// handles filter update
const handleFilters = (key: keyof TViewFilterProps, value: boolean | string | EViewAccess | string[]) => {
const currValues = (filters.filters?.[key] ?? []) as (string | EViewAccess)[];
if (typeof currValues === "boolean" && typeof value === "boolean") return;
if (Array.isArray(currValues)) {
if (Array.isArray(value)) {
value.forEach((val) => {
if (!currValues.includes(val)) currValues.push(val);
else currValues.splice(currValues.indexOf(val), 1);
});
} else if (typeof value !== "boolean") {
if (currValues?.includes(value)) currValues.splice(currValues.indexOf(value), 1);
else currValues.push(value);
}
}
handleFiltersUpdate("filters", {
...filters.filters,
[key]: currValues,
});
};
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
<div className="py-2">
<FilterOption
isChecked={!!filters.filters?.favorites}
onClick={() =>
handleFiltersUpdate("filters", {
...filters.filters,
favorites: !filters.filters?.favorites,
})
}
title="Favorites"
/>
</div>
{/* created date */}
<div className="py-2">
<FilterCreatedDate
appliedFilters={filters.filters?.created_at ?? null}
handleUpdate={(val: string | string[]) => handleFilters("created_at", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* created by */}
<div className="py-2">
<FilterCreatedBy
appliedFilters={filters.filters?.owned_by ?? null}
handleUpdate={(val) => handleFilters("owned_by", val)}
searchQuery={filtersSearchQuery}
memberIds={memberIds}
/>
</div>
</div>
</div>
);
});

View file

@ -0,0 +1,75 @@
"use client";
import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react";
// types
import { TViewFiltersSortBy, TViewFiltersSortKey } from "@plane/types";
// ui
import { CustomMenu, getButtonStyling } from "@plane/ui";
// constants
import { VIEW_SORTING_KEY_OPTIONS } from "@/constants/views";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
onChange: (value: { key?: TViewFiltersSortKey; order?: TViewFiltersSortBy }) => void;
sortBy: TViewFiltersSortBy;
sortKey: TViewFiltersSortKey;
};
export const ViewOrderByDropdown: React.FC<Props> = (props) => {
const { onChange, sortBy, sortKey } = props;
const orderByDetails = VIEW_SORTING_KEY_OPTIONS.find((option) => sortKey === option.key);
const isDescending = sortBy === "desc";
const sortByOptions = [
{ key: "asc", label: "Ascending", isSelected: !isDescending },
{ key: "desc", label: "Descending", isSelected: isDescending },
];
return (
<CustomMenu
customButton={
<div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}>
<ArrowDownWideNarrow className="h-3 w-3" />
{orderByDetails?.label}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</div>
}
placement="bottom-end"
maxHeight="lg"
closeOnSelect
>
{VIEW_SORTING_KEY_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
className="flex items-center justify-between gap-2"
onClick={() =>
onChange({
key: option.key,
})
}
>
{option.label}
{sortKey === option.key && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
<hr className="my-2 border-custom-border-200" />
{sortByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
className="flex items-center justify-between gap-2"
onClick={() => {
if (!option.isSelected)
onChange({
order: option.key as TViewFiltersSortBy,
});
}}
>
{option.label}
{option.isSelected && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
</CustomMenu>
);
};

View file

@ -14,6 +14,7 @@ import { AppliedFiltersList, FilterSelection, FiltersDropdown } from "@/componen
import { EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@/helpers/issue.helper";
// hooks
import { useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
import { LayoutDropDown } from "../dropdowns/layout";
@ -28,6 +29,8 @@ type Props = {
const defaultValues: Partial<IProjectView> = {
name: "",
description: "",
display_properties: getComputedDisplayProperties(),
display_filters: getComputedDisplayFilters(),
};
export const ProjectViewForm: React.FC<Props> = observer((props) => {

View file

@ -51,14 +51,14 @@ export const UpdateViewComponent = (props: Props) => {
);
return (
<div className="flex gap-2">
<div className="flex gap-2 h-fit">
{isLocked ? (
<LockedComponent toolTipContent={lockedTooltipContent} />
) : (
!areFiltersEqual &&
isAuthorizedUser && (
<>
<Button variant="outline-primary" size="sm" className="flex-shrink-0" onClick={() => setIsModalOpen(true)}>
<Button variant="outline-primary" size="md" className="flex-shrink-0" onClick={() => setIsModalOpen(true)}>
Save as
</Button>
{isOwner && <>{updateButton}</>}

View file

@ -1,12 +1,15 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// icons
import { Search, X } from "lucide-react";
import { ListFilter, Search, X } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useProjectView } from "@/hooks/store";
import { useMember, useProjectView } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { FiltersDropdown } from "../issues";
import { ViewFiltersSelection } from "./filters/filter-selection";
import { ViewOrderByDropdown } from "./filters/order-by";
export const ViewListHeader = observer(() => {
// states
@ -14,13 +17,17 @@ export const ViewListHeader = observer(() => {
// refs
const inputRef = useRef<HTMLInputElement>(null);
// store hooks
const { searchQuery, updateSearchQuery } = useProjectView();
const { filters, updateFilters } = useProjectView();
const {
project: { projectMemberIds },
} = useMember();
// handlers
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else {
if (filters?.searchQuery && filters?.searchQuery.trim() !== "") {
updateFilters("searchQuery", "");
} else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
@ -29,12 +36,12 @@ export const ViewListHeader = observer(() => {
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
if (isSearchOpen && filters?.searchQuery.trim() === "") setIsSearchOpen(false);
});
useEffect(() => {
if (searchQuery.trim() !== "") setIsSearchOpen(true);
}, [searchQuery]);
if (filters?.searchQuery.trim() !== "") setIsSearchOpen(true);
}, [filters?.searchQuery]);
return (
<div className="h-full flex items-center gap-2">
@ -64,8 +71,8 @@ export const ViewListHeader = observer(() => {
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
value={filters?.searchQuery}
onChange={(e) => updateFilters("searchQuery", e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
@ -73,7 +80,7 @@ export const ViewListHeader = observer(() => {
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
updateFilters("searchQuery", "");
setIsSearchOpen(false);
}}
>
@ -82,6 +89,26 @@ export const ViewListHeader = observer(() => {
)}
</div>
</div>
<ViewOrderByDropdown
sortBy={filters.sortBy}
sortKey={filters.sortKey}
onChange={(val) => {
if (val.key) updateFilters("sortKey", val.key);
if (val.order) updateFilters("sortBy", val.order);
}}
/>
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={false}
>
<ViewFiltersSelection
filters={filters}
handleFiltersUpdate={updateFilters}
memberIds={projectMemberIds ?? undefined}
/>
</FiltersDropdown>
</div>
);
});

View file

@ -1,4 +1,6 @@
import { observer } from "mobx-react";
import Image from "next/image";
import { useParams } from "next/navigation";
// components
import { ListLayout } from "@/components/core/list";
import { EmptyState } from "@/components/empty-state";
@ -8,26 +10,48 @@ import { ProjectViewListItem } from "@/components/views";
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useCommandPalette, useProjectView } from "@/hooks/store";
// assets
import AllFiltersImage from "@/public/empty-state/pages/all-filters.svg";
import NameFilterImage from "@/public/empty-state/pages/name-filter.svg";
export const ProjectViewsList = observer(() => {
const { projectId } = useParams();
// store hooks
const { toggleCreateViewModal } = useCommandPalette();
const { projectViewIds, getViewById, loader, searchQuery } = useProjectView();
const { getProjectViews, getFilteredProjectViews, filters, loader } = useProjectView();
if (loader || !projectViewIds) return <ViewListLoader />;
const projectViews = getProjectViews(projectId?.toString());
const filteredProjectViews = getFilteredProjectViews(projectId?.toString());
// derived values
const viewsList = projectViewIds.map((viewId) => getViewById(viewId));
if (loader || !projectViews || !filteredProjectViews) return <ViewListLoader />;
const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(searchQuery.toLowerCase()));
if (filteredProjectViews.length === 0 && projectViews) {
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={filters.searchQuery.length > 0 ? NameFilterImage : AllFiltersImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching modules"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching views</h5>
<p className="text-custom-text-400 text-base">
{filters.searchQuery.length > 0
? "Remove the search criteria to see all views"
: "Remove the filters to see all views"}
</p>
</div>
</div>
);
}
return (
<>
{viewsList.length > 0 ? (
{filteredProjectViews.length > 0 ? (
<div className="flex h-full w-full flex-col">
<ListLayout>
{filteredViewsList.length > 0 ? (
filteredViewsList.map((view) => <ProjectViewListItem key={view.id} view={view} />)
{filteredProjectViews.length > 0 ? (
filteredProjectViews.map((view) => <ProjectViewListItem key={view.id} view={view} />)
) : (
<p className="mt-10 text-center text-sm text-custom-text-300">No results found</p>
)}

View file

@ -14,7 +14,7 @@ import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace";
import { useEventTracker, useWorkspace } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// services
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
type Props = {
onSubmit?: (res: IWorkspace) => Promise<void>;

View file

@ -11,6 +11,8 @@ import { Button, Input, TextArea } from "@plane/ui";
import { AppliedFiltersList, FilterSelection, FiltersDropdown } from "@/components/issues";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@/helpers/issue.helper";
// hooks
import { useLabel, useMember } from "@/hooks/store";
@ -24,6 +26,8 @@ type Props = {
const defaultValues: Partial<IWorkspaceView> = {
name: "",
description: "",
display_properties: getComputedDisplayProperties(),
display_filters: getComputedDisplayFilters(),
};
export const WorkspaceViewForm: React.FC<Props> = observer((props) => {

View file

@ -1,4 +1,33 @@
import { Globe2, Lock, LucideIcon } from "lucide-react";
import { TViewFiltersSortBy, TViewFiltersSortKey } from "@plane/types";
export enum EViewAccess {
PRIVATE,
PUBLIC,
}
export const VIEW_ACCESS_SPECIFIERS: {
key: EViewAccess;
label: string;
icon: LucideIcon;
}[] = [
{ key: EViewAccess.PUBLIC, label: "Public", icon: Globe2 },
{ key: EViewAccess.PRIVATE, label: "Private", icon: Lock },
];
export const VIEW_SORTING_KEY_OPTIONS: {
key: TViewFiltersSortKey;
label: string;
}[] = [
{ key: "name", label: "Name" },
{ key: "created_at", label: "Date created" },
{ key: "updated_at", label: "Date modified" },
];
export const VIEW_SORT_BY_OPTIONS: {
key: TViewFiltersSortBy;
label: string;
}[] = [
{ key: "asc", label: "Ascending" },
{ key: "desc", label: "Descending" },
];

View file

@ -1,12 +1,11 @@
import { IProjectView } from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// types
// helpers
export class ViewService extends APIService {
constructor() {
super(API_BASE_URL);
constructor(baseUrl: string) {
super(baseUrl);
}
async createView(workspaceSlug: string, projectId: string, data: Partial<IProjectView>): Promise<any> {

View file

@ -13,14 +13,13 @@ import {
IWorkspaceView,
TIssuesResponse,
} from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// helpers
// types
export class WorkspaceService extends APIService {
constructor() {
super(API_BASE_URL);
constructor(baseUrl: string) {
super(baseUrl);
}
async userWorkspaces(): Promise<IWorkspace[]> {

View file

@ -7,8 +7,9 @@ import { computedFn } from "mobx-utils";
import { IIssueFilterOptions, IWorkspaceView } from "@plane/types";
// constants
import { EIssueFilterType } from "@/constants/issue";
import { EViewAccess } from "@/constants/views";
// services
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
// store
import { CoreRootStore } from "./root.store";
@ -130,14 +131,19 @@ export class GlobalViewStore implements IGlobalViewStore {
* @param workspaceSlug
* @param data
*/
createGlobalView = async (workspaceSlug: string, data: Partial<IWorkspaceView>): Promise<IWorkspaceView> =>
await this.workspaceService.createView(workspaceSlug, data).then((response) => {
runInAction(() => {
set(this.globalViewMap, response.id, response);
});
return response;
createGlobalView = async (workspaceSlug: string, data: Partial<IWorkspaceView>): Promise<IWorkspaceView> => {
const response = await this.workspaceService.createView(workspaceSlug, data);
runInAction(() => {
set(this.globalViewMap, response.id, response);
});
if (data.access === EViewAccess.PRIVATE) {
await this.updateViewAccess(workspaceSlug, response.id, EViewAccess.PRIVATE);
}
return response;
};
/**
* @description update global view
* @param workspaceSlug
@ -155,7 +161,16 @@ export class GlobalViewStore implements IGlobalViewStore {
const currentKey = key as keyof IWorkspaceView;
set(this.globalViewMap, [viewId, currentKey], data[currentKey]);
});
const currentView = await this.workspaceService.updateView(workspaceSlug, viewId, data);
const promiseRequests = [];
promiseRequests.push(this.workspaceService.updateView(workspaceSlug, viewId, data));
if (data.access !== undefined && data.access !== currentViewData?.access) {
promiseRequests.push(this.updateViewAccess(workspaceSlug, viewId, data.access));
}
const [currentView] = await Promise.all(promiseRequests);
// applying the filters in the global view
if (!isEqual(currentViewData?.filters || {}, currentView?.filters || {})) {
if (isEmpty(currentView?.filters)) {
@ -178,13 +193,13 @@ export class GlobalViewStore implements IGlobalViewStore {
workspaceSlug,
undefined,
EIssueFilterType.FILTERS,
currentView.filters,
currentView?.filters,
viewId
);
}
this.rootStore.issue.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation");
}
return currentView;
return currentView as IWorkspaceView;
} catch {
Object.keys(data).forEach((key) => {
const currentKey = key as keyof IWorkspaceView;
@ -204,4 +219,74 @@ export class GlobalViewStore implements IGlobalViewStore {
delete this.globalViewMap[viewId];
});
});
/** Locks view
* @param workspaceSlug
* @param projectId
* @param viewId
* @returns
*/
lockView = async (workspaceSlug: string, viewId: string) => {
try {
const currentView = this.getViewDetailsById(viewId);
if (currentView?.is_locked) return;
runInAction(() => {
set(this.globalViewMap, [viewId, "is_locked"], true);
});
await this.workspaceService.lockView(workspaceSlug, viewId);
} catch (error) {
console.error("Failed to lock the view in view store", error);
runInAction(() => {
set(this.globalViewMap, [viewId, "is_locked"], false);
});
}
};
/**
* unlocks View
* @param workspaceSlug
* @param projectId
* @param viewId
* @returns
*/
unLockView = async (workspaceSlug: string, viewId: string) => {
try {
const currentView = this.getViewDetailsById(viewId);
if (!currentView?.is_locked) return;
runInAction(() => {
set(this.globalViewMap, [viewId, "is_locked"], false);
});
await this.workspaceService.unLockView(workspaceSlug, viewId);
} catch (error) {
console.error("Failed to unlock view in view store", error);
runInAction(() => {
set(this.globalViewMap, [viewId, "is_locked"], true);
});
}
};
/**
* Updates View access
* @param workspaceSlug
* @param projectId
* @param viewId
* @param access
* @returns
*/
updateViewAccess = async (workspaceSlug: string, viewId: string, access: EViewAccess) => {
const currentView = this.getViewDetailsById(viewId);
const currentAccess = currentView?.access;
try {
if (currentAccess === access) return;
runInAction(() => {
set(this.globalViewMap, [viewId, "access"], access);
});
await this.workspaceService.updateViewAccess(workspaceSlug, viewId, access);
} catch (error) {
console.error("Failed to update Access for view", error);
runInAction(() => {
set(this.globalViewMap, [viewId, "access"], currentAccess);
});
}
};
}

View file

@ -17,8 +17,9 @@ import {
EIssuesStoreType,
EIssueGroupByToServerOptions,
EServerGroupByToFilterOptions,
EIssueLayoutTypes,
} from "@/constants/issue";
// helpers
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@/helpers/issue.helper";
// lib
import { storage } from "@/lib/local-storage";
@ -181,46 +182,15 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
computedDisplayFilters = (
displayFilters: IIssueDisplayFilterOptions,
defaultValues?: IIssueDisplayFilterOptions
): IIssueDisplayFilterOptions => {
const filters = displayFilters || defaultValues;
return {
calendar: {
show_weekends: filters?.calendar?.show_weekends || false,
layout: filters?.calendar?.layout || "month",
},
layout: filters?.layout || EIssueLayoutTypes.LIST,
order_by: filters?.order_by || "sort_order",
group_by: filters?.group_by || null,
sub_group_by: filters?.sub_group_by || null,
type: filters?.type || null,
sub_issue: filters?.sub_issue || false,
show_empty_groups: filters?.show_empty_groups || false,
};
};
): IIssueDisplayFilterOptions => getComputedDisplayFilters(displayFilters, defaultValues);
/**
* @description This method is used to apply the display properties on the issues
* @param {IIssueDisplayProperties} displayProperties
* @returns {IIssueDisplayProperties}
*/
computedDisplayProperties = (displayProperties: IIssueDisplayProperties): IIssueDisplayProperties => ({
assignee: displayProperties?.assignee ?? true,
start_date: displayProperties?.start_date ?? true,
due_date: displayProperties?.due_date ?? true,
labels: displayProperties?.labels ?? true,
priority: displayProperties?.priority ?? true,
state: displayProperties?.state ?? true,
sub_issue_count: displayProperties?.sub_issue_count ?? true,
attachment_count: displayProperties?.attachment_count ?? true,
link: displayProperties?.link ?? true,
estimate: displayProperties?.estimate ?? true,
key: displayProperties?.key ?? true,
created_on: displayProperties?.created_on ?? true,
updated_on: displayProperties?.updated_on ?? true,
modules: displayProperties?.modules ?? true,
cycle: displayProperties?.cycle ?? true,
});
computedDisplayProperties = (displayProperties: IIssueDisplayProperties): IIssueDisplayProperties =>
getComputedDisplayProperties(displayProperties);
handleIssuesLocalFilters = {
fetchFiltersFromStorage: () => {

View file

@ -16,13 +16,13 @@ import {
} from "@plane/types";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
import { handleIssueQueryParamsByLayout } from "@/helpers/issue.helper";
import { ViewService } from "@/services/view.service";
// services
import { ViewService } from "@/plane-web/services";
import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers
// types
import { IIssueRootStore } from "../root.store";
// constants
// services
export interface IProjectViewIssuesFilter extends IBaseIssueFilterStore {
//helper actions

View file

@ -16,8 +16,9 @@ import {
IssuePaginationOptions,
} from "@plane/types";
import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
// services
import { handleIssueQueryParamsByLayout } from "@/helpers/issue.helper";
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
// helpers
// types

View file

@ -1,8 +1,15 @@
import { action, makeObservable, runInAction } from "mobx";
// base class
import { IssuePaginationOptions, TBulkOperationsPayload, TIssue, TIssuesResponse, TLoader, ViewFlags } from "@plane/types";
import { WorkspaceService } from "@/services/workspace.service";
import {
IssuePaginationOptions,
TBulkOperationsPayload,
TIssue,
TIssuesResponse,
TLoader,
ViewFlags,
} from "@plane/types";
// services
import { WorkspaceService } from "@/plane-web/services";
// types
import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store";
import { IIssueRootStore } from "../root.store";

View file

@ -7,7 +7,7 @@ import { IWorkspaceBulkInviteFormData, IWorkspaceMember, IWorkspaceMemberInvitat
// constants
import { EUserWorkspaceRoles } from "@/constants/workspace";
// services
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
// types
import { IRouterStore } from "@/store/router.store";
import { IUserStore } from "@/store/user";

View file

@ -2,9 +2,13 @@ import { set } from "lodash";
import { observable, action, makeObservable, runInAction, computed } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { IProjectView } from "@plane/types";
import { IProjectView, TViewFilters } from "@plane/types";
// constants
import { EViewAccess } from "@/constants/views";
// helpers
import { getViewName, orderViews, shouldFilterView } from "@/helpers/project-views.helpers";
// services
import { ViewService } from "@/services/view.service";
import { ViewService } from "@/plane-web/services";
// store
import { CoreRootStore } from "./root.store";
@ -13,13 +17,14 @@ export interface IProjectViewStore {
loader: boolean;
fetchedMap: Record<string, boolean>;
// observables
searchQuery: string;
viewMap: Record<string, IProjectView>;
filters: TViewFilters;
// computed
projectViewIds: string[] | null;
// computed actions
getProjectViews: (projectId: string) => IProjectView[] | undefined;
getFilteredProjectViews: (projectId: string) => IProjectView[] | undefined;
getViewById: (viewId: string) => IProjectView;
updateSearchQuery: (query: string) => void;
// fetch actions
fetchViews: (workspaceSlug: string, projectId: string) => Promise<undefined | IProjectView[]>;
fetchViewDetails: (workspaceSlug: string, projectId: string, viewId: string) => Promise<IProjectView>;
@ -32,6 +37,8 @@ export interface IProjectViewStore {
data: Partial<IProjectView>
) => Promise<IProjectView>;
deleteView: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
updateFilters: <T extends keyof TViewFilters>(filterKey: T, filterValue: TViewFilters[T]) => void;
clearAllFilters: () => void;
// favorites actions
addViewToFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
removeViewFromFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
@ -41,9 +48,9 @@ export class ProjectViewStore implements IProjectViewStore {
// observables
loader: boolean = false;
viewMap: Record<string, IProjectView> = {};
searchQuery: string = "";
//loaders
fetchedMap: Record<string, boolean> = {};
filters: TViewFilters = { searchQuery: "", sortBy: "asc", sortKey: "created_at" };
// root store
rootStore;
// services
@ -55,7 +62,7 @@ export class ProjectViewStore implements IProjectViewStore {
loader: observable.ref,
viewMap: observable,
fetchedMap: observable,
searchQuery: observable.ref,
filters: observable,
// computed
projectViewIds: computed,
// fetch actions
@ -66,7 +73,8 @@ export class ProjectViewStore implements IProjectViewStore {
updateView: action,
deleteView: action,
// actions
updateSearchQuery: action,
updateFilters: action,
clearAllFilters: action,
// favorites actions
addViewToFavorites: action,
removeViewFromFavorites: action,
@ -87,16 +95,58 @@ export class ProjectViewStore implements IProjectViewStore {
return viewIds;
}
getProjectViews = computedFn((projectId: string) => {
if (!this.fetchedMap[projectId]) return undefined;
const ViewsList = Object.values(this.viewMap ?? {});
// helps to filter views based on the projectId
let filteredViews = ViewsList.filter((view) => view?.project === projectId);
filteredViews = orderViews(filteredViews, this.filters.sortKey, this.filters.sortBy);
return filteredViews ?? undefined;
});
/**
* returns viewsIds of issues
*/
getFilteredProjectViews = computedFn((projectId: string) => {
if (!this.fetchedMap[projectId]) return undefined;
const ViewsList = Object.values(this.viewMap ?? {});
// helps to filter views based on the projectId, searchQuery and filters
let filteredViews = ViewsList.filter(
(view) =>
view?.project === projectId &&
getViewName(view.name).toLowerCase().includes(this.filters.searchQuery.toLowerCase()) &&
shouldFilterView(view, this.filters.filters)
);
filteredViews = orderViews(filteredViews, this.filters.sortKey, this.filters.sortBy);
return filteredViews ?? undefined;
});
/**
* Returns view details by id
*/
getViewById = computedFn((viewId: string) => this.viewMap?.[viewId] ?? null);
/**
* @description update search query
* @param {string} query
* Updates the filter
* @param filterKey
* @param filterValue
*/
updateSearchQuery = (query: string) => (this.searchQuery = query);
updateFilters = <T extends keyof TViewFilters>(filterKey: T, filterValue: TViewFilters[T]) => {
runInAction(() => {
set(this.filters, [filterKey], filterValue);
});
};
/**
* @description clears all the filters
*/
clearAllFilters = () =>
runInAction(() => {
set(this.filters, ["filters"], {});
});
/**
* Fetches views for current project
@ -145,14 +195,20 @@ export class ProjectViewStore implements IProjectViewStore {
* @param data
* @returns Promise<IProjectView>
*/
createView = async (workspaceSlug: string, projectId: string, data: Partial<IProjectView>): Promise<IProjectView> =>
await this.viewService.createView(workspaceSlug, projectId, data).then((response) => {
runInAction(() => {
set(this.viewMap, [response.id], response);
});
return response;
createView = async (workspaceSlug: string, projectId: string, data: Partial<IProjectView>): Promise<IProjectView> => {
const response = await this.viewService.createView(workspaceSlug, projectId, data);
runInAction(() => {
set(this.viewMap, [response.id], response);
});
if (data.access === EViewAccess.PRIVATE) {
await this.updateViewAccess(workspaceSlug, projectId, response.id, EViewAccess.PRIVATE);
}
return response;
};
/**
* Updates a view details of specific view and updates it in the store
* @param workspaceSlug
@ -168,12 +224,21 @@ export class ProjectViewStore implements IProjectViewStore {
data: Partial<IProjectView>
): Promise<IProjectView> => {
const currentView = this.getViewById(viewId);
return await this.viewService.patchView(workspaceSlug, projectId, viewId, data).then((response) => {
runInAction(() => {
set(this.viewMap, [viewId], { ...currentView, ...data });
});
return response;
const promiseRequests = [];
promiseRequests.push(this.viewService.patchView(workspaceSlug, projectId, viewId, data));
runInAction(() => {
set(this.viewMap, [viewId], { ...currentView, ...data });
});
if (data.access !== undefined && data.access !== currentView.access) {
promiseRequests.push(this.updateViewAccess(workspaceSlug, projectId, viewId, data.access));
}
const [response] = await Promise.all(promiseRequests);
return response;
};
/**
@ -191,6 +256,76 @@ export class ProjectViewStore implements IProjectViewStore {
});
};
/** Locks view
* @param workspaceSlug
* @param projectId
* @param viewId
* @returns
*/
lockView = async (workspaceSlug: string, projectId: string, viewId: string) => {
try {
const currentView = this.getViewById(viewId);
if (currentView?.is_locked) return;
runInAction(() => {
set(this.viewMap, [viewId, "is_locked"], true);
});
await this.viewService.lockView(workspaceSlug, projectId, viewId);
} catch (error) {
console.error("Failed to lock the view in view store", error);
runInAction(() => {
set(this.viewMap, [viewId, "is_locked"], false);
});
}
};
/**
* unlocks View
* @param workspaceSlug
* @param projectId
* @param viewId
* @returns
*/
unLockView = async (workspaceSlug: string, projectId: string, viewId: string) => {
try {
const currentView = this.getViewById(viewId);
if (!currentView?.is_locked) return;
runInAction(() => {
set(this.viewMap, [viewId, "is_locked"], false);
});
await this.viewService.unLockView(workspaceSlug, projectId, viewId);
} catch (error) {
console.error("Failed to unlock view in view store", error);
runInAction(() => {
set(this.viewMap, [viewId, "is_locked"], true);
});
}
};
/**
* Updates View access
* @param workspaceSlug
* @param projectId
* @param viewId
* @param access
* @returns
*/
updateViewAccess = async (workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) => {
const currentView = this.getViewById(viewId);
const currentAccess = currentView?.access;
try {
if (currentAccess === access) return;
runInAction(() => {
set(this.viewMap, [viewId, "access"], access);
});
await this.viewService.updateViewAccess(workspaceSlug, projectId, viewId, access);
} catch (error) {
console.error("Failed to update Access for view", error);
runInAction(() => {
set(this.viewMap, [viewId, "access"], currentAccess);
});
}
};
/**
* Adds a view to favorites
* @param workspaceSlug

View file

@ -6,9 +6,9 @@ import { IWorkspaceMemberMe, IProjectMember, IUserProjectsRole } from "@plane/ty
import { EUserProjectRoles } from "@/constants/project";
import { EUserWorkspaceRoles } from "@/constants/workspace";
// services
import { WorkspaceService } from "@/plane-web/services";
import { ProjectMemberService } from "@/services/project";
import { UserService } from "@/services/user.service";
import { WorkspaceService } from "@/services/workspace.service";
// plane web store
import { CoreRootStore } from "../root.store";

View file

@ -3,7 +3,7 @@ import { action, computed, observable, makeObservable, runInAction } from "mobx"
// types
import { IWorkspace } from "@plane/types";
// services
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
// store
import { CoreRootStore } from "@/store/root.store";
// sub-stores

View file

@ -30,12 +30,13 @@ export const satisfiesDateFilter = (date: Date, filter: string): boolean => {
const [value, operator, from] = filter.split(";");
const dateValue = getDate(value);
const differenceInDays = differenceInCalendarDays(date, new Date());
if (operator === "custom" && from === "custom") {
if (value === "today") return differenceInCalendarDays(date, new Date()) === 0;
if (value === "yesterday") return differenceInCalendarDays(date, new Date()) === -1;
if (value === "last_7_days") return differenceInCalendarDays(date, new Date()) >= -7;
if (value === "last_30_days") return differenceInCalendarDays(date, new Date()) >= -30;
if (value === "today") return differenceInDays === 0;
if (value === "yesterday") return differenceInDays === -1;
if (value === "last_7_days") return differenceInDays >= -7;
if (value === "last_30_days") return differenceInDays >= -30;
}
if (!from && dateValue) {
@ -45,16 +46,16 @@ export const satisfiesDateFilter = (date: Date, filter: string): boolean => {
if (from === "fromnow") {
if (operator === "before") {
if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) <= -7;
if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) <= -14;
if (value === "1_months") return differenceInCalendarDays(date, new Date()) <= -30;
if (value === "1_weeks") return differenceInDays <= -7;
if (value === "2_weeks") return differenceInDays <= -14;
if (value === "1_months") return differenceInDays <= -30;
}
if (operator === "after") {
if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) >= 7;
if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) >= 14;
if (value === "1_months") return differenceInCalendarDays(date, new Date()) >= 30;
if (value === "2_months") return differenceInCalendarDays(date, new Date()) >= 60;
if (value === "1_weeks") return differenceInDays >= 7;
if (value === "2_weeks") return differenceInDays >= 14;
if (value === "1_months") return differenceInDays >= 30;
if (value === "2_months") return differenceInDays >= 60;
}
}

View file

@ -2,6 +2,8 @@ import differenceInCalendarDays from "date-fns/differenceInCalendarDays";
import { v4 as uuidv4 } from "uuid";
// types
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
TGroupedIssues,
TIssue,
TIssueGroupByOptions,
@ -169,12 +171,12 @@ export const shouldHighlightIssueDueDate = (
return targetDateDistance <= 0;
};
export const getIssueBlocksStructure = (block: TIssue): IGanttBlock => ({
data: block,
id: block?.id,
sort_order: block?.sort_order,
start_date: getDate(block?.start_date),
target_date: getDate(block?.target_date),
});
data: block,
id: block?.id,
sort_order: block?.sort_order,
start_date: getDate(block?.start_date),
target_date: getDate(block?.target_date),
});
export function getChangedIssuefields(formData: Partial<TIssue>, dirtyFields: { [key: string]: boolean | undefined }) {
const changedFields: Partial<TIssue> = {};
@ -252,3 +254,54 @@ export const issueCountBasedOnFilters = (
return issuesCount;
};
/**
* @description This method is used to apply the display filters on the issues
* @param {IIssueDisplayFilterOptions} displayFilters
* @returns {IIssueDisplayFilterOptions}
*/
export const getComputedDisplayFilters = (
displayFilters: IIssueDisplayFilterOptions = {},
defaultValues?: IIssueDisplayFilterOptions
): IIssueDisplayFilterOptions => {
const filters = displayFilters || defaultValues;
return {
calendar: {
show_weekends: filters?.calendar?.show_weekends || false,
layout: filters?.calendar?.layout || "month",
},
layout: filters?.layout || EIssueLayoutTypes.LIST,
order_by: filters?.order_by || "sort_order",
group_by: filters?.group_by || null,
sub_group_by: filters?.sub_group_by || null,
type: filters?.type || null,
sub_issue: filters?.sub_issue || false,
show_empty_groups: filters?.show_empty_groups || false,
};
};
/**
* @description This method is used to apply the display properties on the issues
* @param {IIssueDisplayProperties} displayProperties
* @returns {IIssueDisplayProperties}
*/
export const getComputedDisplayProperties = (
displayProperties: IIssueDisplayProperties = {}
): IIssueDisplayProperties => ({
assignee: displayProperties?.assignee ?? true,
start_date: displayProperties?.start_date ?? true,
due_date: displayProperties?.due_date ?? true,
labels: displayProperties?.labels ?? true,
priority: displayProperties?.priority ?? true,
state: displayProperties?.state ?? true,
sub_issue_count: displayProperties?.sub_issue_count ?? true,
attachment_count: displayProperties?.attachment_count ?? true,
link: displayProperties?.link ?? true,
estimate: displayProperties?.estimate ?? true,
key: displayProperties?.key ?? true,
created_on: displayProperties?.created_on ?? true,
updated_on: displayProperties?.updated_on ?? true,
modules: displayProperties?.modules ?? true,
cycle: displayProperties?.cycle ?? true,
});

View file

@ -0,0 +1,76 @@
import orderBy from "lodash/orderBy";
import { IProjectView, TViewFilterProps, TViewFiltersSortBy, TViewFiltersSortKey } from "@plane/types";
import { getDate } from "@/helpers/date-time.helper";
import { satisfiesDateFilter } from "./filter.helper";
/**
* order views base on TViewFiltersSortKey
* @param views
* @param sortByKey
* @param sortByOrder
* @returns
*/
export const orderViews = (
views: IProjectView[],
sortByKey: TViewFiltersSortKey | undefined,
sortByOrder: TViewFiltersSortBy
): IProjectView[] => {
if (views.length === 0 || !sortByKey) return [];
let iterableFunction;
if (sortByKey === "name") {
iterableFunction = (view: IProjectView) => view.name?.toLowerCase();
}
if (sortByKey === "created_at") {
iterableFunction = (view: IProjectView) => view.created_at;
}
if (sortByKey === "updated_at") {
iterableFunction = (view: IProjectView) => view.updated_at;
}
if (!iterableFunction) return [];
return orderBy(views, [iterableFunction], [sortByOrder]);
};
/**
* Checks if the passed down view should be filtered or not
* @param view
* @param filters
* @returns
*/
export const shouldFilterView = (view: IProjectView, filters: TViewFilterProps | undefined): boolean => {
let fallsInFilters = true;
Object.keys(filters ?? {}).forEach((key) => {
const filterKey = key as keyof TViewFilterProps;
if (filterKey === "owned_by" && filters?.owned_by && filters.owned_by.length > 0) {
fallsInFilters = fallsInFilters && filters.owned_by.includes(`${view.created_by}`);
}
if (filterKey === "created_at" && filters?.created_at && filters.created_at.length > 0) {
const createdDate = getDate(view.created_at);
filters?.created_at.forEach((dateFilter) => {
fallsInFilters = fallsInFilters && !!createdDate && satisfiesDateFilter(createdDate, dateFilter);
});
}
if (filterKey === "accessTypes" && filters?.accessTypes && filters?.accessTypes?.length > 0) {
fallsInFilters = filters.accessTypes.includes(view.access);
}
});
if (filters?.favorites && !view.is_favorite) fallsInFilters = false;
return fallsInFilters;
};
/**
* @description returns the name of the project after checking for untitled view
* @param {string | undefined} name
* @returns {string}
*/
export const getViewName = (name: string | undefined) => {
if (name === undefined) return "";
if (!name || name.trim() === "") return "Untitled";
return name;
};