snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
55
apps/web/core/components/views/applied-filters/access.tsx
Normal file
55
apps/web/core/components/views/applied-filters/access.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CloseOutline } from "@makeplane/propel/icons";
|
||||
// constants
|
||||
// helpers
|
||||
import type { EViewAccess } from "@plane/types";
|
||||
import { VIEW_ACCESS_SPECIFIERS } from "@/helpers/views.helper";
|
||||
|
||||
type Props = {
|
||||
editable: boolean | undefined;
|
||||
handleRemove: (val: EViewAccess) => void;
|
||||
values: EViewAccess[];
|
||||
};
|
||||
|
||||
export const AppliedAccessFilters = observer(function AppliedAccessFilters(props: Props) {
|
||||
const { editable, handleRemove, values } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getAccessLabel = (val: EViewAccess) => {
|
||||
const value = VIEW_ACCESS_SPECIFIERS.find((option) => option.key === val);
|
||||
return value?.i18n_label;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((access) => {
|
||||
const label = getAccessLabel(access);
|
||||
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<div key={access} className="flex items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 text-11">
|
||||
<span className="normal-case">{t(label)}</span>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-tertiary hover:text-secondary"
|
||||
onClick={() => handleRemove(access)}
|
||||
>
|
||||
<CloseOutline height={10} width={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/views/applied-filters/index.tsx
Normal file
7
apps/web/core/components/views/applied-filters/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
98
apps/web/core/components/views/applied-filters/root.tsx
Normal file
98
apps/web/core/components/views/applied-filters/root.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PillButton } from "@makeplane/propel/components/pill";
|
||||
import { CloseOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import type { EViewAccess, TViewFilterProps } from "@plane/types";
|
||||
import { replaceUnderscoreIfSnakeCase } from "@plane/utils";
|
||||
// components
|
||||
import { AppliedDateFilters } from "@/components/common/applied-filters/date";
|
||||
import { AppliedMembersFilters } from "@/components/common/applied-filters/members";
|
||||
// local imports
|
||||
import { AppliedAccessFilters } from "./access";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: TViewFilterProps;
|
||||
handleClearAllFilters: () => void;
|
||||
handleRemoveFilter: (key: keyof TViewFilterProps, value: string | EViewAccess | null) => void;
|
||||
alwaysAllowEditing?: boolean;
|
||||
};
|
||||
|
||||
const MEMBERS_FILTERS = ["owned_by"];
|
||||
const DATE_FILTERS = ["created_at"];
|
||||
const VIEW_ACCESS_FILTERS = ["view_type"];
|
||||
|
||||
export function ViewAppliedFiltersList(props: Props) {
|
||||
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
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-surface-1">
|
||||
{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="my-auto flex min-h-9 cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-subtle p-1.5 text-11 text-tertiary capitalize hover:text-secondary"
|
||||
>
|
||||
<span className="text-11 text-tertiary">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
|
||||
{VIEW_ACCESS_FILTERS.includes(filterKey) && (
|
||||
<AppliedAccessFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
|
||||
values={Array.isArray(value) ? (value as EViewAccess[]) : []}
|
||||
/>
|
||||
)}
|
||||
{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-tertiary hover:text-secondary"
|
||||
onClick={() => handleRemoveFilter(filterKey, null)}
|
||||
>
|
||||
<CloseOutline height={12} width={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isEditingAllowed && (
|
||||
<PillButton
|
||||
type="button"
|
||||
size="md"
|
||||
variant="outline"
|
||||
label={t("common.clear_all")}
|
||||
endIcon={<CloseOutline height={12} width={12} />}
|
||||
onClick={handleClearAllFilters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/web/core/components/views/delete-view-modal.tsx
Normal file
72
apps/web/core/components/views/delete-view-modal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IProjectView } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
|
||||
type Props = {
|
||||
data: IProjectView;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DeleteProjectViewModal = observer(function DeleteProjectViewModal(props: Props) {
|
||||
const { data, isOpen, onClose } = props;
|
||||
// states
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { deleteView } = useProjectView();
|
||||
const { t } = useTranslation();
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteView = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
try {
|
||||
setIsDeleteLoading(true);
|
||||
await deleteView(workspaceSlug.toString(), projectId.toString(), data.id);
|
||||
handleClose();
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/views`);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "View deleted successfully.",
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "View could not be deleted. Please try again.",
|
||||
});
|
||||
}
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={handleClose}
|
||||
handleSubmit={handleDeleteView}
|
||||
isSubmitting={isDeleteLoading}
|
||||
isOpen={isOpen}
|
||||
title={t("project_views.delete_view.title")}
|
||||
content={<>{t("project_views.delete_view.content")}</>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
113
apps/web/core/components/views/filters/filter-selection.tsx
Normal file
113
apps/web/core/components/views/filters/filter-selection.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import { CloseOutline, SearchOutline } from "@makeplane/propel/icons";
|
||||
import type { TViewFilterProps, TViewFilters } from "@plane/types";
|
||||
import { EViewAccess } from "@plane/types";
|
||||
// components
|
||||
import { FilterCreatedDate } from "@/components/common/filters/created-at";
|
||||
import { FilterCreatedBy } from "@/components/common/filters/created-by";
|
||||
import { FilterOption } from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
filters: TViewFilters;
|
||||
handleFiltersUpdate: <T extends keyof TViewFilters>(filterKey: T, filterValue: TViewFilters[T]) => void;
|
||||
memberIds?: string[] | undefined;
|
||||
};
|
||||
|
||||
export const ViewFiltersSelection = observer(function ViewFiltersSelection(props: Props) {
|
||||
const { filters, handleFiltersUpdate, memberIds } = props;
|
||||
// states
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
// store
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
// 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-surface-1 p-2.5 pb-0">
|
||||
<div className="flex items-center gap-1.5 rounded-sm border-[0.5px] border-subtle bg-surface-2 px-1.5 py-1 text-11">
|
||||
<SearchOutline className="text-placeholder" width={12} height={12} />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-surface-2 outline-none placeholder:text-placeholder"
|
||||
placeholder="Search"
|
||||
value={filtersSearchQuery}
|
||||
onChange={(e) => setFiltersSearchQuery(e.target.value)}
|
||||
// oxlint-disable-next-line jsx_a11y/no-autofocus
|
||||
autoFocus={!isMobile}
|
||||
/>
|
||||
{filtersSearchQuery !== "" && (
|
||||
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
|
||||
<CloseOutline className="text-tertiary" height={12} width={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-subtle-1 overflow-y-auto px-2.5">
|
||||
<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>
|
||||
);
|
||||
});
|
||||
84
apps/web/core/components/views/filters/order-by.tsx
Normal file
84
apps/web/core/components/views/filters/order-by.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { SortAscendingOutline, SortDescendingOutline, TickOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { VIEW_SORT_BY_OPTIONS, VIEW_SORTING_KEY_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import type { TViewFiltersSortBy, TViewFiltersSortKey } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
onChange: (value: { key?: TViewFiltersSortKey; order?: TViewFiltersSortBy }) => void;
|
||||
sortBy: TViewFiltersSortBy;
|
||||
sortKey: TViewFiltersSortKey;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
export function ViewOrderByDropdown(props: Props) {
|
||||
const { onChange, sortBy, sortKey, isMobile = false } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const orderByDetails = VIEW_SORTING_KEY_OPTIONS.find((option) => sortKey === option.key);
|
||||
const isDescending = sortBy === "desc";
|
||||
|
||||
const buttonClassName = isMobile
|
||||
? "flex items-center text-13 text-secondary gap-2 w-full"
|
||||
: getButtonStyling("secondary", "lg");
|
||||
|
||||
const icon = (
|
||||
<>{!isDescending ? <SortAscendingOutline className="size-3" /> : <SortDescendingOutline className="size-3" />}</>
|
||||
);
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<span className={buttonClassName}>
|
||||
{!isMobile && icon}
|
||||
<span className="shrink-0"> {orderByDetails?.i18n_label && t(orderByDetails?.i18n_label)}</span>
|
||||
</span>
|
||||
}
|
||||
placement="bottom-end"
|
||||
className="flex w-full justify-center"
|
||||
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 as TViewFiltersSortKey,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(option.i18n_label)}
|
||||
{sortKey === option.key && <TickOutline className="h-3 w-3" />}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
<hr className="my-2 border-subtle" />
|
||||
{VIEW_SORT_BY_OPTIONS.map((option) => {
|
||||
const isSelected = (option.key === "asc" && !isDescending) || (option.key === "desc" && isDescending);
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
onClick={() => {
|
||||
if (!isSelected)
|
||||
onChange({
|
||||
order: option.key as TViewFiltersSortBy,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(option.i18n_label)}
|
||||
{isSelected && <TickOutline className="h-3 w-3" />}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
);
|
||||
}
|
||||
308
apps/web/core/components/views/form.tsx
Normal file
308
apps/web/core/components/views/form.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane imports
|
||||
import { Field } from "@makeplane/propel/components/field";
|
||||
import { Input, InputGroup } from "@makeplane/propel/components/input";
|
||||
import { TextArea, TextAreaGroup } from "@makeplane/propel/components/text-area";
|
||||
import { ETabIndices, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { ViewsOutline } from "@makeplane/propel/icons";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IProjectView,
|
||||
EIssueLayoutTypes,
|
||||
IIssueFilters,
|
||||
} from "@plane/types";
|
||||
import { EViewAccess, EIssuesStoreType } from "@plane/types";
|
||||
import { getComputedDisplayFilters, getComputedDisplayProperties, getTabIndex } from "@plane/utils";
|
||||
// components
|
||||
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { LayoutDropDown } from "../dropdowns/layout";
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "../work-item-filters/filters-hoc/project-level";
|
||||
|
||||
type Props = {
|
||||
data?: IProjectView | null;
|
||||
handleClose: () => void;
|
||||
handleFormSubmit: (values: IProjectView) => Promise<void>;
|
||||
preLoadedData?: Partial<IProjectView> | null;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const DEFAULT_VALUES: Partial<IProjectView> = {
|
||||
name: "",
|
||||
description: "",
|
||||
access: EViewAccess.PUBLIC,
|
||||
display_properties: getComputedDisplayProperties(),
|
||||
display_filters: { ...getComputedDisplayFilters(), group_by: "state" },
|
||||
};
|
||||
|
||||
export const ProjectViewForm = observer(function ProjectViewForm(props: Props) {
|
||||
const { handleFormSubmit, handleClose, data, preLoadedData, projectId, workspaceSlug } = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { isMobile } = usePlatformOS();
|
||||
// form info
|
||||
const defaultValues = {
|
||||
...DEFAULT_VALUES,
|
||||
...preLoadedData,
|
||||
...data,
|
||||
};
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
getValues,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<IProjectView>({
|
||||
defaultValues,
|
||||
});
|
||||
// derived values
|
||||
const projectDetails = getProjectById(projectId);
|
||||
const logoValue = watch("logo_props");
|
||||
const workItemFilters: IIssueFilters = {
|
||||
richFilters: getValues("rich_filters"),
|
||||
displayFilters: getValues("display_filters"),
|
||||
displayProperties: getValues("display_properties"),
|
||||
kanbanFilters: undefined,
|
||||
};
|
||||
const { getIndex } = getTabIndex(ETabIndices.PROJECT_VIEW, isMobile);
|
||||
|
||||
const handleCreateUpdateView = async (formData: IProjectView) => {
|
||||
await handleFormSubmit({
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
logo_props: formData.logo_props,
|
||||
rich_filters: formData.rich_filters,
|
||||
display_filters: formData.display_filters,
|
||||
display_properties: formData.display_properties,
|
||||
access: formData.access,
|
||||
} as IProjectView);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateUpdateView)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-18 font-medium text-secondary">{data ? t("view.update.label") : t("view.create.label")}</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<EmojiPicker
|
||||
iconType="lucide"
|
||||
isOpen={isOpen}
|
||||
handleToggle={(val: boolean) => setIsOpen(val)}
|
||||
className="flex-shrink0 flex items-center justify-center"
|
||||
buttonClassName="flex items-center justify-center"
|
||||
label={
|
||||
<span className="grid h-9 w-9 place-items-center rounded-md bg-surface-2">
|
||||
<>
|
||||
{logoValue?.in_use ? (
|
||||
<Logo logo={logoValue} size={18} type="lucide" />
|
||||
) : (
|
||||
<ViewsOutline className="h-4 w-4 text-tertiary" />
|
||||
)}
|
||||
</>
|
||||
</span>
|
||||
}
|
||||
// TODO: fix types
|
||||
onChange={(val: any) => {
|
||||
// oxlint-disable-next-line no-shadow
|
||||
let logoValue = {};
|
||||
|
||||
if (val?.type === "emoji")
|
||||
logoValue = {
|
||||
value: val.value,
|
||||
};
|
||||
else if (val?.type === "icon") logoValue = val.value;
|
||||
|
||||
setValue("logo_props", {
|
||||
in_use: val?.type,
|
||||
[val?.type]: logoValue,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}}
|
||||
defaultIconColor={logoValue?.in_use && logoValue?.in_use === "icon" ? logoValue?.icon?.color : undefined}
|
||||
defaultOpen={
|
||||
logoValue?.in_use && logoValue?.in_use === "emoji"
|
||||
? EmojiIconPickerTypes.EMOJI
|
||||
: EmojiIconPickerTypes.ICON
|
||||
}
|
||||
/>
|
||||
<div className="flew-grow w-full space-y-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: t("form.title.required"),
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: t("form.title.max_length", { length: 255 }),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Field name="name" invalid={Boolean(errors.name)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="name"
|
||||
type="name"
|
||||
name="name"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={t("common.title")}
|
||||
tabIndex={getIndex("name")}
|
||||
// oxlint-disable-next-line jsx_a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<span className="text-11 text-danger-primary">{errors?.name?.message?.toString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Field name="description" invalid={Boolean(errors?.description)}>
|
||||
<TextAreaGroup resize="none">
|
||||
<TextArea
|
||||
size="lg"
|
||||
surface="field"
|
||||
autoResize
|
||||
maxRows={8}
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder={t("common.description")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tabIndex={getIndex("descriptions")}
|
||||
/>
|
||||
</TextAreaGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="display_filters"
|
||||
render={({ field: { onChange: onDisplayFiltersChange, value: displayFilters } }) => (
|
||||
<>
|
||||
{/* layout dropdown */}
|
||||
<LayoutDropDown
|
||||
onChange={(selectedValue: EIssueLayoutTypes) =>
|
||||
onDisplayFiltersChange({
|
||||
...displayFilters,
|
||||
layout: selectedValue,
|
||||
})
|
||||
}
|
||||
value={displayFilters.layout}
|
||||
/>
|
||||
{/* display filters dropdown */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="display_properties"
|
||||
render={({ field: { onChange: onDisplayPropertiesChange, value: displayProperties } }) => (
|
||||
<FiltersDropdown title={t("common.display")}>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[displayFilters.layout]
|
||||
}
|
||||
displayFilters={displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
onDisplayFiltersChange({
|
||||
...displayFilters,
|
||||
...updatedDisplayFilter,
|
||||
});
|
||||
}}
|
||||
displayProperties={displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={(
|
||||
updatedDisplayProperties: Partial<IIssueDisplayProperties>
|
||||
) => {
|
||||
onDisplayPropertiesChange({
|
||||
...displayProperties,
|
||||
...updatedDisplayProperties,
|
||||
});
|
||||
}}
|
||||
cycleViewDisabled={!projectDetails?.cycle_view}
|
||||
moduleViewDisabled={!projectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{/* filters dropdown */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="rich_filters"
|
||||
render={({ field: { onChange: onFiltersChange } }) => (
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
entityId={data?.id}
|
||||
entityType={EIssuesStoreType.PROJECT_VIEW}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
|
||||
initialWorkItemFilters={workItemFilters}
|
||||
isTemporary
|
||||
updateFilters={(updateFilters) => onFiltersChange(updateFilters)}
|
||||
projectId={projectId}
|
||||
showOnMount
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: projectViewWorkItemsFilter }) =>
|
||||
projectViewWorkItemsFilter && (
|
||||
<WorkItemFiltersRow filter={projectViewWorkItemsFilter} variant="modal" />
|
||||
)
|
||||
}
|
||||
</ProjectLevelWorkItemFiltersHOC>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-subtle px-5 py-4">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose} tabIndex={getIndex("cancel")}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" type="submit" tabIndex={getIndex("submit")} loading={isSubmitting}>
|
||||
{data
|
||||
? isSubmitting
|
||||
? t("common.updating")
|
||||
: t("view.update.label")
|
||||
: isSubmitting
|
||||
? t("common.creating")
|
||||
: t("view.create.label")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
62
apps/web/core/components/views/helper.tsx
Normal file
62
apps/web/core/components/views/helper.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { EIssueLayoutTypes } from "@plane/types";
|
||||
import { WorkspaceSpreadsheetRoot } from "@/components/issues/issue-layouts/spreadsheet/roots/workspace-root";
|
||||
|
||||
export type TWorkspaceLayoutProps = {
|
||||
activeLayout: EIssueLayoutTypes | undefined;
|
||||
isDefaultView: boolean;
|
||||
isLoading?: boolean;
|
||||
toggleLoading: (value: boolean) => void;
|
||||
workspaceSlug: string;
|
||||
globalViewId: string;
|
||||
routeFilters: {
|
||||
[key: string]: string;
|
||||
};
|
||||
fetchNextPages: () => void;
|
||||
globalViewsLoading: boolean;
|
||||
issuesLoading: boolean;
|
||||
};
|
||||
|
||||
export function WorkspaceActiveLayout(props: TWorkspaceLayoutProps) {
|
||||
const {
|
||||
activeLayout = EIssueLayoutTypes.SPREADSHEET,
|
||||
isDefaultView,
|
||||
isLoading,
|
||||
toggleLoading,
|
||||
workspaceSlug,
|
||||
globalViewId,
|
||||
routeFilters,
|
||||
fetchNextPages,
|
||||
globalViewsLoading,
|
||||
issuesLoading,
|
||||
} = props;
|
||||
switch (activeLayout) {
|
||||
case EIssueLayoutTypes.SPREADSHEET:
|
||||
return (
|
||||
<WorkspaceSpreadsheetRoot
|
||||
isDefaultView={isDefaultView}
|
||||
isLoading={isLoading}
|
||||
toggleLoading={toggleLoading}
|
||||
workspaceSlug={workspaceSlug}
|
||||
globalViewId={globalViewId}
|
||||
routeFilters={routeFilters}
|
||||
fetchNextPages={fetchNextPages}
|
||||
globalViewsLoading={globalViewsLoading}
|
||||
issuesLoading={issuesLoading}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
export type TLayoutSelectionProps = {
|
||||
onChange: (layout: EIssueLayoutTypes) => void;
|
||||
selectedLayout: EIssueLayoutTypes;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
102
apps/web/core/components/views/modal.tsx
Normal file
102
apps/web/core/components/views/modal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IProjectView } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
// local imports
|
||||
import { ProjectViewForm } from "./form";
|
||||
|
||||
type Props = {
|
||||
data?: IProjectView | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
preLoadedData?: Partial<IProjectView> | null;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const CreateUpdateProjectViewModal = observer(function CreateUpdateProjectViewModal(props: Props) {
|
||||
const { data, isOpen, onClose, preLoadedData, workspaceSlug, projectId } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { createView, updateView } = useProjectView();
|
||||
const {
|
||||
issuesFilter: { mutateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
|
||||
const { resetExpression } = useWorkItemFilters();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateView = async (payload: IProjectView) => {
|
||||
try {
|
||||
const res = await createView(workspaceSlug, projectId, payload);
|
||||
handleClose();
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/views/${res.id}`);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "View created successfully.",
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to create view. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateView = async (payload: IProjectView) => {
|
||||
try {
|
||||
const viewDetails = await updateView(workspaceSlug, projectId, data?.id as string, payload);
|
||||
mutateFilters(workspaceSlug, viewDetails.id, viewDetails);
|
||||
resetExpression(EIssuesStoreType.PROJECT_VIEW, viewDetails.id, viewDetails.rich_filters);
|
||||
handleClose();
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to update view. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IProjectView) => {
|
||||
if (!data) await handleCreateView(formData);
|
||||
else await handleUpdateView(formData);
|
||||
};
|
||||
|
||||
useKeypress("Escape", () => {
|
||||
if (isOpen) handleClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<ProjectViewForm
|
||||
data={data}
|
||||
handleClose={handleClose}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
preLoadedData={preLoadedData}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/views/publish/index.ts
Normal file
7
apps/web/core/components/views/publish/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./use-view-publish";
|
||||
13
apps/web/core/components/views/publish/use-view-publish.tsx
Normal file
13
apps/web/core/components/views/publish/use-view-publish.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const useViewPublish = (isPublished: boolean, isAuthorized: boolean) => ({
|
||||
isPublishModalOpen: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
setPublishModalOpen: (value: boolean) => {},
|
||||
publishContextMenu: undefined,
|
||||
});
|
||||
143
apps/web/core/components/views/quick-actions.tsx
Normal file
143
apps/web/core/components/views/quick-actions.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreHorizontalOutline } from "@makeplane/propel/icons";
|
||||
// types
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IProjectView } from "@plane/types";
|
||||
// ui
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { useViewMenuItems } from "@/components/common/quick-actions-helper";
|
||||
// hooks
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useViewPublish } from "@/components/views/publish";
|
||||
// local imports
|
||||
import { DeleteProjectViewModal } from "./delete-view-modal";
|
||||
import { CreateUpdateProjectViewModal } from "./modal";
|
||||
|
||||
type Props = {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
projectId: string;
|
||||
view: IProjectView;
|
||||
workspaceSlug: string;
|
||||
customClassName?: string;
|
||||
};
|
||||
|
||||
export const ViewQuickActions = observer(function ViewQuickActions(props: Props) {
|
||||
const { parentRef, projectId, view, workspaceSlug, customClassName } = props;
|
||||
// states
|
||||
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
|
||||
const [deleteViewModal, setDeleteViewModal] = useState(false);
|
||||
// store hooks
|
||||
const { data } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// auth
|
||||
const isOwner = view?.owned_by === data?.id;
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
const { publishContextMenu } = useViewPublish(!!view.anchor, isAdmin || isOwner);
|
||||
|
||||
const viewLink = `${workspaceSlug}/projects/${projectId}/views/${view.id}`;
|
||||
const handleCopyText = () =>
|
||||
// oxlint-disable-next-line promise/always-return
|
||||
copyUrlToClipboard(viewLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "View link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
|
||||
|
||||
const menuResult = useViewMenuItems({
|
||||
isOwner,
|
||||
isAdmin,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
view,
|
||||
handleEdit: () => setCreateUpdateViewModal(true),
|
||||
handleDelete: () => setDeleteViewModal(true),
|
||||
handleCopyLink: handleCopyText,
|
||||
handleOpenInNewTab,
|
||||
});
|
||||
|
||||
// Handle both CE (array) and EE (object) return types
|
||||
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
|
||||
const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
|
||||
|
||||
if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu);
|
||||
|
||||
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
|
||||
return {
|
||||
...item,
|
||||
action: () => {
|
||||
item.action();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateProjectViewModal
|
||||
isOpen={createUpdateViewModal}
|
||||
onClose={() => setCreateUpdateViewModal(false)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
data={view}
|
||||
/>
|
||||
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||
{additionalModals}
|
||||
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
|
||||
<CustomMenu
|
||||
customButton={<IconButton variant="tertiary" size="lg" icon={MoreHorizontalOutline} />}
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
buttonClassName={customClassName}
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
item.action();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
{
|
||||
"text-placeholder": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("whitespace-pre-line text-tertiary", {
|
||||
"text-placeholder": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
125
apps/web/core/components/views/view-list-header.tsx
Normal file
125
apps/web/core/components/views/view-list-header.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { CloseOutline, FilterOutline, SearchOutline } from "@makeplane/propel/icons";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// plane helpers
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { FiltersDropdown } from "../issues/issue-layouts/filters";
|
||||
import { ViewFiltersSelection } from "./filters/filter-selection";
|
||||
import { ViewOrderByDropdown } from "./filters/order-by";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
|
||||
export const ViewListHeader = observer(function ViewListHeader() {
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// store hooks
|
||||
const { filters, updateFilters } = useProjectView();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
// handlers
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
if (filters?.searchQuery && filters?.searchQuery.trim() !== "") {
|
||||
updateFilters("searchQuery", "");
|
||||
} else {
|
||||
setIsSearchOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// outside click detector hook
|
||||
useOutsideClickDetector(inputRef, () => {
|
||||
if (isSearchOpen && filters?.searchQuery.trim() === "") setIsSearchOpen(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (filters?.searchQuery.trim() !== "") setIsSearchOpen(true);
|
||||
}, [filters?.searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
{!isSearchOpen && (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="-mr-1"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
icon={SearchOutline}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-surface-1 text-placeholder opacity-0 transition-[width] ease-linear",
|
||||
{
|
||||
"w-64 border-subtle px-2.5 py-1.5 opacity-100": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<SearchOutline className="h-3.5 w-3.5" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder focus:outline-none"
|
||||
placeholder="Search"
|
||||
value={filters?.searchQuery}
|
||||
onChange={(e) => updateFilters("searchQuery", e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
updateFilters("searchQuery", "");
|
||||
setIsSearchOpen(false);
|
||||
}}
|
||||
>
|
||||
<CloseOutline className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<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={<FilterOutline className="h-3 w-3" />}
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={false}
|
||||
>
|
||||
<ViewFiltersSelection
|
||||
filters={filters}
|
||||
handleFiltersUpdate={updateFilters}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
137
apps/web/core/components/views/view-list-item-action.tsx
Normal file
137
apps/web/core/components/views/view-list-item-action.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { GlobeOutline, LockOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
import type { IProjectView } from "@plane/types";
|
||||
import { EViewAccess } from "@plane/types";
|
||||
import { FavoriteStar } from "@plane/ui";
|
||||
import { getPublishViewLink } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { ButtonAvatars } from "../dropdowns/member/avatar";
|
||||
import { DeleteProjectViewModal } from "./delete-view-modal";
|
||||
import { CreateUpdateProjectViewModal } from "./modal";
|
||||
import { ViewQuickActions } from "./quick-actions";
|
||||
|
||||
type Props = {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
view: IProjectView;
|
||||
};
|
||||
|
||||
export const ViewListItemAction = observer(function ViewListItemAction(props: Props) {
|
||||
const { parentRef, view } = props;
|
||||
// states
|
||||
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
|
||||
const [deleteViewModal, setDeleteViewModal] = useState(false);
|
||||
const [_, setPublishModalOpen] = useState<boolean>(false);
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const { addViewToFavorites, removeViewFromFavorites } = useProjectView();
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
// local storage
|
||||
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteOpen } = useLocalStorage<boolean>(
|
||||
IS_FAVORITE_MENU_OPEN,
|
||||
false
|
||||
);
|
||||
|
||||
// derived values
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
const access = view.access;
|
||||
|
||||
const publishLink = getPublishViewLink(view?.anchor);
|
||||
|
||||
// handlers
|
||||
const handleAddToFavorites = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await addViewToFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
|
||||
if (!isFavoriteOpen) toggleFavoriteMenu(true);
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
|
||||
};
|
||||
|
||||
const ownedByDetails = view.owned_by ? getUserDetails(view.owned_by) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && projectId && view && (
|
||||
<CreateUpdateProjectViewModal
|
||||
isOpen={createUpdateViewModal}
|
||||
onClose={() => setCreateUpdateViewModal(false)}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
data={view}
|
||||
/>
|
||||
)}
|
||||
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||
<div className="cursor-default text-tertiary">
|
||||
<Tooltip label={access === EViewAccess.PUBLIC ? "Public" : "Private"}>
|
||||
{access === EViewAccess.PUBLIC ? <GlobeOutline className="h-4 w-4" /> : <LockOutline className="h-4 w-4" />}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{view?.anchor && publishLink ? (
|
||||
// oxlint-disable-next-line jsx_a11y/click-events-have-key-events oxlint-disable-next-line jsx_a11y/no-static-element-interactions
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-sm bg-success-subtle px-3 py-1.5 text-11 font-medium text-success-primary"
|
||||
onClick={() => setPublishModalOpen(true)}
|
||||
>
|
||||
<span className="size-1.5 flex-shrink-0 rounded-full bg-success-primary" />
|
||||
Live
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{/* created by */}
|
||||
{<ButtonAvatars showTooltip={false} userIds={ownedByDetails?.id ?? []} />}
|
||||
|
||||
{isEditingAllowed && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (view.is_favorite) handleRemoveFromFavorites();
|
||||
else handleAddToFavorites();
|
||||
}}
|
||||
selected={view.is_favorite}
|
||||
/>
|
||||
)}
|
||||
{projectId && workspaceSlug && (
|
||||
<div className="hidden md:block">
|
||||
<ViewQuickActions
|
||||
parentRef={parentRef}
|
||||
projectId={projectId.toString()}
|
||||
view={view}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
63
apps/web/core/components/views/view-list-item.tsx
Normal file
63
apps/web/core/components/views/view-list-item.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { ViewsOutline } from "@makeplane/propel/icons";
|
||||
// types
|
||||
import type { IProjectView } from "@plane/types";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { ViewQuickActions } from "./quick-actions";
|
||||
import { ViewListItemAction } from "./view-list-item-action";
|
||||
|
||||
type Props = {
|
||||
view: IProjectView;
|
||||
};
|
||||
|
||||
export const ProjectViewListItem = observer(function ProjectViewListItem(props: Props) {
|
||||
const { view } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
prependTitleElement={
|
||||
<>
|
||||
{view?.logo_props?.in_use ? (
|
||||
<Logo logo={view?.logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<ViewsOutline className="h-4 w-4 text-tertiary" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
title={view.name}
|
||||
itemLink={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}
|
||||
actionableItems={<ViewListItemAction parentRef={parentRef} view={view} />}
|
||||
quickActionElement={
|
||||
<div className="block md:hidden">
|
||||
<ViewQuickActions
|
||||
parentRef={parentRef}
|
||||
projectId={projectId.toString()}
|
||||
view={view}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
81
apps/web/core/components/views/views-list.tsx
Normal file
81
apps/web/core/components/views/views-list.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
// components
|
||||
import { ListLayout } from "@/components/core/list";
|
||||
import { ViewListLoader } from "@/components/ui/loader/view-list-loader";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { ProjectViewListItem } from "./view-list-item";
|
||||
|
||||
export const ProjectViewsList = observer(function ProjectViewsList() {
|
||||
const { projectId } = useParams();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { toggleCreateViewModal } = useCommandPalette();
|
||||
const { getProjectViews, getFilteredProjectViews, loader } = useProjectView();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const projectViews = getProjectViews(projectId?.toString());
|
||||
const filteredProjectViews = getFilteredProjectViews(projectId?.toString());
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER, EUserProjectRoles.GUEST],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
if (loader || !projectViews || !filteredProjectViews) return <ViewListLoader />;
|
||||
|
||||
if (filteredProjectViews.length === 0 && projectViews.length > 0) {
|
||||
return (
|
||||
<EmptyStateDetailed
|
||||
assetKey="search"
|
||||
title={t("common_empty_state.search.title")}
|
||||
description={t("common_empty_state.search.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredProjectViews.length > 0 ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<ListLayout>
|
||||
{filteredProjectViews.length > 0 ? (
|
||||
filteredProjectViews.map((view) => <ProjectViewListItem key={view.id} view={view} />)
|
||||
) : (
|
||||
<p className="mt-10 text-center text-13 text-tertiary">No results found</p>
|
||||
)}
|
||||
</ListLayout>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateDetailed
|
||||
assetKey="view"
|
||||
title={t("project_empty_state.views.title")}
|
||||
description={t("project_empty_state.views.description")}
|
||||
actions={[
|
||||
{
|
||||
label: t("project_empty_state.views.cta_primary"),
|
||||
onClick: () => toggleCreateViewModal(true),
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
variant: "primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user