From 8d730e66804978430451b1fd15b180facc02be03 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Fri, 9 Feb 2024 16:14:08 +0530 Subject: [PATCH] fix: spreadsheet date validation and sorting (#3607) * fix validation for start and end date in spreadsheet layout * revamp logic for sorting in all fields --- .../spreadsheet/columns/due-date-column.tsx | 1 + .../spreadsheet/columns/start-date-column.tsx | 1 + web/store/issue/helpers/issue-helper.store.ts | 165 ++++++++++++++---- web/store/issue/root.store.ts | 39 +++-- web/store/member/workspace-member.store.ts | 8 + 5 files changed, 160 insertions(+), 54 deletions(-) diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index c5674cee9..98262b504 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -21,6 +21,7 @@ export const SpreadsheetDueDateColumn: React.FC = observer((props: Props)
{ const targetDate = data ? renderFormattedPayloadDate(data) : null; onChange( diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index fcbd817b6..82c00fc12 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -21,6 +21,7 @@ export const SpreadsheetStartDateColumn: React.FC = observer((props: Prop
{ const startDate = data ? renderFormattedPayloadDate(data) : null; onChange( diff --git a/web/store/issue/helpers/issue-helper.store.ts b/web/store/issue/helpers/issue-helper.store.ts index 5fdf0df82..ff5dba9dd 100644 --- a/web/store/issue/helpers/issue-helper.store.ts +++ b/web/store/issue/helpers/issue-helper.store.ts @@ -1,7 +1,7 @@ -import sortBy from "lodash/sortBy"; +import orderBy from "lodash/orderBy"; import get from "lodash/get"; import indexOf from "lodash/indexOf"; -import reverse from "lodash/reverse"; +import isEmpty from "lodash/isEmpty"; import values from "lodash/values"; // types import { TIssue, TIssueMap, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types"; @@ -144,98 +144,189 @@ export class IssueHelperStore implements TIssueHelperStore { issueDisplayFiltersDefaultData = (groupBy: string | null): string[] => { switch (groupBy) { case "state": - return this.rootStore?.states || []; + return Object.keys(this.rootStore?.stateMap || {}); case "state_detail.group": return Object.keys(STATE_GROUPS); case "priority": return ISSUE_PRIORITIES.map((i) => i.key); case "labels": - return this.rootStore?.labels || []; + return Object.keys(this.rootStore?.labelMap || {}); case "created_by": - return this.rootStore?.members || []; + return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); case "assignees": - return this.rootStore?.members || []; + return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); case "project": - return this.rootStore?.projects || []; + return Object.keys(this.rootStore?.projectMap || {}); default: return []; } }; + /** + * This Method is used to get data of the issue based on the ids of the data for states, labels adn assignees + * @param dataType what type of data is being sent + * @param dataIds id/ids of the data that is to be populated + * @param order ascending or descending for arrays of data + * @returns string | string[] of sortable fields to be used for sorting + */ + populateIssueDataForSorting( + dataType: "state_id" | "label_ids" | "assignee_ids", + dataIds: string | string[] | null | undefined, + order?: "asc" | "desc" + ) { + if (!dataIds) return; + + const dataValues: string[] = []; + const isDataIdsArray = Array.isArray(dataIds); + const dataIdsArray = isDataIdsArray ? dataIds : [dataIds]; + + switch (dataType) { + case "state_id": + const stateMap = this.rootStore?.stateMap; + if (!stateMap) break; + for (const dataId of dataIdsArray) { + const state = stateMap[dataId]; + if (state && state.name) dataValues.push(state.name.toLocaleLowerCase()); + } + break; + case "label_ids": + const labelMap = this.rootStore?.labelMap; + if (!labelMap) break; + for (const dataId of dataIdsArray) { + const label = labelMap[dataId]; + if (label && label.name) dataValues.push(label.name.toLocaleLowerCase()); + } + break; + case "assignee_ids": + const memberMap = this.rootStore?.memberMap; + if (!memberMap) break; + for (const dataId of dataIdsArray) { + const member = memberMap[dataId]; + if (memberMap && member.first_name) dataValues.push(member.first_name.toLocaleLowerCase()); + } + break; + } + + return isDataIdsArray ? (order ? orderBy(dataValues, undefined, [order]) : dataValues) : dataValues[0]; + } + + /** + * This Method is mainly used to filter out empty values in the begining + * @param key key of the value that is to be checked if empty + * @param object any object in which the key's value is to be checked + * @returns 1 if emoty, 0 if not empty + */ + getSortOrderToFilterEmptyValues(key: string, object: any) { + const value = object?.[key]; + + if (typeof value !== "number" && isEmpty(value)) return 1; + + return 0; + } + issuesSortWithOrderBy = (issueObject: TIssueMap, key: Partial): TIssue[] => { let array = values(issueObject); - array = reverse(sortBy(array, "created_at")); + array = orderBy(array, "created_at"); + switch (key) { case "sort_order": - return sortBy(array, "sort_order"); - + return orderBy(array, "sort_order"); case "state__name": - return reverse(sortBy(array, "state")); + return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"])); case "-state__name": - return sortBy(array, "state"); - + return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"]), ["desc"]); // dates case "created_at": - return sortBy(array, "created_at"); + return orderBy(array, "created_at"); case "-created_at": - return reverse(sortBy(array, "created_at")); - + return orderBy(array, "created_at", ["desc"]); case "updated_at": - return sortBy(array, "updated_at"); + return orderBy(array, "updated_at"); case "-updated_at": - return reverse(sortBy(array, "updated_at")); - + return orderBy(array, "updated_at", ["desc"]); case "start_date": - return sortBy(array, "start_date"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"]); //preferring sorting based on empty values to always keep the empty values below case "-start_date": - return reverse(sortBy(array, "start_date")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); case "target_date": - return sortBy(array, "target_date"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"]); //preferring sorting based on empty values to always keep the empty values below case "-target_date": - return reverse(sortBy(array, "target_date")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); // custom case "priority": { const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return reverse(sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority))); + return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority), ["desc"]); } case "-priority": { const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)); + return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)); } // number case "attachment_count": - return sortBy(array, "attachment_count"); + return orderBy(array, "attachment_count"); case "-attachment_count": - return reverse(sortBy(array, "attachment_count")); + return orderBy(array, "attachment_count", ["desc"]); case "estimate_point": - return sortBy(array, "estimate_point"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"]); //preferring sorting based on empty values to always keep the empty values below case "-estimate_point": - return reverse(sortBy(array, "estimate_point")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); case "link_count": - return sortBy(array, "link_count"); + return orderBy(array, "link_count"); case "-link_count": - return reverse(sortBy(array, "link_count")); + return orderBy(array, "link_count", ["desc"]); case "sub_issues_count": - return sortBy(array, "sub_issues_count"); + return orderBy(array, "sub_issues_count"); case "-sub_issues_count": - return reverse(sortBy(array, "sub_issues_count")); + return orderBy(array, "sub_issues_count", ["desc"]); // Array case "labels__name": - return reverse(sortBy(array, "labels")); + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "asc"), + ]); case "-labels__name": - return sortBy(array, "labels"); + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "desc"), + ], + ["asc", "desc"] + ); case "assignees__first_name": - return reverse(sortBy(array, "assignees")); + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "asc"), + ]); case "-assignees__first_name": - return sortBy(array, "assignees"); + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "desc"), + ], + ["asc", "desc"] + ); default: return array; diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index b2425757c..ee2e6d84d 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -4,7 +4,7 @@ import isEmpty from "lodash/isEmpty"; import { RootStore } from "../root.store"; import { IStateStore, StateStore } from "../state.store"; // issues data store -import { IState } from "@plane/types"; +import { IIssueLabel, IProject, IState, IUserLite } from "@plane/types"; import { IIssueStore, IssueStore } from "./issue.store"; import { IIssueDetail, IssueDetail } from "./issue-details/root.store"; import { IWorkspaceIssuesFilter, WorkspaceIssuesFilter, IWorkspaceIssues, WorkspaceIssues } from "./workspace"; @@ -22,6 +22,7 @@ import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedI import { IDraftIssuesFilter, DraftIssuesFilter, IDraftIssues, DraftIssues } from "./draft"; import { IIssueKanBanViewStore, IssueKanBanViewStore } from "./issue_kanban_view.store"; import { ICalendarStore, CalendarStore } from "./issue_calendar_view.store"; +import { IWorkspaceMembership } from "store/member/workspace-member.store"; export interface IIssueRootStore { currentUserId: string | undefined; @@ -32,11 +33,12 @@ export interface IIssueRootStore { viewId: string | undefined; globalViewId: string | undefined; // all issues view id userId: string | undefined; // user profile detail Id - states: string[] | undefined; + stateMap: Record | undefined; stateDetails: IState[] | undefined; - labels: string[] | undefined; - members: string[] | undefined; - projects: string[] | undefined; + labelMap: Record | undefined; + workSpaceMemberRolesMap: Record | undefined; + memberMap: Record | undefined; + projectMap: Record | undefined; rootStore: RootStore; @@ -83,11 +85,12 @@ export class IssueRootStore implements IIssueRootStore { viewId: string | undefined = undefined; globalViewId: string | undefined = undefined; userId: string | undefined = undefined; - states: string[] | undefined = undefined; + stateMap: Record | undefined = undefined; stateDetails: IState[] | undefined = undefined; - labels: string[] | undefined = undefined; - members: string[] | undefined = undefined; - projects: string[] | undefined = undefined; + labelMap: Record | undefined = undefined; + workSpaceMemberRolesMap: Record | undefined = undefined; + memberMap: Record | undefined = undefined; + projectMap: Record | undefined = undefined; rootStore: RootStore; @@ -133,11 +136,12 @@ export class IssueRootStore implements IIssueRootStore { viewId: observable.ref, userId: observable.ref, globalViewId: observable.ref, - states: observable, + stateMap: observable, stateDetails: observable, - labels: observable, - members: observable, - projects: observable, + labelMap: observable, + memberMap: observable, + workSpaceMemberRolesMap: observable, + projectMap: observable, }); this.rootStore = rootStore; @@ -151,13 +155,14 @@ export class IssueRootStore implements IIssueRootStore { if (rootStore.app.router.viewId) this.viewId = rootStore.app.router.viewId; if (rootStore.app.router.globalViewId) this.globalViewId = rootStore.app.router.globalViewId; if (rootStore.app.router.userId) this.userId = rootStore.app.router.userId; - if (!isEmpty(rootStore?.state?.stateMap)) this.states = Object.keys(rootStore?.state?.stateMap); + if (!isEmpty(rootStore?.state?.stateMap)) this.stateMap = rootStore?.state?.stateMap; if (!isEmpty(rootStore?.state?.projectStates)) this.stateDetails = rootStore?.state?.projectStates; - if (!isEmpty(rootStore?.label?.labelMap)) this.labels = Object.keys(rootStore?.label?.labelMap); + if (!isEmpty(rootStore?.label?.labelMap)) this.labelMap = rootStore?.label?.labelMap; if (!isEmpty(rootStore?.memberRoot?.workspace?.workspaceMemberMap)) - this.members = Object.keys(rootStore?.memberRoot?.workspace?.workspaceMemberMap); + this.workSpaceMemberRolesMap = rootStore?.memberRoot?.workspace?.memberMap || undefined; + if (!isEmpty(rootStore?.memberRoot?.memberMap)) this.memberMap = rootStore?.memberRoot?.memberMap || undefined; if (!isEmpty(rootStore?.projectRoot?.project?.projectMap)) - this.projects = Object.keys(rootStore?.projectRoot?.project?.projectMap); + this.projectMap = rootStore?.projectRoot?.project?.projectMap; }); this.issues = new IssueStore(); diff --git a/web/store/member/workspace-member.store.ts b/web/store/member/workspace-member.store.ts index ff65d0eb9..1dae25bd4 100644 --- a/web/store/member/workspace-member.store.ts +++ b/web/store/member/workspace-member.store.ts @@ -26,6 +26,7 @@ export interface IWorkspaceMemberStore { // computed workspaceMemberIds: string[] | null; workspaceMemberInvitationIds: string[] | null; + memberMap: Record | null; // computed actions getSearchedWorkspaceMemberIds: (searchQuery: string) => string[] | null; getSearchedWorkspaceInvitationIds: (searchQuery: string) => string[] | null; @@ -68,6 +69,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { // computed workspaceMemberIds: computed, workspaceMemberInvitationIds: computed, + memberMap: computed, // actions fetchWorkspaceMembers: action, updateMember: action, @@ -100,6 +102,12 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { return memberIds; } + get memberMap() { + const workspaceSlug = this.routerStore.workspaceSlug; + if (!workspaceSlug) return null; + return this.workspaceMemberMap?.[workspaceSlug] ?? {}; + } + get workspaceMemberInvitationIds() { const workspaceSlug = this.routerStore.workspaceSlug; if (!workspaceSlug) return null;