Skip to content
Source
Sign inSign up

useProjectGroupUpdate.ts

160 lines5.7 KB

1import { AuthOrchestrationOperateScope, type ProjectIconOverride } from "@t3tools/contracts";
2import {
3 isAtomCommandInterrupted,
4 mapAtomCommandResult,
5 squashAtomCommandFailure,
6 type AtomCommandResult,
7} from "@t3tools/client-runtime/state/runtime";
8import { AsyncResult } from "effect/reactivity";
9import * as Cause from "effect/Cause";
10​
11import { stackedThreadToast, toastManager } from "../components/ui/toast";
12import type { SidebarProjectGroupMember } from "../sidebarProjectGrouping";
13import { useProjects, useServerConfigs } from "../state/entities";
14import { readEnvironment } from "../state/environments";
15import { projectEnvironment } from "../state/projects";
16import { readEnvironmentScope, useEnvironmentsWithScope } from "../state/session";
17import { useOrchestrationCommand } from "../state/use-orchestration-command";
18​
19export type ProjectGroupUpdate = Partial<{
20 title: string;
21 faviconPath: string | null;
22 projectIcon: ProjectIconOverride | null;
23 pinned: boolean;
24 archived: boolean;
25}>;
26​
27type ProjectGroupMember = Pick<
28 SidebarProjectGroupMember,
29 "environmentId" | "id" | "environmentLabel"
30>;
31​
32/**
33 * Writes one update to each checkout of a project, one machine at a time, and
34 * reports the first failure as a toast. Every machine must be connected first.
35 */
36export function useProjectGroupUpdate() {
37 const serverConfigs = useServerConfigs();
38 const updateProject = useOrchestrationCommand(projectEnvironment.update, {
39 reportFailure: false,
40 });
41​
42 const updateProjectGroup = async <TMember extends ProjectGroupMember>(
43 members: ReadonlyArray<TMember>,
44 input: ProjectGroupUpdate | ((member: TMember) => ProjectGroupUpdate),
45 failureTitle: string,
46 ): Promise<AtomCommandResult<void, unknown>> => {
47 const denied = checkProjectAccess(members, failureTitle);
48 if (denied) return denied;
49 const unavailable = members.find((member) => {
50 const environment = readEnvironment(member.environmentId);
51 return environment?.connection.phase !== "connected" || !environment.serverConfig;
52 });
53 if (unavailable) {
54 return failWith(
55 failureTitle,
56 `Connect ${unavailable.environmentLabel ?? "the selected environment"} and try again.`,
57 );
58 }
59 for (const member of members) {
60 // Access can be revoked between writes.
61 const revoked = checkProjectAccess([member], failureTitle);
62 if (revoked) return revoked;
63 const result = mapAtomCommandResult(
64 await updateProject({
65 environmentId: member.environmentId,
66 input: {
67 projectId: member.id,
68 ...(typeof input === "function" ? input(member) : input),
69 },
70 }),
71 () => undefined,
72 );
73 if (result._tag === "Failure") {
74 reportProjectFailure(
75 members.length > 1
76 ? `${failureTitle} on ${member.environmentLabel ?? "the current environment"}`
77 : failureTitle,
78 result,
79 );
80 return result;
81 }
82 }
83 return AsyncResult.success(undefined);
84 };
85​
86 /** Older servers drop pin and archive, so their projects do not offer them. */
87 const canOrganizeProjectGroup = (members: ReadonlyArray<ProjectGroupMember>) =>
88 members.every(
89 (member) =>
90 serverConfigs.get(member.environmentId)?.environment.capabilities.projectOrganization ===
91 true,
92 );
93​
94 return { updateProjectGroup, canOrganizeProjectGroup };
96​
97/** Pin state for a project group, shared by every list that can pin or unpin a project. */
98export function useProjectPin() {
99 const { updateProjectGroup, canOrganizeProjectGroup } = useProjectGroupUpdate();
100 const environments = [...new Set(useProjects().map((project) => project.environmentId))].map(
101 (environmentId) => ({ environmentId }),
102 );
103 const operableEnvironmentIds = useEnvironmentsWithScope(
104 environments,
105 AuthOrchestrationOperateScope,
106 );
107 type PinnableGroup = {
108 readonly memberProjects: ReadonlyArray<ProjectGroupMember>;
109 readonly pinned: boolean;
110 };
111 const canPinProjectGroup = (group: PinnableGroup) =>
112 canOrganizeProjectGroup(group.memberProjects) &&
113 group.memberProjects.every((member) => operableEnvironmentIds.has(member.environmentId));
114 const setProjectGroupPinned = (group: PinnableGroup, pinned: boolean) => {
115 void updateProjectGroup(
116 group.memberProjects,
117 { pinned },
118 pinned ? "Failed to pin project" : "Failed to unpin project",
119 );
120 };
121 return {
122 canPinProjectGroup,
123 setProjectGroupPinned,
124 toggleProjectGroupPin: (group: PinnableGroup) => setProjectGroupPinned(group, !group.pinned),
125 };
127​
128/** Reports the first member this connection cannot change, and returns that failure. */
129export function checkProjectAccess(
130 members: ReadonlyArray<ProjectGroupMember>,
131 failureTitle: string,
132): AtomCommandResult<void, unknown> | null {
133 const denied = members.find(
134 (member) => !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
135 );
136 return denied
137 ? failWith(
138 failureTitle,
139 `This connection cannot change projects in ${denied.environmentLabel ?? "this environment"}.`,
140 )
141 : null;
143​
144export function reportProjectFailure(title: string, result: AtomCommandResult<unknown, unknown>) {
145 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
146 const error = squashAtomCommandFailure(result);
147 toastManager.add(
148 stackedThreadToast({
149 type: "error",
150 title,
151 description: error instanceof Error ? error.message : "An error occurred.",
152 }),
153 );
155​
156function failWith(title: string, message: string): AtomCommandResult<void, unknown> {
157 const result = AsyncResult.failure<void, Error>(Cause.fail(new Error(message)));
158 reportProjectFailure(title, result);
159 return result;