[WEB-3096] feat: stickies page (#6380)

* feat: added independent stickies page

* chore: randomized sticky color

* chore: search in stickies

* feat: dnd

* fix: quick links

* fix: stickies abrupt rendering

* fix: handled edge cases for dnd

* fix: empty states

* fix: build and lint

* fix: handled new sticky when last sticky is emoty

* fix: new sticky condition

* refactor: stickies empty states, store

* chore: update stickies empty states

* fix: random sticky color

* fix: header

* refactor: better error handling

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
Akshita Goyal 2025-01-16 19:57:51 +05:30 committed by GitHub
parent d2c9b437f4
commit fd7eedc343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1347 additions and 574 deletions

View file

@ -1,45 +1,50 @@
import { orderBy, set } from "lodash";
import { observable, action, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { TSticky } from "@plane/types";
import { STICKIES_PER_PAGE } from "@plane/constants";
import { InstructionType, TLoader, TPaginationInfo, TSticky } from "@plane/types";
import { StickyService } from "@/services/sticky.service";
export interface IStickyStore {
creatingSticky: boolean;
fetchingWorkspaceStickies: boolean;
loader: TLoader;
workspaceStickies: Record<string, string[]>; // workspaceId -> stickyIds
stickies: Record<string, TSticky>; // stickyId -> sticky
searchQuery: string;
activeStickyId: string | undefined;
recentStickyId: string | undefined;
showAddNewSticky: boolean;
currentPage: number;
totalPages: number;
paginationInfo: TPaginationInfo | undefined;
// computed
getWorkspaceStickies: (workspaceSlug: string) => string[];
getWorkspaceStickyIds: (workspaceSlug: string) => string[];
// actions
toggleShowNewSticky: (value: boolean) => void;
updateSearchQuery: (query: string) => void;
fetchWorkspaceStickies: (workspaceSlug: string, cursor?: string, per_page?: number) => void;
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => void;
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => void;
deleteSticky: (workspaceSlug: string, id: string) => void;
fetchWorkspaceStickies: (workspaceSlug: string) => void;
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => Promise<void>;
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => Promise<void>;
deleteSticky: (workspaceSlug: string, id: string) => Promise<void>;
updateActiveStickyId: (id: string | undefined) => void;
fetchRecentSticky: (workspaceSlug: string) => void;
incrementPage: () => void;
fetchRecentSticky: (workspaceSlug: string) => Promise<void>;
fetchNextWorkspaceStickies: (workspaceSlug: string) => Promise<void>;
updateStickyPosition: (
workspaceSlug: string,
stickyId: string,
destinationId: string,
edge: InstructionType
) => Promise<void>;
}
export class StickyStore implements IStickyStore {
loader: TLoader = "init-loader";
creatingSticky = false;
fetchingWorkspaceStickies = true;
workspaceStickies: Record<string, string[]> = {};
stickies: Record<string, TSticky> = {};
recentStickyId: string | undefined = undefined;
searchQuery = "";
activeStickyId: string | undefined = undefined;
showAddNewSticky = false;
currentPage = 0;
totalPages = 0;
paginationInfo: TPaginationInfo | undefined = undefined;
// services
stickyService;
@ -48,33 +53,35 @@ export class StickyStore implements IStickyStore {
makeObservable(this, {
// observables
creatingSticky: observable,
fetchingWorkspaceStickies: observable,
loader: observable,
activeStickyId: observable,
showAddNewSticky: observable,
recentStickyId: observable,
workspaceStickies: observable,
stickies: observable,
searchQuery: observable,
currentPage: observable,
totalPages: observable,
// actions
updateSearchQuery: action,
updateSticky: action,
deleteSticky: action,
incrementPage: action,
fetchNextWorkspaceStickies: action,
fetchWorkspaceStickies: action,
createSticky: action,
updateActiveStickyId: action,
toggleShowNewSticky: action,
fetchRecentSticky: action,
updateStickyPosition: action,
});
this.stickyService = new StickyService();
}
getWorkspaceStickies = computedFn((workspaceSlug: string) => {
let filteredStickies = (this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]);
if (this.searchQuery) {
filteredStickies = filteredStickies.filter(
(sticky) => sticky.name && sticky.name.toLowerCase().includes(this.searchQuery.toLowerCase())
);
}
return filteredStickies.map((sticky) => sticky.id);
});
getWorkspaceStickyIds = computedFn((workspaceSlug: string) =>
orderBy(
(this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]),
["sort_order"],
["desc"]
).map((sticky) => sticky.id)
);
toggleShowNewSticky = (value: boolean) => {
this.showAddNewSticky = value;
@ -88,34 +95,77 @@ export class StickyStore implements IStickyStore {
this.activeStickyId = id;
};
incrementPage = () => {
this.currentPage += 1;
};
fetchRecentSticky = async (workspaceSlug: string) => {
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0", 1);
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0");
runInAction(() => {
this.recentStickyId = response.results[0]?.id;
this.stickies[response.results[0]?.id] = response.results[0];
});
};
fetchWorkspaceStickies = async (workspaceSlug: string, cursor?: string, per_page?: number) => {
fetchNextWorkspaceStickies = async (workspaceSlug: string) => {
try {
const response = await this.stickyService.getStickies(workspaceSlug, cursor, per_page);
if (!this.paginationInfo?.next_cursor || !this.paginationInfo.next_page_results || this.loader === "pagination") {
return;
}
this.loader = "pagination";
const response = await this.stickyService.getStickies(
workspaceSlug,
this.paginationInfo.next_cursor,
this.searchQuery
);
runInAction(() => {
response.results.forEach((sticky) => {
const { results, ...paginationInfo } = response;
// Add new stickies to store
results.forEach((sticky) => {
if (!this.workspaceStickies[workspaceSlug]?.includes(sticky.id)) {
this.workspaceStickies[workspaceSlug] = [...(this.workspaceStickies[workspaceSlug] || []), sticky.id];
}
this.stickies[sticky.id] = sticky;
});
this.totalPages = response.total_pages;
this.fetchingWorkspaceStickies = false;
// Update pagination info directly from backend
set(this, "paginationInfo", paginationInfo);
set(this, "loader", "loaded");
});
} catch (e) {
console.error(e);
this.fetchingWorkspaceStickies = false;
runInAction(() => {
this.loader = "loaded";
});
}
};
fetchWorkspaceStickies = async (workspaceSlug: string) => {
try {
if (this.workspaceStickies[workspaceSlug]) {
this.loader = "mutation";
} else {
this.loader = "init-loader";
}
const response = await this.stickyService.getStickies(
workspaceSlug,
`${STICKIES_PER_PAGE}:0:0`,
this.searchQuery
);
runInAction(() => {
const { results, ...paginationInfo } = response;
results.forEach((sticky) => {
this.stickies[sticky.id] = sticky;
});
this.workspaceStickies[workspaceSlug] = results.map((sticky) => sticky.id);
set(this, "paginationInfo", paginationInfo);
this.loader = "loaded";
});
} catch (e) {
console.error(e);
runInAction(() => {
this.loader = "loaded";
});
}
};
@ -138,16 +188,18 @@ export class StickyStore implements IStickyStore {
const sticky = this.stickies[id];
if (!sticky) return;
try {
this.stickies[id] = {
...sticky,
...updates,
updatedAt: new Date(),
};
runInAction(() => {
Object.keys(updates).forEach((key) => {
const currentStickyKey = key as keyof TSticky;
set(this.stickies[id], key, updates[currentStickyKey] || undefined);
});
});
this.recentStickyId = id;
await this.stickyService.updateSticky(workspaceSlug, id, updates);
} catch (e) {
console.log(e);
} catch (error) {
console.error("Error in updating sticky:", error);
this.stickies[id] = sticky;
throw new Error();
}
};
@ -167,4 +219,53 @@ export class StickyStore implements IStickyStore {
this.stickies[id] = sticky;
}
};
updateStickyPosition = async (
workspaceSlug: string,
stickyId: string,
destinationId: string,
edge: InstructionType
) => {
const previousSortOrder = this.stickies[stickyId].sort_order;
try {
let resultSequence = 10000;
const workspaceStickies = this.workspaceStickies[workspaceSlug] || [];
const stickies = workspaceStickies.map((id) => this.stickies[id]);
const sortedStickies = orderBy(stickies, "sort_order", "desc").map((sticky) => sticky.id);
const destinationSequence = this.stickies[destinationId]?.sort_order || undefined;
if (destinationSequence) {
const destinationIndex = sortedStickies.findIndex((id) => id === destinationId);
if (edge === "reorder-above") {
const prevSequence = this.stickies[sortedStickies[destinationIndex - 1]]?.sort_order || undefined;
if (prevSequence) {
resultSequence = (destinationSequence + prevSequence) / 2;
} else {
resultSequence = destinationSequence + resultSequence;
}
} else {
// reorder-below
resultSequence = destinationSequence - resultSequence;
}
}
runInAction(() => {
this.stickies[stickyId] = {
...this.stickies[stickyId],
sort_order: resultSequence,
};
});
await this.stickyService.updateSticky(workspaceSlug, stickyId, {
sort_order: resultSequence,
});
} catch (error) {
console.error("Failed to move sticky");
runInAction(() => {
this.stickies[stickyId].sort_order = previousSortOrder;
});
throw error;
}
};
}

View file

@ -11,6 +11,7 @@ export interface IHomeStore {
widgetsMap: Record<string, TWidgetEntityData>;
widgets: THomeWidgetKeys[];
// computed
isAnyWidgetEnabled: boolean;
orderedWidgets: THomeWidgetKeys[];
//stores
quickLinks: IWorkspaceLinkStore;
@ -38,6 +39,7 @@ export class HomeStore implements IHomeStore {
widgetsMap: observable,
widgets: observable,
// computed
isAnyWidgetEnabled: computed,
orderedWidgets: computed,
// actions
toggleWidgetSettings: action,
@ -52,6 +54,10 @@ export class HomeStore implements IHomeStore {
this.quickLinks = new WorkspaceLinkStore();
}
get isAnyWidgetEnabled() {
return Object.values(this.widgetsMap).some((widget) => widget.is_enabled);
}
get orderedWidgets() {
return orderBy(Object.values(this.widgetsMap), "sort_order", "desc").map((widget) => widget.key);
}