feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
// constants
// helpers
import { useTranslation } from "@plane/i18n";
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: React.FC<Props> = observer((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 bg-custom-background-80 py-1 px-1.5 text-xs">
<span className="normal-case">{t(label)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(access)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,87 @@
import { X } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { EViewAccess, TViewFilterProps } from "@plane/types";
import { Tag } from "@plane/ui";
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 const ViewAppliedFiltersList: React.FC<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-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TViewFilterProps;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<Tag key={filterKey}>
<span className="text-xs text-custom-text-300">{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-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</Tag>
);
})}
{isEditingAllowed && (
<button type="button" onClick={handleClearAllFilters}>
<Tag>
{t("common.clear_all")}
<X size={12} strokeWidth={2} />
</Tag>
</button>
)}
</div>
);
};

View File

@@ -0,0 +1,88 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
// types
import { PROJECT_VIEW_TRACKER_EVENTS } from "@plane/constants";
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";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProjectView } from "@/hooks/store/use-project-view";
type Props = {
data: IProjectView;
isOpen: boolean;
onClose: () => void;
};
export const DeleteProjectViewModal: React.FC<Props> = observer((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;
setIsDeleteLoading(true);
await deleteView(workspaceSlug.toString(), projectId.toString(), data.id)
.then(() => {
handleClose();
router.push(`/${workspaceSlug}/projects/${projectId}/views`);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "View deleted successfully.",
});
captureSuccess({
eventName: PROJECT_VIEW_TRACKER_EVENTS.delete,
payload: {
view_id: data.id,
},
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "View could not be deleted. Please try again.",
});
captureError({
eventName: PROJECT_VIEW_TRACKER_EVENTS.delete,
payload: {
view_id: data.id,
},
});
})
.finally(() => {
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")}</>}
/>
);
});

View File

@@ -0,0 +1,119 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
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";
// constants
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { FilterByAccess } from "@/plane-web/components/views/filters/access-filter";
type Props = {
filters: TViewFilters;
handleFiltersUpdate: <T extends keyof TViewFilters>(filterKey: T, filterValue: TViewFilters[T]) => void;
memberIds?: string[] | undefined;
};
export const ViewFiltersSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate, memberIds } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
// 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-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus={!isMobile}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
<div className="py-2">
<FilterOption
isChecked={!!filters.filters?.favorites}
onClick={() =>
handleFiltersUpdate("filters", {
...filters.filters,
favorites: !filters.filters?.favorites,
})
}
title="Favorites"
/>
</div>
{/* access / view type */}
<FilterByAccess
appliedFilters={filters.filters?.view_type}
handleUpdate={(val: string | string[]) => handleFilters("view_type", val)}
searchQuery={filtersSearchQuery}
accessFilters={[
{ key: EViewAccess.PRIVATE, value: "Private" },
{ key: EViewAccess.PUBLIC, value: "Public" },
]}
/>
{/* created date */}
<div className="py-2">
<FilterCreatedDate
appliedFilters={filters.filters?.created_at ?? null}
handleUpdate={(val: string | string[]) => handleFilters("created_at", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* created by */}
<div className="py-2">
<FilterCreatedBy
appliedFilters={filters.filters?.owned_by ?? null}
handleUpdate={(val) => handleFilters("owned_by", val)}
searchQuery={filtersSearchQuery}
memberIds={memberIds}
/>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,84 @@
"use client";
import { ArrowDownWideNarrow, ArrowUpWideNarrow, Check, ChevronDown } from "lucide-react";
// types
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";
// ui
import { CustomMenu } from "@plane/ui";
// constants
type Props = {
onChange: (value: { key?: TViewFiltersSortKey; order?: TViewFiltersSortBy }) => void;
sortBy: TViewFiltersSortBy;
sortKey: TViewFiltersSortKey;
isMobile?: boolean;
};
export const ViewOrderByDropdown: React.FC<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-sm text-custom-text-200 gap-2 w-full"
: `${getButtonStyling("neutral-primary", "sm")} px-2 text-custom-text-300`;
const chevronClassName = isMobile ? "h-4 w-4 text-custom-text-200" : "h-3 w-3";
const icon = (
<>{!isDescending ? <ArrowUpWideNarrow className="size-3 " /> : <ArrowDownWideNarrow className="size-3 " />}</>
);
return (
<CustomMenu
customButton={
<span className={buttonClassName}>
{!isMobile && icon}
<span className="flex-shrink-0"> {orderByDetails?.i18n_label && t(orderByDetails?.i18n_label)}</span>
<ChevronDown className={chevronClassName} strokeWidth={2} />
</span>
}
placement="bottom-end"
className="w-full flex 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 && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
<hr className="my-2 border-custom-border-200" />
{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 && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
);
};

View File

@@ -0,0 +1,297 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// plane imports
import { ETabIndices, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
import { ViewsIcon } from "@plane/propel/icons";
import type {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IProjectView,
EIssueLayoutTypes,
IIssueFilters,
} from "@plane/types";
import { EViewAccess, EIssuesStoreType } from "@plane/types";
import { Input, TextArea } from "@plane/ui";
import { getComputedDisplayFilters, getComputedDisplayProperties, getTabIndex } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
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";
// plane web imports
import { AccessController } from "@/plane-web/components/views/access-controller";
// 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: React.FC<Props> = observer((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-xl font-medium text-custom-text-200">
{data ? t("view.update.label") : t("view.create.label")}
</h3>
<div className="space-y-3">
<div className="flex items-start gap-2 w-full">
<EmojiPicker
iconType="lucide"
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center flex-shrink0"
buttonClassName="flex items-center justify-center"
label={
<span className="grid h-9 w-9 place-items-center rounded-md bg-custom-background-90">
<>
{logoValue?.in_use ? (
<Logo logo={logoValue} size={18} type="lucide" />
) : (
<ViewsIcon className="h-4 w-4 text-custom-text-300" />
)}
</>
</span>
}
// TODO: fix types
onChange={(val: any) => {
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="space-y-1 flew-grow w-full">
<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 } }) => (
<Input
id="name"
type="name"
name="name"
value={value}
onChange={onChange}
hasError={Boolean(errors.name)}
placeholder={t("common.title")}
className="w-full text-base"
tabIndex={getIndex("name")}
autoFocus
/>
)}
/>
<span className="text-xs text-red-500">{errors?.name?.message?.toString()}</span>
</div>
</div>
<div>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="description"
name="description"
placeholder={t("common.description")}
className="w-full text-base resize-none min-h-24"
hasError={Boolean(errors?.description)}
value={value}
onChange={onChange}
tabIndex={getIndex("descriptions")}
/>
)}
/>
</div>
<div className="flex gap-2">
<AccessController control={control} />
<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="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="sm" 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>
);
});

View File

@@ -0,0 +1,51 @@
import { EIssueLayoutTypes } from "@plane/types";
import { WorkspaceSpreadsheetRoot } from "@/components/issues/issue-layouts/spreadsheet/roots/workspace-root";
import { WorkspaceAdditionalLayouts } from "@/plane-web/components/views/helper";
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 const 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 <WorkspaceAdditionalLayouts {...props} />;
}
};

View File

@@ -0,0 +1,124 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// types
import { PROJECT_VIEW_TRACKER_EVENTS } from "@plane/constants";
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 { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
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: FC<Props> = observer((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) => {
await createView(workspaceSlug, projectId, payload)
.then((res) => {
handleClose();
router.push(`/${workspaceSlug}/projects/${projectId}/views/${res.id}`);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "View created successfully.",
});
captureSuccess({
eventName: PROJECT_VIEW_TRACKER_EVENTS.create,
payload: {
view_id: res.id,
},
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
captureError({
eventName: PROJECT_VIEW_TRACKER_EVENTS.create,
});
});
};
const handleUpdateView = async (payload: IProjectView) => {
await updateView(workspaceSlug, projectId, data?.id as string, payload)
.then((viewDetails) => {
mutateFilters(workspaceSlug, viewDetails.id, viewDetails);
resetExpression(EIssuesStoreType.PROJECT_VIEW, viewDetails.id, viewDetails.rich_filters);
handleClose();
captureSuccess({
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: data?.id,
},
});
})
.catch((err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.detail ?? "Something went wrong. Please try again.",
});
captureError({
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: data?.id,
},
});
});
};
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>
);
});

View File

@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
// types
import { EUserPermissions, EUserPermissionsLevel, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
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 { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useViewMenuItems } from "@/plane-web/components/views/helper";
import { PublishViewModal, useViewPublish } from "@/plane-web/components/views/publish";
// local imports
import { DeleteProjectViewModal } from "./delete-view-modal";
import { CreateUpdateProjectViewModal } from "./modal";
type Props = {
parentRef: React.RefObject<HTMLElement>;
projectId: string;
view: IProjectView;
workspaceSlug: string;
customClassName?: string;
};
export const ViewQuickActions: React.FC<Props> = observer((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 { isPublishModalOpen, setPublishModalOpen, publishContextMenu } = useViewPublish(
!!view.anchor,
isAdmin || isOwner
);
const viewLink = `${workspaceSlug}/projects/${projectId}/views/${view.id}`;
const handleCopyText = () =>
copyUrlToClipboard(viewLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "View link copied to clipboard.",
});
});
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({
isOwner,
isAdmin,
setDeleteViewModal,
setCreateUpdateViewModal,
handleOpenInNewTab,
handleCopyText,
isLocked: view.is_locked,
workspaceSlug,
projectId,
viewId: view.id,
});
if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu);
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map((item) => ({
...item,
action: () => {
captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.LIST_ITEM_CONTEXT_MENU });
item.action();
},
}));
return (
<>
<CreateUpdateProjectViewModal
isOpen={createUpdateViewModal}
onClose={() => setCreateUpdateViewModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
data={view}
/>
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<PublishViewModal isOpen={isPublishModalOpen} onClose={() => setPublishModalOpen(false)} view={view} />
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu ellipsis placement="bottom-end" closeOnSelect buttonClassName={customClassName}>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.QUICK_ACTIONS });
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": 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("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// icons
import { ListFilter, Search, X } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// 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";
export const ViewListHeader = observer(() => {
// 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="h-full flex items-center gap-2">
<div className="flex items-center">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
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={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);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
<div className="hidden md:flex items-center gap-2">
<ViewOrderByDropdown
sortBy={filters.sortBy}
sortKey={filters.sortKey}
onChange={(val) => {
if (val.key) updateFilters("sortKey", val.key);
if (val.order) updateFilters("sortBy", val.order);
}}
/>
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={false}
>
<ViewFiltersSelection
filters={filters}
handleFiltersUpdate={updateFilters}
memberIds={projectMemberIds ?? undefined}
/>
</FiltersDropdown>
</div>
</div>
);
});

View File

@@ -0,0 +1,134 @@
import type { FC } from "react";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Earth, Lock } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { Tooltip } from "@plane/propel/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";
// plane web imports
import { PublishViewModal } from "@/plane-web/components/views/publish";
// 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>;
view: IProjectView;
};
export const ViewListItemAction: FC<Props> = observer((props) => {
const { parentRef, view } = props;
// states
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
const [deleteViewModal, setDeleteViewModal] = useState(false);
const [isPublishModalOpen, 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 (
<>
<PublishViewModal isOpen={isPublishModalOpen} onClose={() => setPublishModalOpen(false)} view={view} />
{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-custom-text-300">
<Tooltip tooltipContent={access === EViewAccess.PUBLIC ? "Public" : "Private"}>
{access === EViewAccess.PUBLIC ? <Earth className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Tooltip>
</div>
{view?.anchor && publishLink ? (
<div
className="px-3 py-1.5 bg-green-500/20 text-green-500 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
onClick={() => setPublishModalOpen(true)}
>
<span className="flex-shrink-0 rounded-full size-1.5 bg-green-500" />
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>
)}
</>
);
});

View File

@@ -0,0 +1,60 @@
"use client";
import type { FC } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ViewsIcon } from "@plane/propel/icons";
// types
import type { IProjectView } from "@plane/types";
// components
import { Logo } from "@/components/common/logo";
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: FC<Props> = observer((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" />
) : (
<ViewsIcon className="h-4 w-4 text-custom-text-300" />
)}
</>
}
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}
/>
);
});

View File

@@ -0,0 +1,91 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EUserProjectRoles } from "@plane/types";
// components
import { ListLayout } from "@/components/core/list";
import { ComicBoxButton } from "@/components/empty-state/comic-box-button";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
import { ViewListLoader } from "@/components/ui/loader/view-list-loader";
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// local imports
import { ProjectViewListItem } from "./view-list-item";
export const ProjectViewsList = observer(() => {
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
);
const generalViewResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/onboarding/views",
});
const filteredViewResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/search/views",
});
if (loader || !projectViews || !filteredProjectViews) return <ViewListLoader />;
if (filteredProjectViews.length === 0 && projectViews.length > 0) {
return (
<div className="flex items-center justify-center h-full w-full">
<SimpleEmptyState
title={t("project_views.empty_state.filter.title")}
description={t("project_views.empty_state.filter.description")}
assetPath={filteredViewResolvedPath}
/>
</div>
);
}
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-sm text-custom-text-300">No results found</p>
)}
</ListLayout>
</div>
) : (
<DetailedEmptyState
title={t("project_views.empty_state.general.title")}
description={t("project_views.empty_state.general.description")}
assetPath={generalViewResolvedPath}
customPrimaryButton={
<ComicBoxButton
label={t("project_views.empty_state.general.primary_button.text")}
title={t("project_views.empty_state.general.primary_button.comic.title")}
description={t("project_views.empty_state.general.primary_button.comic.description")}
onClick={() => {
toggleCreateViewModal(true);
captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON });
}}
disabled={!canPerformEmptyStateActions}
/>
}
/>
)}
</>
);
});