diff --git a/packages/types/src/views.d.ts b/packages/types/src/views.d.ts index 6ce598168..29db479ed 100644 --- a/packages/types/src/views.d.ts +++ b/packages/types/src/views.d.ts @@ -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; +}; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx index a7e271425..fd192cc60 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx @@ -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(() => { - {canUserCreateIssue && ( -
- +
+ + {canUserCreateView && (
-
- )} + )} +
+ {isFiltersApplied && ( +
+ +
+ )} ); }); diff --git a/web/app/invitations/page.tsx b/web/app/invitations/page.tsx index 89ff66ab8..a56ff6ad4 100644 --- a/web/app/invitations/page.tsx +++ b/web/app/invitations/page.tsx @@ -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(); diff --git a/web/app/onboarding/page.tsx b/web/app/onboarding/page.tsx index 9e318e521..ffc198698 100644 --- a/web/app/onboarding/page.tsx +++ b/web/app/onboarding/page.tsx @@ -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", diff --git a/web/app/workspace-invitations/page.tsx b/web/app/workspace-invitations/page.tsx index 2844713db..a68290198 100644 --- a/web/app/workspace-invitations/page.tsx +++ b/web/app/workspace-invitations/page.tsx @@ -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(); diff --git a/web/ce/services/index.ts b/web/ce/services/index.ts index 9ff8c7dff..3a7bd7005 100644 --- a/web/ce/services/index.ts +++ b/web/ce/services/index.ts @@ -1 +1,2 @@ export * from "./project"; +export * from "./workspace.service"; \ No newline at end of file diff --git a/web/ce/services/project/index.ts b/web/ce/services/project/index.ts index 29b17e55d..6c0fc3df4 100644 --- a/web/ce/services/project/index.ts +++ b/web/ce/services/project/index.ts @@ -1 +1,2 @@ export * from "./estimate.service"; +export * from "./view.service"; \ No newline at end of file diff --git a/web/ce/services/project/view.service.ts b/web/ce/services/project/view.service.ts new file mode 100644 index 000000000..07872394a --- /dev/null +++ b/web/ce/services/project/view.service.ts @@ -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(); + } +} diff --git a/web/ce/services/workspace.service.ts b/web/ce/services/workspace.service.ts new file mode 100644 index 000000000..59fe39c69 --- /dev/null +++ b/web/ce/services/workspace.service.ts @@ -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(); + } +} diff --git a/web/core/components/account/auth-forms/auth-header.tsx b/web/core/components/account/auth-forms/auth-header.tsx index c9b01d5e9..424fcbfe6 100644 --- a/web/core/components/account/auth-forms/auth-header.tsx +++ b/web/core/components/account/auth-forms/auth-header.tsx @@ -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; diff --git a/web/core/components/command-palette/command-modal.tsx b/web/core/components/command-palette/command-modal.tsx index 8c64240fd..246485262 100644 --- a/web/core/components/command-palette/command-modal.tsx +++ b/web/core/components/command-palette/command-modal.tsx @@ -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 diff --git a/web/core/components/pages/list/applied-filters/date.tsx b/web/core/components/common/applied-filters/date.tsx similarity index 100% rename from web/core/components/pages/list/applied-filters/date.tsx rename to web/core/components/common/applied-filters/date.tsx index 67ad973b3..06d182647 100644 --- a/web/core/components/pages/list/applied-filters/date.tsx +++ b/web/core/components/common/applied-filters/date.tsx @@ -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; diff --git a/web/core/components/common/applied-filters/index.ts b/web/core/components/common/applied-filters/index.ts new file mode 100644 index 000000000..77f9f6304 --- /dev/null +++ b/web/core/components/common/applied-filters/index.ts @@ -0,0 +1,2 @@ +export * from "./date"; +export * from "./members"; diff --git a/web/core/components/pages/list/applied-filters/members.tsx b/web/core/components/common/applied-filters/members.tsx similarity index 100% rename from web/core/components/pages/list/applied-filters/members.tsx rename to web/core/components/common/applied-filters/members.tsx diff --git a/web/core/components/pages/list/filters/created-at.tsx b/web/core/components/common/filters/created-at.tsx similarity index 100% rename from web/core/components/pages/list/filters/created-at.tsx rename to web/core/components/common/filters/created-at.tsx diff --git a/web/core/components/pages/list/filters/created-by.tsx b/web/core/components/common/filters/created-by.tsx similarity index 100% rename from web/core/components/pages/list/filters/created-by.tsx rename to web/core/components/common/filters/created-by.tsx diff --git a/web/core/components/common/filters/index.ts b/web/core/components/common/filters/index.ts new file mode 100644 index 000000000..6f0b5dbde --- /dev/null +++ b/web/core/components/common/filters/index.ts @@ -0,0 +1,2 @@ +export * from "./created-at"; +export * from "./created-by"; diff --git a/web/core/components/common/product-updates-modal.tsx b/web/core/components/common/product-updates-modal.tsx index dc10e6abd..9e1112db1 100644 --- a/web/core/components/common/product-updates-modal.tsx +++ b/web/core/components/common/product-updates-modal.tsx @@ -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; diff --git a/web/core/components/integration/github/single-user-select.tsx b/web/core/components/integration/github/single-user-select.tsx index 55c1e7118..0f50a5a12 100644 --- a/web/core/components/integration/github/single-user-select.tsx +++ b/web/core/components/integration/github/single-user-select.tsx @@ -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"; diff --git a/web/core/components/integration/jira/import-users.tsx b/web/core/components/integration/jira/import-users.tsx index eb63e6bc1..0f8f82165 100644 --- a/web/core/components/integration/jira/import-users.tsx +++ b/web/core/components/integration/jira/import-users.tsx @@ -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 diff --git a/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx b/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx index c99343bb7..71928239a 100644 --- a/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx +++ b/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx @@ -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} /> diff --git a/web/core/components/onboarding/create-workspace.tsx b/web/core/components/onboarding/create-workspace.tsx index b8c708abc..d9d789ede 100644 --- a/web/core/components/onboarding/create-workspace.tsx +++ b/web/core/components/onboarding/create-workspace.tsx @@ -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) => Promise; diff --git a/web/core/components/onboarding/invitations.tsx b/web/core/components/onboarding/invitations.tsx index 4afd7953a..3c9d57e20 100644 --- a/web/core/components/onboarding/invitations.tsx +++ b/web/core/components/onboarding/invitations.tsx @@ -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; diff --git a/web/core/components/onboarding/invite-members.tsx b/web/core/components/onboarding/invite-members.tsx index db4042caf..5b1c24f01 100644 --- a/web/core/components/onboarding/invite-members.tsx +++ b/web/core/components/onboarding/invite-members.tsx @@ -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"; diff --git a/web/core/components/pages/list/applied-filters/index.ts b/web/core/components/pages/list/applied-filters/index.ts index 687cffb99..1efe34c51 100644 --- a/web/core/components/pages/list/applied-filters/index.ts +++ b/web/core/components/pages/list/applied-filters/index.ts @@ -1,3 +1 @@ -export * from "./date"; -export * from "./members"; export * from "./root"; diff --git a/web/core/components/pages/list/applied-filters/root.tsx b/web/core/components/pages/list/applied-filters/root.tsx index f889739cc..5ae9d7bb1 100644 --- a/web/core/components/pages/list/applied-filters/root.tsx +++ b/web/core/components/pages/list/applied-filters/root.tsx @@ -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 diff --git a/web/core/components/pages/list/filters/index.ts b/web/core/components/pages/list/filters/index.ts index e95258f0e..1efe34c51 100644 --- a/web/core/components/pages/list/filters/index.ts +++ b/web/core/components/pages/list/filters/index.ts @@ -1,3 +1 @@ -export * from "./created-at"; -export * from "./created-by"; export * from "./root"; diff --git a/web/core/components/pages/list/filters/root.tsx b/web/core/components/pages/list/filters/root.tsx index e228bd235..9b2aaeb4b 100644 --- a/web/core/components/pages/list/filters/root.tsx +++ b/web/core/components/pages/list/filters/root.tsx @@ -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; diff --git a/web/core/components/views/filters/applied-filters.tsx b/web/core/components/views/filters/applied-filters.tsx new file mode 100644 index 000000000..d532ff61d --- /dev/null +++ b/web/core/components/views/filters/applied-filters.tsx @@ -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) => { + const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props; + + if (!appliedFilters) return null; + if (Object.keys(appliedFilters).length === 0) return null; + + const isEditingAllowed = alwaysAllowEditing; + + return ( +
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TViewFilterProps; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( +
+
+ {replaceUnderscoreIfSnakeCase(filterKey)} + {DATE_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={Array.isArray(value) ? (value as string[]) : []} + /> + )} + {MEMBERS_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={Array.isArray(value) ? (value as string[]) : []} + /> + )} + {isEditingAllowed && ( + + )} +
+
+ ); + })} + {isEditingAllowed && ( + + )} +
+ ); +}; diff --git a/web/core/components/views/filters/filter-selection.tsx b/web/core/components/views/filters/filter-selection.tsx new file mode 100644 index 000000000..9278ec410 --- /dev/null +++ b/web/core/components/views/filters/filter-selection.tsx @@ -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: (filterKey: T, filterValue: TViewFilters[T]) => void; + memberIds?: string[] | undefined; +}; + +export const ViewFiltersSelection: React.FC = 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 ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+
+ + handleFiltersUpdate("filters", { + ...filters.filters, + favorites: !filters.filters?.favorites, + }) + } + title="Favorites" + /> +
+ + {/* created date */} +
+ handleFilters("created_at", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* created by */} +
+ handleFilters("owned_by", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
+
+
+ ); +}); diff --git a/web/core/components/views/filters/order-by.tsx b/web/core/components/views/filters/order-by.tsx new file mode 100644 index 000000000..3f607e53d --- /dev/null +++ b/web/core/components/views/filters/order-by.tsx @@ -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) => { + 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 ( + + + {orderByDetails?.label} + + + } + placement="bottom-end" + maxHeight="lg" + closeOnSelect + > + {VIEW_SORTING_KEY_OPTIONS.map((option) => ( + + onChange({ + key: option.key, + }) + } + > + {option.label} + {sortKey === option.key && } + + ))} +
+ {sortByOptions.map((option) => ( + { + if (!option.isSelected) + onChange({ + order: option.key as TViewFiltersSortBy, + }); + }} + > + {option.label} + {option.isSelected && } + + ))} +
+ ); +}; diff --git a/web/core/components/views/form.tsx b/web/core/components/views/form.tsx index 4371481df..0f9020724 100644 --- a/web/core/components/views/form.tsx +++ b/web/core/components/views/form.tsx @@ -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 = { name: "", description: "", + display_properties: getComputedDisplayProperties(), + display_filters: getComputedDisplayFilters(), }; export const ProjectViewForm: React.FC = observer((props) => { diff --git a/web/core/components/views/update-view-component.tsx b/web/core/components/views/update-view-component.tsx index 9e598d528..5b3f8c79b 100644 --- a/web/core/components/views/update-view-component.tsx +++ b/web/core/components/views/update-view-component.tsx @@ -51,14 +51,14 @@ export const UpdateViewComponent = (props: Props) => { ); return ( -
+
{isLocked ? ( ) : ( !areFiltersEqual && isAuthorizedUser && ( <> - {isOwner && <>{updateButton}} diff --git a/web/core/components/views/view-list-header.tsx b/web/core/components/views/view-list-header.tsx index adbae785e..952472ef5 100644 --- a/web/core/components/views/view-list-header.tsx +++ b/web/core/components/views/view-list-header.tsx @@ -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(null); // store hooks - const { searchQuery, updateSearchQuery } = useProjectView(); + const { filters, updateFilters } = useProjectView(); + const { + project: { projectMemberIds }, + } = useMember(); // handlers const handleInputKeyDown = (e: React.KeyboardEvent) => { 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 (
@@ -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(() => { )}
+ { + if (val.key) updateFilters("sortKey", val.key); + if (val.order) updateFilters("sortBy", val.order); + }} + /> + } + title="Filters" + placement="bottom-end" + isFiltersApplied={false} + > + +
); }); diff --git a/web/core/components/views/views-list.tsx b/web/core/components/views/views-list.tsx index cc962bd15..6001b668e 100644 --- a/web/core/components/views/views-list.tsx +++ b/web/core/components/views/views-list.tsx @@ -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 ; + const projectViews = getProjectViews(projectId?.toString()); + const filteredProjectViews = getFilteredProjectViews(projectId?.toString()); - // derived values - const viewsList = projectViewIds.map((viewId) => getViewById(viewId)); + if (loader || !projectViews || !filteredProjectViews) return ; - const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(searchQuery.toLowerCase())); + if (filteredProjectViews.length === 0 && projectViews) { + return ( +
+
+ 0 ? NameFilterImage : AllFiltersImage} + className="h-36 sm:h-48 w-36 sm:w-48 mx-auto" + alt="No matching modules" + /> +
No matching views
+

+ {filters.searchQuery.length > 0 + ? "Remove the search criteria to see all views" + : "Remove the filters to see all views"} +

+
+
+ ); + } return ( <> - {viewsList.length > 0 ? ( + {filteredProjectViews.length > 0 ? (
- {filteredViewsList.length > 0 ? ( - filteredViewsList.map((view) => ) + {filteredProjectViews.length > 0 ? ( + filteredProjectViews.map((view) => ) ) : (

No results found

)} diff --git a/web/core/components/workspace/create-workspace-form.tsx b/web/core/components/workspace/create-workspace-form.tsx index c293c43b0..f9234290d 100644 --- a/web/core/components/workspace/create-workspace-form.tsx +++ b/web/core/components/workspace/create-workspace-form.tsx @@ -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; diff --git a/web/core/components/workspace/views/form.tsx b/web/core/components/workspace/views/form.tsx index 9901c5462..61ef96602 100644 --- a/web/core/components/workspace/views/form.tsx +++ b/web/core/components/workspace/views/form.tsx @@ -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 = { name: "", description: "", + display_properties: getComputedDisplayProperties(), + display_filters: getComputedDisplayFilters(), }; export const WorkspaceViewForm: React.FC = observer((props) => { diff --git a/web/core/constants/views.ts b/web/core/constants/views.ts index aab38da3f..a8e501db4 100644 --- a/web/core/constants/views.ts +++ b/web/core/constants/views.ts @@ -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" }, +]; diff --git a/web/core/services/view.service.ts b/web/core/services/view.service.ts index f4fccbae0..b05331775 100644 --- a/web/core/services/view.service.ts +++ b/web/core/services/view.service.ts @@ -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): Promise { diff --git a/web/core/services/workspace.service.ts b/web/core/services/workspace.service.ts index 79bde3b8c..e1aa9b7cd 100644 --- a/web/core/services/workspace.service.ts +++ b/web/core/services/workspace.service.ts @@ -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 { diff --git a/web/core/store/global-view.store.ts b/web/core/store/global-view.store.ts index 52d826bb6..8fd883e1c 100644 --- a/web/core/store/global-view.store.ts +++ b/web/core/store/global-view.store.ts @@ -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): Promise => - await this.workspaceService.createView(workspaceSlug, data).then((response) => { - runInAction(() => { - set(this.globalViewMap, response.id, response); - }); - return response; + createGlobalView = async (workspaceSlug: string, data: Partial): Promise => { + 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); + }); + } + }; } diff --git a/web/core/store/issue/helpers/issue-filter-helper.store.ts b/web/core/store/issue/helpers/issue-filter-helper.store.ts index 42eccc6a6..380e1268f 100644 --- a/web/core/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/core/store/issue/helpers/issue-filter-helper.store.ts @@ -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: () => { diff --git a/web/core/store/issue/project-views/filter.store.ts b/web/core/store/issue/project-views/filter.store.ts index a82bfd8f8..511ce8508 100644 --- a/web/core/store/issue/project-views/filter.store.ts +++ b/web/core/store/issue/project-views/filter.store.ts @@ -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 diff --git a/web/core/store/issue/workspace/filter.store.ts b/web/core/store/issue/workspace/filter.store.ts index 8ac69e7fd..e9b001d35 100644 --- a/web/core/store/issue/workspace/filter.store.ts +++ b/web/core/store/issue/workspace/filter.store.ts @@ -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 diff --git a/web/core/store/issue/workspace/issue.store.ts b/web/core/store/issue/workspace/issue.store.ts index a3bf7a6cc..6960135f1 100644 --- a/web/core/store/issue/workspace/issue.store.ts +++ b/web/core/store/issue/workspace/issue.store.ts @@ -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"; diff --git a/web/core/store/member/workspace-member.store.ts b/web/core/store/member/workspace-member.store.ts index 444c1d1a3..8c7e68aa2 100644 --- a/web/core/store/member/workspace-member.store.ts +++ b/web/core/store/member/workspace-member.store.ts @@ -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"; diff --git a/web/core/store/project-view.store.ts b/web/core/store/project-view.store.ts index ba7d738b7..13c366a14 100644 --- a/web/core/store/project-view.store.ts +++ b/web/core/store/project-view.store.ts @@ -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; // observables - searchQuery: string; viewMap: Record; + 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; fetchViewDetails: (workspaceSlug: string, projectId: string, viewId: string) => Promise; @@ -32,6 +37,8 @@ export interface IProjectViewStore { data: Partial ) => Promise; deleteView: (workspaceSlug: string, projectId: string, viewId: string) => Promise; + updateFilters: (filterKey: T, filterValue: TViewFilters[T]) => void; + clearAllFilters: () => void; // favorites actions addViewToFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise; removeViewFromFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise; @@ -41,9 +48,9 @@ export class ProjectViewStore implements IProjectViewStore { // observables loader: boolean = false; viewMap: Record = {}; - searchQuery: string = ""; //loaders fetchedMap: Record = {}; + 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 = (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 */ - createView = async (workspaceSlug: string, projectId: string, data: Partial): Promise => - 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): Promise => { + 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 ): Promise => { 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 diff --git a/web/core/store/user/user-membership.store.ts b/web/core/store/user/user-membership.store.ts index e4675b758..f9e9e6468 100644 --- a/web/core/store/user/user-membership.store.ts +++ b/web/core/store/user/user-membership.store.ts @@ -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"; diff --git a/web/core/store/workspace/index.ts b/web/core/store/workspace/index.ts index eba260eaa..4efba1e13 100644 --- a/web/core/store/workspace/index.ts +++ b/web/core/store/workspace/index.ts @@ -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 diff --git a/web/helpers/filter.helper.ts b/web/helpers/filter.helper.ts index 670d6d0e0..42c140ce3 100644 --- a/web/helpers/filter.helper.ts +++ b/web/helpers/filter.helper.ts @@ -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; } } diff --git a/web/helpers/issue.helper.ts b/web/helpers/issue.helper.ts index e8ec791c4..6cf6588e4 100644 --- a/web/helpers/issue.helper.ts +++ b/web/helpers/issue.helper.ts @@ -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, dirtyFields: { [key: string]: boolean | undefined }) { const changedFields: Partial = {}; @@ -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, +}); \ No newline at end of file diff --git a/web/helpers/project-views.helpers.ts b/web/helpers/project-views.helpers.ts new file mode 100644 index 000000000..a8fe269be --- /dev/null +++ b/web/helpers/project-views.helpers.ts @@ -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; +};