Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/editor/.env.e2e
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
VITE_MDBASE_EDITOR_DEMO=1
VITE_MDBASE_FEEDBACK_URL=http://127.0.0.1:8790/v1/feedback
19 changes: 8 additions & 11 deletions apps/editor/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ActionMenu } from "./ActionMenu";
import { AttachmentTransfer, attachmentMenuItem, useAttachmentUpload } from "./AttachmentUpload";
import { useCollectionBrowserEntries } from "./collection-browser";
import { CollectionRail } from "./CollectionRail";
import { useFeedback } from "@mdbase-dev/ui/feedback";
import { CollectionSwitcher, ConnectScreen } from "./ConnectionScreens";
import { ConflictResolver } from "./ConflictResolver";
import { DraftRecovery, frontmatterPatch } from "./draft-recovery";
Expand All @@ -35,7 +36,7 @@ import {
type ContractCatalogPack
} from "./contract-catalog";
import { reviewCatalogPackInstallation } from "./catalog-pack-installation";
import type { AppPhase, ConnectionState, ContractCatalogLoadState, CreationContext, MobileHistoryState, MobilePane, Surface } from "./app-state-types";
import { isMobileHistoryState, type AppPhase, type ConnectionState, type ContractCatalogLoadState, type CreationContext, type MobileHistoryState, type MobilePane, type Surface } from "./app-state-types";
import { editorPermissions, gatewayError, missingCoreCapabilities, missingTypeCapabilities } from "./gateway";
import { CollectionMutationScope, StaleCollectionOperationError, type CollectionScopeToken } from "./collection-mutation-scope";
import { useTypeDefinitionLifecycle } from "./use-type-definition-lifecycle";
Expand Down Expand Up @@ -156,7 +157,8 @@ interface NoteNavigationOptions {
historyIndex?: number;
}

export function App({ gateway }: { gateway: CollectionGateway }) {
export function App({ gateway, onFeedbackContext }: { gateway: CollectionGateway; onFeedbackContext?(surface: Surface, collectionName?: string): void }) {
const { reportError } = useFeedback();
const { controller: indexController, state: collectionIndex } = useCollectionIndex(gateway);
const { controller: fileController, state: fileInventory } = useFileInventory(gateway);
const fileAssetStore = useFileAssetStore(gateway);
Expand Down Expand Up @@ -192,6 +194,8 @@ export function App({ gateway }: { gateway: CollectionGateway }) {
const deferredSearch = useDeferredValue(search);
const [noteFilter, setNoteFilter] = useState<NoteFilter>();
const [surface, setSurface] = useState<Surface>(initialEditorSurface);
useEffect(() => { onFeedbackContext?.(surface, description?.displayName); }, [surface, description?.displayName, onFeedbackContext]);
useEffect(() => { if (noteOpenFailure) reportError({ code: "source_open_failed" }); }, [noteOpenFailure, reportError]);
const [selectedTypeName, setSelectedTypeName] = useState<string>();
const [typeWorkspace, setTypeWorkspace] = useState<"definition" | "packs">("definition");
const [contractCatalog, setContractCatalog] = useState<ContractCatalogLoadState>({ status: "idle" });
Expand Down Expand Up @@ -444,14 +448,15 @@ export function App({ gateway }: { gateway: CollectionGateway }) {
session.error = `“${session.draft.title || session.document.path}” changed elsewhere. Your edits are still here.`;
if (active) setNotice(session.error);
} else if (snapshot.problem && snapshot.problem !== previous.problem) {
reportError({ code: "save_failed" });
const message = gatewayError(new MdbaseConnectError(snapshot.problem));
session.error = message;
setNotice(active ? message : `Couldn’t save “${session.draft.title || session.document.path}”. ${message}`);
} else if (snapshot.state === "saved" && previous.state !== "saved") {
session.error = undefined;
}
touchSession(session);
}, [touchSession, updateNoteSummary]);
}, [touchSession, updateNoteSummary, reportError]);

const createSession = useCallback((next: NoteDocument) => {
const session = new NoteSession(next, () => typeDescriptorsRef.current, sessionRecords(mutationScope.current.token()));
Expand Down Expand Up @@ -2263,11 +2268,3 @@ function collectionExplicitTypeKeys(configuration: unknown): string[] {
if (!Array.isArray(configured)) return ["type", "types"];
return configured.filter((key): key is string => typeof key === "string");
}

function isMobileHistoryState(value: unknown): value is MobileHistoryState {
if (!value || typeof value !== "object") return false;
const state = value as Partial<MobileHistoryState>;
return state.mdbaseEditor === true
&& (state.pane === "collections" || state.pane === "notes" || state.pane === "editor")
&& (state.surface === "notes" || state.surface === "types" || state.surface === "settings");
}
25 changes: 16 additions & 9 deletions apps/editor/src/AppErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Component, useEffect, type ErrorInfo, type ReactNode } from "react";
import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback";

interface AppErrorBoundaryProps {
children: ReactNode;
product?: "mdbase editor" | "mdbase connect";
}

interface AppErrorBoundaryState {
Expand All @@ -16,17 +18,22 @@ export class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorB
}

componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("mdbase editor encountered an unrecoverable UI error", error, info.componentStack);
console.error(`${this.props.product ?? "mdbase editor"} encountered an unrecoverable UI error`, error, info.componentStack);
}

render(): ReactNode {
if (!this.state.error) return this.props.children;
return <main className="fatal-error" role="alert">
<div>
<strong>mdbase editor needs to restart</strong>
<p>Your collection was not deleted. Changes that had already finished saving are safe.</p>
<button onClick={() => location.reload()}>Reload editor</button>
</div>
</main>;
return <FatalError product={this.props.product ?? "mdbase editor"} />;
}
}

function FatalError({ product }: { product: "mdbase editor" | "mdbase connect" }) {
const { reportError } = useFeedback();
useEffect(() => { reportError({ code: "unknown_error" }); }, [reportError]);
return <main className="fatal-error" role="alert"><div>
<strong>{product} needs to restart</strong>
<p>Your collection was not deleted. Changes that had already finished saving are safe.</p>
<button onClick={() => location.reload()}>Reload {product === "mdbase connect" ? "Connect" : "editor"}</button>
<FeedbackButton topic="problem" />
</div></main>;
}
3 changes: 3 additions & 0 deletions apps/editor/src/CollectionRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
TagIcon as Tag
} from "./icons";
import { ContextMenu } from "./ContextMenu";
import { FeedbackButton } from "@mdbase-dev/ui/feedback";
import "@mdbase-dev/ui/feedback.css";
import { EditorRail } from "./EditorRail";
import type { CollectionFile, ConnectionSummary, NoteSummary } from "./model";
import { folderTree, tags as collectionTags, types as collectionTypes, type FolderTreeNode } from "./note";
Expand Down Expand Up @@ -74,6 +76,7 @@ export function CollectionRail({ collectionId, name, count, types, activeFilter,
? <button className="local-access-action" disabled={directAccessBusy} onClick={onRequestDirectAccess}>{directAccessBusy ? "Checking…" : "Use this computer"}</button>
: <p role="status" aria-label={`Collection ${connectionState}`} title={connectionIssue}><span className={`status-dot ${connectionState}`} aria-hidden="true" /><span>{connectionState === "connected" ? "Connected" : "Reconnecting"}</span></p>}
{connectionState === "reconnecting" && <button className="reconnect-action" aria-label="Retry connection" onClick={onReconnect}>Retry</button>}
<FeedbackButton />
<button className="shortcut-action" aria-label="Keyboard shortcuts" title="Keyboard shortcuts" onClick={onShortcuts}><Keyboard aria-hidden="true" /><span>Shortcuts</span></button>
</>}
>
Expand Down
12 changes: 7 additions & 5 deletions apps/editor/src/ConnectApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,15 @@ describe("ConnectApp", () => {
render(<ConnectApp />);

await screen.findByRole("heading", { name: "Garden notes" });
await user.click(screen.getAllByRole("link", { name: "Send feedback" })[0]);
await user.click(screen.getAllByRole("button", { name: "Send feedback" })[0]);

await waitFor(() => expect(location.pathname).toBe("/connect/feedback"));
expect(screen.getByRole("heading", { name: "Send feedback" })).toHaveFocus();
expect(location.pathname).toBe("/connect");
expect(screen.getByRole("dialog", { name: "Send feedback" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: /What happened/ })).toHaveFocus();
await user.click(screen.getByText("What gets sent"));
expect(screen.getByRole("checkbox", { name: /Include collection name: Garden notes/ })).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: /Include application origin/ })).not.toBeChecked();
expect(screen.getByText(/Nothing here is included unless you choose it/)).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /Include technical diagnostics/ })).not.toBeChecked();
expect(screen.getByText("Private feedback to the mdbase team.")).toBeInTheDocument();
});

it("keeps Connect inside the editor collection shell", async () => {
Expand Down
58 changes: 34 additions & 24 deletions apps/editor/src/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@mdbase/connect-ui/access";
import { useCallback, useEffect, useRef, useState, type FormEvent, type MouseEvent, type ReactNode } from "react";
import { AccountManagement, DeletedAccount } from "./AccountManagement";
import { AppErrorBoundary } from "./AppErrorBoundary";
import { MdbaseMark } from "@mdbase-dev/ui/brand";
import { usePermissionSelection } from "@mdbase-dev/ui/permission-selection";
import {
Expand All @@ -29,7 +30,8 @@ import {
} from "./ConnectPrimitives";
import { EditorRail } from "./EditorRail";
import { FeedbackPage } from "./FeedbackPage";
import { feedbackEndpoint as configuredFeedbackEndpoint, recordFeedbackFailure, turnstileSiteKey, type FeedbackSourceView } from "./feedback";
import { FeedbackButton, FeedbackProvider, feedbackApplication, useFeedback, type FeedbackFailure } from "@mdbase-dev/ui/feedback";
import { feedbackEndpoint as configuredFeedbackEndpoint, turnstileSiteKey } from "./feedback";
import {
BracketsCurlyIcon as Braces,
GearSixIcon as Settings,
Expand All @@ -40,14 +42,15 @@ import {
WarningCircleIcon as Warning
} from "./icons";
import "./connect.css";
import "@mdbase-dev/ui/feedback.css";
import { Select } from "@mdbase-dev/ui/select";

type MemberRole = "viewer" | "editor";

// The owner row shows "Owner" instead of a role choice, so only assignable roles are listed.
const memberRoles: ReadonlyArray<{ value: MemberRole; label: string }> = [{ value: "viewer", label: "Viewer" }, { value: "editor", label: "Editor" }];

type ConnectView = FeedbackSourceView;
type ConnectView = "overview" | "storage" | "access" | "collections" | "applications" | "computers" | "account" | "feedback";
type Grant = ManagementOverview["grants"][number];
type BusyOperations = ReadonlySet<string>;
type PerformOperation = (
Expand All @@ -66,9 +69,19 @@ const allOperations = [
];

export function ConnectApp() {
const [context, setContext] = useState<{ view: ConnectView; collectionName?: string }>({ view: viewFromPath() });
const updateContext = useCallback((view: ConnectView, collectionName?: string) => {
setContext((previous) => previous.view === view && previous.collectionName === collectionName ? previous : { view, collectionName });
}, []);
return <FeedbackProvider endpoint={configuredFeedbackEndpoint()} turnstileSiteKey={turnstileSiteKey()} application={feedbackApplication("mdbase connect", context.view, import.meta.env.VITE_MDBASE_REVISION, import.meta.env.VITE_MDBASE_ENV ?? (import.meta.env.DEV ? "development" : "production"))} collectionName={context.collectionName}>
<AppErrorBoundary product="mdbase connect"><ConnectWorkspace onFeedbackContext={updateContext} /></AppErrorBoundary>
</FeedbackProvider>;
}

function ConnectWorkspace({ onFeedbackContext }: { onFeedbackContext(view: ConnectView, collectionName?: string): void }) {
const { reportError, enabled: feedbackEnabled } = useFeedback();
const [accountDeleted, setAccountDeleted] = useState(location.pathname === "/connect/account-deleted");
const [view, setView] = useState<ConnectView>(viewFromPath);
const [feedbackSourceView, setFeedbackSourceView] = useState<FeedbackSourceView>("overview");
const [data, setData] = useState<ManagementOverview>();
const [sessions, setSessions] = useState<Awaited<ReturnType<typeof management.sessions>>["sessions"]>();
const [refreshError, setRefreshError] = useState("");
Expand Down Expand Up @@ -102,7 +115,6 @@ export function ConnectApp() {
}
} catch (reason) {
if (signal?.aborted) return;
recordFeedbackFailure("management_refresh_failed", reason);
if (reason instanceof ManagementApiError && reason.status === 401) {
location.href = new URL("/login", management.baseUrl).href;
return;
Expand Down Expand Up @@ -156,7 +168,7 @@ export function ConnectApp() {
succeeded = true;
} catch (reason) {
if (!lifecycle.aborted) {
recordFeedbackFailure("management_request_failed", reason);
reportError(feedbackFailure(reason));
setMutationError(errorMessage(reason));
}
} finally {
Expand Down Expand Up @@ -187,7 +199,6 @@ export function ConnectApp() {
}

function navigate(next: ConnectView, collectionId?: string) {
if (next === "feedback" && view !== "feedback") setFeedbackSourceView(view);
const path = next === "overview" ? "/connect" : `/connect/${next}`;
const url = new URL(location.href);
url.pathname = path;
Expand All @@ -209,6 +220,8 @@ export function ConnectApp() {
?? (collections.length === 1 ? collections[0] : undefined);
const selectedCollectionId = selectedCollection?.id;

useEffect(() => { onFeedbackContext(view, selectedCollection?.name); }, [view, selectedCollection?.name, onFeedbackContext]);

useEffect(() => {
if (!data || accountDeleted) return;
if (selectedCollectionId) {
Expand Down Expand Up @@ -242,10 +255,6 @@ export function ConnectApp() {
|| (typeof grant.reauthorization_required_at === "string"
&& !freshlyAuthorized.has(`${grant.application_id}\0${grant.collection_id}`)));
const applications = groupApplicationAccess(activeGrants);
const feedbackEndpoint = configuredFeedbackEndpoint();
const feedbackApplicationOrigins = [...new Set((selectedCollection
? activeGrants.filter((grant) => grant.collection_id === selectedCollection.id)
: activeGrants).map((grant) => normalizedOrigin(grant.application_origin)).filter(Boolean))].sort();
const selectedGrants = selectedCollection
? activeGrants.filter((grant) => grant.collection_id === selectedCollection.id)
: [];
Expand All @@ -268,7 +277,7 @@ export function ConnectApp() {
onSwitch={() => navigate("collections", selectedCollection?.id)}
footer={<>
{selectedCollection && <p role="status"><span className={`status-dot ${selectedCollection.available ? "connected" : "reconnecting"}`} aria-hidden="true" /><span>{selectedCollection.status}</span></p>}
{feedbackEndpoint && <RouteLink className="connect-rail-feedback" view="feedback" collectionId={selectedCollection?.id} navigate={navigate}>Send feedback</RouteLink>}
<FeedbackButton className="connect-rail-feedback" />
<RouteLink className="connect-rail-account" view="account" collectionId={selectedCollection?.id} navigate={navigate} ariaLabel="Open account and sessions"><span className="connect-avatar" aria-hidden="true">{initials(data.user.name)}</span><span><strong>{data.user.name}</strong><small>{identityLabel(data.user)}</small></span></RouteLink>
</>}
/>
Expand All @@ -287,7 +296,7 @@ export function ConnectApp() {
<NavLink label="Applications" icon={<Package />} selected={activeView === "applications"} view="applications" collectionId={selectedCollection?.id} navigate={navigate} />
<NavLink label="Computers" icon={<Braces />} selected={activeView === "computers"} view="computers" collectionId={selectedCollection?.id} navigate={navigate} />
<NavLink label="Account & sessions" icon={<Settings />} selected={activeView === "account"} view="account" collectionId={selectedCollection?.id} navigate={navigate} />
{feedbackEndpoint && <RouteLink className="connect-feedback-mobile-link" view="feedback" collectionId={selectedCollection?.id} navigate={navigate}>Send feedback</RouteLink>}
<FeedbackButton className="connect-feedback-mobile-link" />
</section>
</nav>
</aside>
Expand All @@ -308,15 +317,8 @@ export function ConnectApp() {
{activeView === "applications" && <Applications groups={applications} busy={busy} perform={perform} />}
{activeView === "computers" && <Computers data={data} busy={busy} perform={perform} />}
{activeView === "account" && <AccountManagement client={management} overview={data} sessions={sessions} onOverviewRefresh={refresh} onDeleted={() => setAccountDeleted(true)} />}
{activeView === "feedback" && feedbackEndpoint && <FeedbackPage
endpoint={feedbackEndpoint}
turnstileSiteKey={turnstileSiteKey()}
sourceView={feedbackSourceView}
collectionName={selectedCollection?.name}
applicationOrigins={feedbackApplicationOrigins}
onDone={() => navigate(feedbackSourceView === "feedback" ? "overview" : feedbackSourceView, selectedCollection?.id)}
/>}
{activeView === "feedback" && !feedbackEndpoint && <Page title="Feedback unavailable" intro="This deployment has not configured a feedback destination."><section><Empty title="Feedback is unavailable" body="Contact the person who operates this mdbase connect deployment." /></section></Page>}
{activeView === "feedback" && feedbackEnabled && <FeedbackPage onDone={() => navigate("overview", selectedCollection?.id)} />}
{activeView === "feedback" && !feedbackEnabled && <Page title="Feedback unavailable" intro="This deployment has not configured a feedback destination."><section><Empty title="Feedback is unavailable" body="Contact the person who operates this mdbase connect deployment." /></section></Page>}
</main>
</div>;
}
Expand Down Expand Up @@ -782,7 +784,12 @@ function RouteLink({ view, collectionId, navigate, children, className = "", ari
const connectLoadingFailed = { kind: "error", id: 1 } as const;

function ConnectLoading({ error }: { error: string }) {
return <div className="connect-loading" aria-busy={!error}><MdbaseMark motion="orbit" signal={error ? connectLoadingFailed : null} className="wordmark-mark" /><strong>{error ? "mdbase connect is unavailable" : "Opening mdbase connect"}</strong><p>{error || "Loading your account and collections…"}</p></div>;
const { reportError } = useFeedback();
const reportedInitialFailure = useRef(false);
useEffect(() => {
if (error && !reportedInitialFailure.current) { reportedInitialFailure.current = true; reportError({ code: "unknown_error" }); }
}, [error, reportError]);
return <div className="connect-loading" aria-busy={!error}><MdbaseMark motion="orbit" signal={error ? connectLoadingFailed : null} className="wordmark-mark" /><strong>{error ? "mdbase connect is unavailable" : "Opening mdbase connect"}</strong><p>{error || "Loading your account and collections…"}</p><FeedbackButton topic={error ? "problem" : undefined} /></div>;
}

function DesktopRecoveryHelp({ action }: { action: string }) {
Expand Down Expand Up @@ -925,8 +932,11 @@ function host(value: string): string {
try { return new URL(value).host; } catch { return value; }
}

function normalizedOrigin(value: string): string {
try { return new URL(value).origin; } catch { return ""; }
function feedbackFailure(reason: unknown): FeedbackFailure {
if (!(reason instanceof ManagementApiError)) return { code: "unknown_error" };
const codes = ["cancelled", "http_error", "invalid_response", "outcome_unknown", "partial_failure", "timeout", "network_error"] as const;
const code = codes.find((value) => value === reason.code) ?? "http_error";
return { code, ...(Number.isInteger(reason.status) && reason.status >= 100 && reason.status <= 599 ? { status: reason.status } : {}) };
}

function relativeTime(value: string): string {
Expand Down
Loading
Loading