diff --git a/client/src/protoFleet/api/clients.ts b/client/src/protoFleet/api/clients.ts index f8bc04e28f..ec376c3922 100644 --- a/client/src/protoFleet/api/clients.ts +++ b/client/src/protoFleet/api/clients.ts @@ -12,6 +12,7 @@ import { FleetManagementService } from "@/protoFleet/api/generated/fleetmanageme import { ForemanImportService } from "@/protoFleet/api/generated/foremanimport/v1/foremanimport_pb"; import { MinerCommandService } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; import { NetworkInfoService } from "@/protoFleet/api/generated/networkinfo/v1/networkinfo_pb"; +import { NoteService } from "@/protoFleet/api/generated/notes/v1/notes_pb"; import { OnboardingService } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; import { PairingService } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; import { PoolsService } from "@/protoFleet/api/generated/pools/v1/pools_pb"; @@ -27,6 +28,7 @@ const authzClient = createClient(AuthzService, transport); const curtailmentClient = createClient(CurtailmentService, transport); const errorQueryClient = createClient(ErrorQueryService, transport); const networkInfoClient = createClient(NetworkInfoService, transport); +const notesClient = createClient(NoteService, transport); const pairingClient = createClient(PairingService, transport); const fleetManagementClient = createClient(FleetManagementService, transport); const onboardingClient = createClient(OnboardingService, transport); @@ -50,6 +52,7 @@ export { deviceSetClient, errorQueryClient, networkInfoClient, + notesClient, pairingClient, fleetManagementClient, onboardingClient, diff --git a/client/src/protoFleet/api/generated/notes/v1/notes_pb.ts b/client/src/protoFleet/api/generated/notes/v1/notes_pb.ts new file mode 100644 index 0000000000..dbf7fc6e75 --- /dev/null +++ b/client/src/protoFleet/api/generated/notes/v1/notes_pb.ts @@ -0,0 +1,278 @@ +// @generated by protoc-gen-es v2.12.0 with parameter "target=ts" +// @generated from file notes/v1/notes.proto (package notes.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file notes/v1/notes.proto. + */ +export const file_notes_v1_notes: GenFile = + /*@__PURE__*/ + fileDesc( + "ChRub3Rlcy92MS9ub3Rlcy5wcm90bxIIbm90ZXMudjEinAEKBE5vdGUSCgoCaWQYASABKAMSDwoHY29udGVudBgCIAEoCRIXCg9hdXRob3JfdXNlcm5hbWUYAyABKAkSLgoKY3JlYXRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKdXBkYXRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiRAoQTGlzdE5vdGVzUmVxdWVzdBIcCglwYWdlX3NpemUYASABKAVCCbpIBhoEGGQoARISCgpwYWdlX3Rva2VuGAIgASgJIksKEUxpc3ROb3Rlc1Jlc3BvbnNlEh0KBW5vdGVzGAEgAygLMg4ubm90ZXMudjEuTm90ZRIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkiMAoRQ3JlYXRlTm90ZVJlcXVlc3QSGwoHY29udGVudBgBIAEoCUIKukgHcgUQARiAICIyChJDcmVhdGVOb3RlUmVzcG9uc2USHAoEbm90ZRgBIAEoCzIOLm5vdGVzLnYxLk5vdGUiRQoRVXBkYXRlTm90ZVJlcXVlc3QSEwoCaWQYASABKANCB7pIBCICIAASGwoHY29udGVudBgCIAEoCUIKukgHcgUQARiAICIyChJVcGRhdGVOb3RlUmVzcG9uc2USHAoEbm90ZRgBIAEoCzIOLm5vdGVzLnYxLk5vdGUiKAoRRGVsZXRlTm90ZVJlcXVlc3QSEwoCaWQYASABKANCB7pIBCICIAAiFAoSRGVsZXRlTm90ZVJlc3BvbnNlMq4CCgtOb3RlU2VydmljZRJECglMaXN0Tm90ZXMSGi5ub3Rlcy52MS5MaXN0Tm90ZXNSZXF1ZXN0Ghsubm90ZXMudjEuTGlzdE5vdGVzUmVzcG9uc2USRwoKQ3JlYXRlTm90ZRIbLm5vdGVzLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Ghwubm90ZXMudjEuQ3JlYXRlTm90ZVJlc3BvbnNlEkcKClVwZGF0ZU5vdGUSGy5ub3Rlcy52MS5VcGRhdGVOb3RlUmVxdWVzdBocLm5vdGVzLnYxLlVwZGF0ZU5vdGVSZXNwb25zZRJHCgpEZWxldGVOb3RlEhsubm90ZXMudjEuRGVsZXRlTm90ZVJlcXVlc3QaHC5ub3Rlcy52MS5EZWxldGVOb3RlUmVzcG9uc2VCoAEKDGNvbS5ub3Rlcy52MUIKTm90ZXNQcm90b1ABWkNnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9ub3Rlcy92MTtub3Rlc3YxogIDTlhYqgIITm90ZXMuVjHKAghOb3Rlc1xWMeICFE5vdGVzXFYxXEdQQk1ldGFkYXRh6gIJTm90ZXM6OlYxYgZwcm90bzM", + [file_buf_validate_validate, file_google_protobuf_timestamp], + ); + +/** + * Note is one entry in the shared notepad. + * + * @generated from message notes.v1.Note + */ +export type Note = Message<"notes.v1.Note"> & { + /** + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * @generated from field: string content = 2; + */ + content: string; + + /** + * Display attribution and the client's "own note" comparison key: + * usernames are unique and the client auth store carries exactly + * this value. The server enforces authorship from the session + * regardless of what the client renders. + * + * @generated from field: string author_username = 3; + */ + authorUsername: string; + + /** + * @generated from field: google.protobuf.Timestamp created_at = 4; + */ + createdAt?: Timestamp | undefined; + + /** + * updated_at > created_at means the note was edited — content + * edits are the only mutation a live note can receive. + * + * @generated from field: google.protobuf.Timestamp updated_at = 5; + */ + updatedAt?: Timestamp | undefined; +}; + +/** + * Describes the message notes.v1.Note. + * Use `create(NoteSchema)` to create a new message. + */ +export const NoteSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 0); + +/** + * @generated from message notes.v1.ListNotesRequest + */ +export type ListNotesRequest = Message<"notes.v1.ListNotesRequest"> & { + /** + * @generated from field: int32 page_size = 1; + */ + pageSize: number; + + /** + * Opaque cursor from a prior response. Empty requests the first + * (newest) page. + * + * @generated from field: string page_token = 2; + */ + pageToken: string; +}; + +/** + * Describes the message notes.v1.ListNotesRequest. + * Use `create(ListNotesRequestSchema)` to create a new message. + */ +export const ListNotesRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 1); + +/** + * @generated from message notes.v1.ListNotesResponse + */ +export type ListNotesResponse = Message<"notes.v1.ListNotesResponse"> & { + /** + * @generated from field: repeated notes.v1.Note notes = 1; + */ + notes: Note[]; + + /** + * Empty when there are no further pages. + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message notes.v1.ListNotesResponse. + * Use `create(ListNotesResponseSchema)` to create a new message. + */ +export const ListNotesResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 2); + +/** + * @generated from message notes.v1.CreateNoteRequest + */ +export type CreateNoteRequest = Message<"notes.v1.CreateNoteRequest"> & { + /** + * Server trims surrounding whitespace; must be non-empty after + * the trim. max_len counts codepoints, matching the domain cap. + * + * @generated from field: string content = 1; + */ + content: string; +}; + +/** + * Describes the message notes.v1.CreateNoteRequest. + * Use `create(CreateNoteRequestSchema)` to create a new message. + */ +export const CreateNoteRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 3); + +/** + * @generated from message notes.v1.CreateNoteResponse + */ +export type CreateNoteResponse = Message<"notes.v1.CreateNoteResponse"> & { + /** + * @generated from field: notes.v1.Note note = 1; + */ + note?: Note | undefined; +}; + +/** + * Describes the message notes.v1.CreateNoteResponse. + * Use `create(CreateNoteResponseSchema)` to create a new message. + */ +export const CreateNoteResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_notes_v1_notes, 4); + +/** + * @generated from message notes.v1.UpdateNoteRequest + */ +export type UpdateNoteRequest = Message<"notes.v1.UpdateNoteRequest"> & { + /** + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * @generated from field: string content = 2; + */ + content: string; +}; + +/** + * Describes the message notes.v1.UpdateNoteRequest. + * Use `create(UpdateNoteRequestSchema)` to create a new message. + */ +export const UpdateNoteRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 5); + +/** + * @generated from message notes.v1.UpdateNoteResponse + */ +export type UpdateNoteResponse = Message<"notes.v1.UpdateNoteResponse"> & { + /** + * @generated from field: notes.v1.Note note = 1; + */ + note?: Note | undefined; +}; + +/** + * Describes the message notes.v1.UpdateNoteResponse. + * Use `create(UpdateNoteResponseSchema)` to create a new message. + */ +export const UpdateNoteResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_notes_v1_notes, 6); + +/** + * @generated from message notes.v1.DeleteNoteRequest + */ +export type DeleteNoteRequest = Message<"notes.v1.DeleteNoteRequest"> & { + /** + * @generated from field: int64 id = 1; + */ + id: bigint; +}; + +/** + * Describes the message notes.v1.DeleteNoteRequest. + * Use `create(DeleteNoteRequestSchema)` to create a new message. + */ +export const DeleteNoteRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 7); + +/** + * Empty: success/failure is carried by the RPC status. + * + * @generated from message notes.v1.DeleteNoteResponse + */ +export type DeleteNoteResponse = Message<"notes.v1.DeleteNoteResponse"> & {}; + +/** + * Describes the message notes.v1.DeleteNoteResponse. + * Use `create(DeleteNoteResponseSchema)` to create a new message. + */ +export const DeleteNoteResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_notes_v1_notes, 8); + +/** + * NoteService is the org-wide shared team notepad: one append-style + * feed every member of the org can read and post to. Authors edit and + * delete their own notes; note:manage holders can delete any note. + * All RPCs gate on the any-scope permission check — a role assignment + * at any scope (org or site) makes the caller a member of the team + * the feed serves. + * + * @generated from service notes.v1.NoteService + */ +export const NoteService: GenService<{ + /** + * ListNotes returns the org feed newest-first with keyset + * pagination. An empty next_page_token means the feed is + * exhausted. + * + * @generated from rpc notes.v1.NoteService.ListNotes + */ + listNotes: { + methodKind: "unary"; + input: typeof ListNotesRequestSchema; + output: typeof ListNotesResponseSchema; + }; + /** + * CreateNote appends a note authored by the caller. Content is + * trimmed server-side and must be non-empty after trimming. + * + * @generated from rpc notes.v1.NoteService.CreateNote + */ + createNote: { + methodKind: "unary"; + input: typeof CreateNoteRequestSchema; + output: typeof CreateNoteResponseSchema; + }; + /** + * UpdateNote edits the content of the caller's own note. Editing + * another author's note is rejected regardless of role. + * + * @generated from rpc notes.v1.NoteService.UpdateNote + */ + updateNote: { + methodKind: "unary"; + input: typeof UpdateNoteRequestSchema; + output: typeof UpdateNoteResponseSchema; + }; + /** + * DeleteNote soft-deletes the caller's own note, or any note when + * the caller holds note:manage. + * + * @generated from rpc notes.v1.NoteService.DeleteNote + */ + deleteNote: { + methodKind: "unary"; + input: typeof DeleteNoteRequestSchema; + output: typeof DeleteNoteResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_notes_v1_notes, 0); diff --git a/client/src/protoFleet/api/notes.ts b/client/src/protoFleet/api/notes.ts new file mode 100644 index 0000000000..ebb28bff74 --- /dev/null +++ b/client/src/protoFleet/api/notes.ts @@ -0,0 +1,101 @@ +import { useCallback } from "react"; +import { type Code, ConnectError } from "@connectrpc/connect"; + +import { notesClient } from "@/protoFleet/api/clients"; +import { type Note } from "@/protoFleet/api/generated/notes/v1/notes_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +// Content cap mirrors the proto's max_len and the server's post-trim +// recheck; the composer uses it to disable submit before a round trip. +export const MAX_NOTE_CONTENT_LENGTH = 4096; + +interface CreateNoteProps { + content: string; + signal?: AbortSignal; + onSuccess?: (note: Note) => void; + onError?: (message: string, code?: Code) => void; + onFinally?: () => void; +} + +interface UpdateNoteProps { + id: bigint; + content: string; + signal?: AbortSignal; + onSuccess?: (note: Note) => void; + onError?: (message: string, code?: Code) => void; + onFinally?: () => void; +} + +interface DeleteNoteProps { + id: bigint; + signal?: AbortSignal; + onSuccess?: () => void; + onError?: (message: string, code?: Code) => void; + onFinally?: () => void; +} + +// Mutation hooks for the shared team notepad. Feed reads live in +// useNotesFeed, which owns pagination + poll merging; these callbacks +// follow the sites.ts shape so composer/card components can stay +// presentation-only. +export const useNotes = () => { + const { handleAuthErrors } = useAuthErrors(); + + const fail = useCallback( + (err: unknown, onError?: (message: string, code?: Code) => void) => { + handleAuthErrors({ + error: err, + onError: (error) => { + const code = error instanceof ConnectError ? error.code : undefined; + onError?.(getErrorMessage(error), code); + }, + }); + }, + [handleAuthErrors], + ); + + const createNote = useCallback( + async ({ content, signal, onSuccess, onError, onFinally }: CreateNoteProps) => { + try { + const response = await notesClient.createNote({ content }, { signal }); + if (response.note) onSuccess?.(response.note); + } catch (err) { + fail(err, onError); + } finally { + onFinally?.(); + } + }, + [fail], + ); + + const updateNote = useCallback( + async ({ id, content, signal, onSuccess, onError, onFinally }: UpdateNoteProps) => { + try { + const response = await notesClient.updateNote({ id, content }, { signal }); + if (response.note) onSuccess?.(response.note); + } catch (err) { + fail(err, onError); + } finally { + onFinally?.(); + } + }, + [fail], + ); + + const deleteNote = useCallback( + async ({ id, signal, onSuccess, onError, onFinally }: DeleteNoteProps) => { + try { + await notesClient.deleteNote({ id }, { signal }); + onSuccess?.(); + } catch (err) { + fail(err, onError); + } finally { + onFinally?.(); + } + }, + [fail], + ); + + return { createNote, updateNote, deleteNote }; +}; diff --git a/client/src/protoFleet/api/useNotesFeed.test.ts b/client/src/protoFleet/api/useNotesFeed.test.ts new file mode 100644 index 0000000000..c381969324 --- /dev/null +++ b/client/src/protoFleet/api/useNotesFeed.test.ts @@ -0,0 +1,395 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { TimestampSchema } from "@bufbuild/protobuf/wkt"; +import { notesClient } from "./clients"; +import { mergeHeadPage, useNotesFeed } from "./useNotesFeed"; +import { ListNotesResponseSchema, type Note, NoteSchema } from "@/protoFleet/api/generated/notes/v1/notes_pb"; + +vi.mock("./clients", () => ({ + notesClient: { + listNotes: vi.fn(), + }, +})); + +const mockHandleAuthErrors = vi.fn(({ onError }) => onError?.(new Error("auth error"))); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +// seconds encodes the feed position; id breaks ties exactly like the +// server's (created_at, id) keyset. An edited note must carry a later +// updatedSeconds — the server's updated_at trigger guarantees content +// never changes without it, and the merge's change detection relies +// on that invariant. +function makeNote(id: number, seconds: number, content = `note ${id}`, updatedSeconds = seconds): Note { + return create(NoteSchema, { + id: BigInt(id), + content, + authorUsername: "alice", + createdAt: create(TimestampSchema, { seconds: BigInt(seconds), nanos: 0 }), + updatedAt: create(TimestampSchema, { seconds: BigInt(updatedSeconds), nanos: 0 }), + }); +} + +function mockListResponse(notes: Note[], nextPageToken = "") { + return create(ListNotesResponseSchema, { notes, nextPageToken }); +} + +describe("mergeHeadPage", () => { + // Feed order is newest-first; the head page covers ids 5..3. + const held = [makeNote(5, 500), makeNote(4, 400), makeNote(3, 300), makeNote(2, 200), makeNote(1, 100)]; + + it("prepends rows strictly newer than the current head", () => { + const head = [makeNote(6, 600), makeNote(5, 500), makeNote(4, 400)]; + const merged = mergeHeadPage(held, head); + expect(merged.map((n) => n.id)).toEqual([6n, 5n, 4n, 3n, 2n, 1n]); + }); + + it("replaces held copies with the head's version to pick up edits", () => { + const edited = makeNote(4, 400, "edited content", 450); + const head = [makeNote(5, 500), edited, makeNote(3, 300)]; + const merged = mergeHeadPage(held, head); + expect(merged.find((n) => n.id === 4n)?.content).toBe("edited content"); + expect(merged.map((n) => n.id)).toEqual([5n, 4n, 3n, 2n, 1n]); + }); + + it("drops held rows inside the head window that the head no longer carries", () => { + // Note 4 was deleted upstream: the head window now spans 5..2. + const head = [makeNote(5, 500), makeNote(3, 300), makeNote(2, 200)]; + const merged = mergeHeadPage(held, head); + expect(merged.map((n) => n.id)).toEqual([5n, 3n, 2n, 1n]); + }); + + it("keeps rows older than the head window untouched", () => { + const head = [makeNote(5, 500), makeNote(4, 400)]; + const merged = mergeHeadPage(held, head); + expect(merged.map((n) => n.id)).toEqual([5n, 4n, 3n, 2n, 1n]); + }); + + it("breaks created_at ties by id like the server keyset", () => { + // Held rows 3 and 2 share a timestamp; the window floor is (300, 3), + // so id 2 (same time, lower id) is older-than-window and survives. + const tiedHeld = [makeNote(3, 300), makeNote(2, 300), makeNote(1, 100)]; + const head = [makeNote(4, 300), makeNote(3, 300)]; + const merged = mergeHeadPage(tiedHeld, head); + expect(merged.map((n) => n.id)).toEqual([4n, 3n, 2n, 1n]); + }); + + it("returns an empty feed when the head page is empty", () => { + expect(mergeHeadPage(held, [])).toEqual([]); + }); + + it("drops rows below the window when the head is the whole feed", () => { + // No continuation token: notes 3..1 were deleted upstream and the + // feed now ends at the head window floor. + const head = [makeNote(5, 500), makeNote(4, 400)]; + const merged = mergeHeadPage(held, head, true); + expect(merged.map((n) => n.id)).toEqual([5n, 4n]); + }); + + it("returns the previous array reference when the head changes nothing", () => { + // The poll tick runs this inside a setState updater; returning the + // same reference is what lets React skip the re-render. + const sameHead = [makeNote(5, 500), makeNote(4, 400), makeNote(3, 300), makeNote(2, 200), makeNote(1, 100)]; + expect(mergeHeadPage(held, sameHead)).toBe(held); + }); + + it("returns the previous reference when an empty feed stays empty", () => { + const empty: Note[] = []; + expect(mergeHeadPage(empty, [])).toBe(empty); + }); + + it("returns the previous reference when a complete head changes nothing", () => { + const sameHead = [makeNote(5, 500), makeNote(4, 400), makeNote(3, 300), makeNote(2, 200), makeNote(1, 100)]; + expect(mergeHeadPage(held, sameHead, true)).toBe(held); + }); + + it("returns a new array when only a note's updated_at changed", () => { + const head = [ + makeNote(5, 500, "note 5", 550), + makeNote(4, 400), + makeNote(3, 300), + makeNote(2, 200), + makeNote(1, 100), + ]; + expect(mergeHeadPage(held, head)).not.toBe(held); + }); +}); + +describe("useNotesFeed", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // The hook deliberately has no fetch-on-mount: the panel drives the + // initial load through usePoll when it opens. Tests stand in for the + // panel by calling refresh() explicitly. + it("refresh loads the first page and reports hasLoaded", async () => { + vi.mocked(notesClient.listNotes).mockResolvedValue(mockListResponse([makeNote(2, 200), makeNote(1, 100)])); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 25 })); + expect(result.current.hasLoaded).toBe(false); + + act(() => { + result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(notesClient.listNotes).toHaveBeenCalledWith({ pageSize: 25, pageToken: "" }); + expect(result.current.notes).toHaveLength(2); + expect(result.current.hasMore).toBe(false); + }); + + it("loadMore appends the next page using the returned token", async () => { + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(2, 200)], "token-2")) + .mockResolvedValueOnce(mockListResponse([makeNote(1, 100)], "")); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 1 })); + + act(() => { + result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.hasMore).toBe(true); + }); + + act(() => { + result.current.loadMore(); + }); + + await waitFor(() => { + expect(result.current.notes).toHaveLength(2); + }); + + expect(notesClient.listNotes).toHaveBeenLastCalledWith({ pageSize: 1, pageToken: "token-2" }); + expect(result.current.hasMore).toBe(false); + expect(result.current.notes.map((n) => n.id)).toEqual([2n, 1n]); + }); + + it("refreshHead merges the head page without collapsing loaded pages", async () => { + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(3, 300), makeNote(2, 200)], "token-2")) + .mockResolvedValueOnce(mockListResponse([makeNote(1, 100)], "")) + // Poll tick: note 4 arrived and note 3 was deleted upstream, so + // the fresh head window spans (400 .. 200). The continuation + // token (always present on a full page) says rows exist below + // the window, so note 1 must survive untouched. + .mockResolvedValueOnce(mockListResponse([makeNote(4, 400), makeNote(2, 200)], "token-head")); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 2 })); + + act(() => { + result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.hasMore).toBe(true); + }); + act(() => { + result.current.loadMore(); + }); + await waitFor(() => { + expect(result.current.notes).toHaveLength(3); + }); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.notes.map((n) => n.id)).toEqual([4n, 2n, 1n]); + expect(result.current.hasMore).toBe(false); + }); + + it("a head page with no continuation token replaces the feed and clears the cursor", async () => { + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(4, 400), makeNote(3, 300)], "token-2")) + .mockResolvedValueOnce(mockListResponse([makeNote(2, 200), makeNote(1, 100)], "token-3")) + // Poll tick: everything below note 4 was deleted upstream, so + // the head page carries the entire feed and no token. + .mockResolvedValueOnce(mockListResponse([makeNote(4, 400)])); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 2 })); + + act(() => { + result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.hasMore).toBe(true); + }); + act(() => { + result.current.loadMore(); + }); + await waitFor(() => { + expect(result.current.notes).toHaveLength(4); + }); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.notes.map((n) => n.id)).toEqual([4n]); + expect(result.current.hasMore).toBe(false); + + // The cleared cursor makes a follow-up Load more a no-op instead + // of appending a stale page below the now-complete feed. + act(() => { + result.current.loadMore(); + }); + expect(notesClient.listNotes).toHaveBeenCalledTimes(3); + }); + + it("an empty head page clears the feed and the cursor", async () => { + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(1, 100)], "token-x")) + .mockResolvedValueOnce(mockListResponse([])); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 1 })); + + act(() => { + result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.hasMore).toBe(true); + }); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.notes).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + + it("an initial load through refreshHead seeds the Load more cursor", async () => { + // The panel's first fetch goes through usePoll → refreshHead, not + // refresh(), so a deeper-than-one-page feed must still surface + // Load more after that first tick. + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(3, 300), makeNote(2, 200)], "token-2")) + .mockResolvedValueOnce(mockListResponse([makeNote(1, 100)], "")); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 2 })); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.hasLoaded).toBe(true); + expect(result.current.hasMore).toBe(true); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => { + expect(result.current.notes).toHaveLength(3); + }); + + expect(notesClient.listNotes).toHaveBeenLastCalledWith({ pageSize: 2, pageToken: "token-2" }); + expect(result.current.hasMore).toBe(false); + }); + + it("a later head tick with a continuation token leaves the cursor alone", async () => { + // After the initial load, the held cursor tracks the bottom of the + // accumulated list; a head token only describes rows below the + // head window, which the list already holds. + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(2, 200), makeNote(1, 100)], "")) + .mockResolvedValueOnce(mockListResponse([makeNote(3, 300), makeNote(2, 200)], "token-stale")); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 2 })); + + act(() => { + result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + expect(result.current.hasMore).toBe(false); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.notes.map((n) => n.id)).toEqual([3n, 2n, 1n]); + expect(result.current.hasMore).toBe(false); + }); + + it("surfaces list errors through the shared auth handler", async () => { + vi.mocked(notesClient.listNotes).mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useNotesFeed()); + + act(() => { + result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.error).not.toBeNull(); + }); + expect(mockHandleAuthErrors).toHaveBeenCalled(); + expect(result.current.hasLoaded).toBe(false); + }); + + it("refreshHead surfaces a failure before anything has loaded", async () => { + vi.mocked(notesClient.listNotes).mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useNotesFeed()); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.error).not.toBeNull(); + expect(result.current.hasLoaded).toBe(false); + }); + + it("refreshHead stays silent on failures after a successful load", async () => { + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(1, 100)])) + .mockRejectedValueOnce(new Error("boom")); + + const { result } = renderHook(() => useNotesFeed()); + + await act(async () => { + await result.current.refreshHead(); + }); + expect(result.current.hasLoaded).toBe(true); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.notes.map((n) => n.id)).toEqual([1n]); + }); + + it("a successful refreshHead clears the error from a failed first load", async () => { + vi.mocked(notesClient.listNotes) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce(mockListResponse([makeNote(1, 100)])); + + const { result } = renderHook(() => useNotesFeed()); + + await act(async () => { + await result.current.refreshHead(); + }); + expect(result.current.error).not.toBeNull(); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.hasLoaded).toBe(true); + expect(result.current.notes.map((n) => n.id)).toEqual([1n]); + }); +}); diff --git a/client/src/protoFleet/api/useNotesFeed.ts b/client/src/protoFleet/api/useNotesFeed.ts new file mode 100644 index 0000000000..d18b02240b --- /dev/null +++ b/client/src/protoFleet/api/useNotesFeed.ts @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { notesClient } from "@/protoFleet/api/clients"; +import { type Note } from "@/protoFleet/api/generated/notes/v1/notes_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseNotesFeedParams { + pageSize?: number; +} + +interface UseNotesFeedResult { + notes: Note[]; + isLoading: boolean; + // True once any fetch has succeeded — distinguishes "loading the + // feed for the first time" (spinner) from "feed is genuinely empty". + hasLoaded: boolean; + error: string | null; + hasMore: boolean; + loadMore: () => void; + refresh: () => void; + refreshHead: () => Promise; +} + +// Compare two notes by the server feed order: (created_at, id) +// descending. Positive when a sorts after b in the feed (i.e. a is +// older). Compares the raw Timestamp fields so sub-millisecond +// distinctions survive (Date would truncate to ms). +const feedCmp = (a: Note, b: Note): number => { + const at = a.createdAt; + const bt = b.createdAt; + const as = at?.seconds ?? 0n; + const bs = bt?.seconds ?? 0n; + if (as !== bs) return as < bs ? 1 : -1; + const an = at?.nanos ?? 0; + const bn = bt?.nanos ?? 0; + if (an !== bn) return an < bn ? 1 : -1; + if (a.id !== b.id) return a.id < b.id ? 1 : -1; + return 0; +}; + +// mergeHeadPage folds a freshly fetched first page into the +// accumulated feed without collapsing pages loaded via Load more. +// +// The head page is authoritative for its own window (everything at or +// newer than its oldest row): rows it carries replace held copies +// (picking up edits), rows it doesn't carry were deleted upstream and +// drop out. Held rows older than the window are kept untouched — +// stale until the next full refresh, which is normal feed behavior — +// unless headIsComplete reports the head as the entire feed, in which +// case anything below the window was deleted upstream and drops too. +// An empty head page means the feed itself is empty. +// Same note in the feed sense: id plus updated_at. Content cannot +// change without the server's updated_at trigger advancing, so this +// pair captures edits without comparing content bytes. +const sameNote = (a: Note, b: Note): boolean => + a.id === b.id && + (a.updatedAt?.seconds ?? 0n) === (b.updatedAt?.seconds ?? 0n) && + (a.updatedAt?.nanos ?? 0) === (b.updatedAt?.nanos ?? 0); + +export const mergeHeadPage = (prev: Note[], head: Note[], headIsComplete = false): Note[] => { + let next: Note[]; + if (head.length === 0 || headIsComplete) { + next = head; + } else { + const windowFloor = head[head.length - 1]; + const headIds = new Set(head.map((n) => n.id)); + const olderThanWindow = prev.filter((n) => !headIds.has(n.id) && feedCmp(n, windowFloor) > 0); + next = [...head, ...olderThanWindow]; + } + // Same-reference bail: the poll tick calls this inside a setState + // updater, and React only skips the re-render when the updater + // returns the previous reference. Head rows are fresh objects every + // fetch, so reference equality alone would never hold. + if (next.length === prev.length && next.every((note, i) => sameNote(note, prev[i]))) { + return prev; + } + return next; +}; + +// Feed state for the shared team notepad: cursor accumulation + +// Load more mirroring useActivity, plus refreshHead() for the poll +// tick so the visible top of the feed stays live without resetting +// scroll position or loaded pages. +export function useNotesFeed({ pageSize = 25 }: UseNotesFeedParams = {}): UseNotesFeedResult { + const { handleAuthErrors } = useAuthErrors(); + + const [notes, setNotes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [pageToken, setPageToken] = useState(""); + + const requestIdRef = useRef(0); + + const fetchNotes = useCallback( + async (token: string, append: boolean) => { + const requestId = ++requestIdRef.current; + setIsLoading(true); + setError(null); + + try { + const response = await notesClient.listNotes({ pageSize, pageToken: token }); + if (requestId !== requestIdRef.current) return; + + const { notes: newNotes, nextPageToken } = response; + if (append) { + setNotes((prev) => [...prev, ...newNotes]); + } else { + setNotes(newNotes); + } + setPageToken(nextPageToken); + setHasMore(nextPageToken !== ""); + setHasLoaded(true); + } catch (err) { + if (requestId !== requestIdRef.current) return; + handleAuthErrors({ + error: err, + onError: (e) => { + setError(getErrorMessage(e, "Failed to load notes")); + }, + }); + } finally { + if (requestId === requestIdRef.current) { + setIsLoading(false); + } + } + }, + [pageSize, handleAuthErrors], + ); + + // Ref-based stability (same pattern as useActivity.ts). + const fetchRef = useRef(fetchNotes); + useEffect(() => { + fetchRef.current = fetchNotes; + }, [fetchNotes]); + + const pageTokenRef = useRef(pageToken); + useEffect(() => { + pageTokenRef.current = pageToken; + }, [pageToken]); + + const isLoadingRef = useRef(isLoading); + useEffect(() => { + isLoadingRef.current = isLoading; + }, [isLoading]); + + const hasMoreRef = useRef(hasMore); + useEffect(() => { + hasMoreRef.current = hasMore; + }, [hasMore]); + + const hasLoadedRef = useRef(hasLoaded); + useEffect(() => { + hasLoadedRef.current = hasLoaded; + }, [hasLoaded]); + + const pageSizeRef = useRef(pageSize); + useEffect(() => { + pageSizeRef.current = pageSize; + }, [pageSize]); + + const loadMore = useCallback(() => { + if (hasMoreRef.current && !isLoadingRef.current) { + void fetchRef.current(pageTokenRef.current, true); + } + }, []); + + const refresh = useCallback(() => { + if (isLoadingRef.current) return; + setNotes([]); + setPageToken(""); + setHasMore(false); + void fetchRef.current("", false); + }, []); + + // refreshHead is the poll tick: re-fetch page 1 only and merge it + // into the accumulated list. It deliberately bypasses the + // isLoading/cursor state so an in-flight Load more and a poll can't + // corrupt each other — the merge is associative with appends. + const refreshHead = useCallback(async () => { + try { + const response = await notesClient.listNotes({ pageSize: pageSizeRef.current, pageToken: "" }); + const { notes: head, nextPageToken } = response; + // The server emits a continuation token only when more rows + // exist below the page, so no token means the head is the + // entire feed. + const headIsComplete = nextPageToken === ""; + setNotes((prev) => mergeHeadPage(prev, head, headIsComplete)); + setHasLoaded(true); + setError(null); + if (headIsComplete) { + // The feed now ends inside the head window: any held cursor + // points at deleted rows. + setPageToken(""); + setHasMore(false); + } else if (!hasLoadedRef.current) { + // This tick performed the initial load (the panel's usePoll + // fires refreshHead immediately on open), so the head page IS + // the entire accumulated list and its continuation token is + // the correct Load-more cursor. Later ticks leave the cursor + // alone — it tracks the bottom of the accumulated list, not + // the head window. + setPageToken(nextPageToken); + setHasMore(true); + } + } catch (err) { + // Poll-tick failures after a successful load are deliberately + // silent: the feed keeps its last-good rows and the next tick + // retries. Before the first load there are no rows to keep, so + // surface the error instead of an indefinite spinner — the poll + // keeps running, and a later success clears the callout. Auth + // errors still route through the shared handler so an expired + // session logs out. + handleAuthErrors({ + error: err, + onError: (e) => { + if (!hasLoadedRef.current) { + setError(getErrorMessage(e, "Failed to load notes")); + } + }, + }); + } + }, [handleAuthErrors]); + + return { notes, isLoading, hasLoaded, error, hasMore, loadMore, refresh, refreshHead }; +} diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.tsx index 2c7e099027..2c046c0a58 100644 --- a/client/src/protoFleet/components/AppLayout/AppLayout.tsx +++ b/client/src/protoFleet/components/AppLayout/AppLayout.tsx @@ -7,6 +7,7 @@ import PageHeader from "@/protoFleet/components/PageHeader"; import { useCurtailmentPillData } from "@/protoFleet/components/PageHeader/useCurtailmentPillData"; import { useSchedulePillData } from "@/protoFleet/components/PageHeader/useSchedulePillData"; import { primaryNavItems } from "@/protoFleet/config/navItems"; +import { NotepadPanel } from "@/protoFleet/features/notes"; import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; @@ -54,6 +55,8 @@ const AppLayoutContent = ({ children }: Props) => { > {children} + + ); }; diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx index 2b223072f3..c3cc4ed17d 100644 --- a/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx +++ b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx @@ -43,12 +43,17 @@ vi.mock("@/shared/hooks/useReactiveLocalStorage", () => ({ useReactiveLocalStorage: () => mockUseReactiveLocalStorage(), })); +const mockSetNotepadOpen = vi.fn(); + vi.mock("@/protoFleet/store", () => ({ useHasPermission: vi.fn(), + useIsNotepadOpen: vi.fn(() => false), + useSetNotepadOpen: vi.fn(() => mockSetNotepadOpen), })); vi.mock("@/shared/assets/icons", () => ({ Pause: ({ ariaLabel }: { ariaLabel?: string }) => , + Edit: () => , })); const createPillSchedule = (name: string): ScheduleListItem => ({ @@ -161,4 +166,35 @@ describe("PageHeader", () => { expect(screen.queryByText("Curtailment pill")).not.toBeInTheDocument(); expect(mockCurtailmentPill).not.toHaveBeenCalled(); }); + + it("shows the notepad toggle with note:read and forwards clicks to the store", () => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + vi.mocked(useHasPermission).mockImplementation((key: string) => key === "note:read"); + + render( + + + , + ); + + const toggle = screen.getByTestId("notepad-toggle"); + expect(toggle).toBeVisible(); + toggle.click(); + expect(mockSetNotepadOpen).toHaveBeenCalledWith(true); + }); + + it("hides the notepad toggle without note:read", () => { + vi.mocked(useHasPermission).mockReturnValue(false); + + render( + + + , + ); + + expect(screen.queryByTestId("notepad-toggle")).not.toBeInTheDocument(); + }); }); diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.tsx index d02581653d..21f8ecc36e 100644 --- a/client/src/protoFleet/components/PageHeader/PageHeader.tsx +++ b/client/src/protoFleet/components/PageHeader/PageHeader.tsx @@ -11,8 +11,8 @@ import { type SiteWithCounts } from "@/protoFleet/api/generated/sites/v1/sites_p import { useSites } from "@/protoFleet/api/sites"; import { MULTI_SITE_ENABLED } from "@/protoFleet/constants/featureFlags"; import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; -import { useHasPermission } from "@/protoFleet/store"; -import { Pause } from "@/shared/assets/icons"; +import { useHasPermission, useIsNotepadOpen, useSetNotepadOpen } from "@/protoFleet/store"; +import { Edit, Pause } from "@/shared/assets/icons"; import Button, { sizes, variants } from "@/shared/components/Button"; import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; @@ -76,6 +76,9 @@ function PageHeader({ const [dismissedSetup, setDismissedSetup] = useReactiveLocalStorage("completeSetupDismissed"); const hasDismissedSetup = Boolean(dismissedSetup); const canReadCurtailment = useHasPermission("curtailment:read"); + const canReadNotes = useHasPermission("note:read"); + const isNotepadOpen = useIsNotepadOpen(); + const setNotepadOpen = useSetNotepadOpen(); // Multi-site: the SitePicker replaces today's LocationSelector when the // feature flag is on. Sites are fetched once on mount and held here so the @@ -143,7 +146,21 @@ function PageHeader({ )} - {!isPhone && headerWidgetEnabled ? : null} +
+ {!isPhone && headerWidgetEnabled ? : null} + {canReadNotes ? ( +
{showPhoneWidgets ? ( diff --git a/client/src/protoFleet/features/activity/components/ActivityTable.tsx b/client/src/protoFleet/features/activity/components/ActivityTable.tsx index ea612f3142..a5902946fa 100644 --- a/client/src/protoFleet/features/activity/components/ActivityTable.tsx +++ b/client/src/protoFleet/features/activity/components/ActivityTable.tsx @@ -67,7 +67,10 @@ const ActivityTable = ({ activities, noDataElement }: ActivityTableProps) => { }} >
-
+ {/* mt-1 optically centers the 16px glyph on the first + 24px text line; items-center would mis-center it + against wrapped multi-line descriptions. */} +
{isFailed ? Failed : null}
diff --git a/client/src/protoFleet/features/activity/utils/activityIcons.tsx b/client/src/protoFleet/features/activity/utils/activityIcons.tsx index 4d8b4f7180..bb07a39fda 100644 --- a/client/src/protoFleet/features/activity/utils/activityIcons.tsx +++ b/client/src/protoFleet/features/activity/utils/activityIcons.tsx @@ -61,6 +61,10 @@ const iconMap: Record ReactNode> = { create_pool: MiningPools, update_pool: MiningPools, delete_pool: Trash, + + "note.created": Edit, + "note.updated": Edit, + "note.deleted": Trash, }; export function getActivityIcon(eventType: string): (props: IconProps) => ReactNode { diff --git a/client/src/protoFleet/features/notes/components/NoteCard.tsx b/client/src/protoFleet/features/notes/components/NoteCard.tsx new file mode 100644 index 0000000000..89a7bf7e65 --- /dev/null +++ b/client/src/protoFleet/features/notes/components/NoteCard.tsx @@ -0,0 +1,199 @@ +import { memo, type ReactElement, useState } from "react"; + +import { type Note } from "@/protoFleet/api/generated/notes/v1/notes_pb"; +import { MAX_NOTE_CONTENT_LENGTH, useNotes } from "@/protoFleet/api/notes"; +import { + authorAvatarClass, + authorInitial, + noteFullTimestamp, + noteTimeLabel, +} from "@/protoFleet/features/notes/noteFormat"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Dialog from "@/shared/components/Dialog"; +import Textarea from "@/shared/components/Textarea"; + +interface NoteCardProps { + note: Note; + // Own-note affordances key off the username match for display; the + // server enforces real authorship from the session either way. + isOwn: boolean; + canModerate: boolean; + onChanged: () => void; +} + +// updated_at > created_at is the wire-level "edited" signal: content +// edits are the only mutation a live note receives. +const isEdited = (note: Note): boolean => { + const created = note.createdAt; + const updated = note.updatedAt; + if (!created || !updated) return false; + if (updated.seconds !== created.seconds) return updated.seconds > created.seconds; + return updated.nanos > created.nanos; +}; + +const NoteCard = ({ note, isOwn, canModerate, onChanged }: NoteCardProps): ReactElement => { + const { updateNote, deleteNote } = useNotes(); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(note.content); + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const trimmedDraft = draft.trim(); + const canSave = !isPending && trimmedDraft !== "" && trimmedDraft.length <= MAX_NOTE_CONTENT_LENGTH; + + const startEditing = () => { + setDraft(note.content); + setError(null); + setIsEditing(true); + }; + + const saveEdit = () => { + if (!canSave) return; + setIsPending(true); + void updateNote({ + id: note.id, + content: trimmedDraft, + onSuccess: () => { + setIsEditing(false); + onChanged(); + }, + onError: (message) => setError(message), + onFinally: () => setIsPending(false), + }); + }; + + const confirmDelete = () => { + setIsPending(true); + void deleteNote({ + id: note.id, + onSuccess: () => { + setIsConfirmingDelete(false); + onChanged(); + }, + onError: (message) => { + setIsConfirmingDelete(false); + setError(message); + }, + onFinally: () => setIsPending(false), + }); + }; + + return ( +
+ + +
+ {/* Hierarchy: content is the largest text in the card; the + author rides on weight (the avatar already carries identity); + the timestamp is the quietest element — small, mono, + right-justified. */} +
+ + {note.authorUsername} + {isOwn ? (you) : null} + + + {noteTimeLabel(note)} + {isEdited(note) ? (edited) : null} + +
+ + {isEditing ? ( +
+