diff --git a/src/App.test.tsx b/src/App.test.tsx index 9a6aa29..b245a59 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -2,12 +2,12 @@ import "@testing-library/jest-dom"; import { act, render, screen } from "@testing-library/react"; import App from "./App"; import { - makeOpfsAdapterSingleton, + makeOpfsFileAdapterSingleton, isPersistenceSupported, } from "./services/origin-private-file-system"; jest.mock("./services/origin-private-file-system.ts", () => ({ - makeOpfsAdapterSingleton: jest + makeOpfsFileAdapterSingleton: jest .fn() .mockReturnValue( jest.fn().mockResolvedValue({ retrieve: jest.fn(), persist: jest.fn() }) @@ -19,8 +19,8 @@ describe("App", () => { beforeEach(() => { jest.useFakeTimers(); - (makeOpfsAdapterSingleton as jest.Mock).mockClear(); - (makeOpfsAdapterSingleton as jest.Mock).mockReturnValue(async () => ({ + (makeOpfsFileAdapterSingleton as jest.Mock).mockClear(); + (makeOpfsFileAdapterSingleton as jest.Mock).mockReturnValue(async () => ({ retrieve: jest .fn() .mockResolvedValue("d48c2b8f-e557-4503-8fac-4561bba582ac"), @@ -39,7 +39,7 @@ describe("App", () => { await act(() => jest.runAllTimers()); - (makeOpfsAdapterSingleton as jest.Mock).mockResolvedValue({ + (makeOpfsFileAdapterSingleton as jest.Mock).mockResolvedValue({ retrieve: jest .fn() .mockResolvedValue("01fb9ee8-926c-483e-9eb5-f020762d4b00"), diff --git a/src/App.tsx b/src/App.tsx index 43fa84e..a3ae71b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import "./App.css"; import { useEffect } from "react"; import useClientSession from "./hooks/useClientSession"; +import { ProjectsManagementPage } from "./features/projects-management/ProjectsManagementPage"; export default function App() { const { isActive, doActiveThisSession, isOfflineModeSupported } = @@ -37,6 +38,7 @@ export default function App() {

Postmaiden

Snake? Do you think love can bloom even on the battlefield? +
); } diff --git a/src/entities/management.ts b/src/entities/management.ts index 8e26a7b..cf5fbc3 100644 --- a/src/entities/management.ts +++ b/src/entities/management.ts @@ -2,7 +2,7 @@ export interface Project { uuid: string; name: string; sections: ProjectSection[]; - requests: RequestEntry[]; + requests: ProjectRequest[]; } export interface ProjectSection { @@ -10,15 +10,15 @@ export interface ProjectSection { name: string; } -export interface RequestEntry { +export interface ProjectRequest { uuid: string; url: string; method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - headers: HeaderEntry[]; + headers: ProjectRequestHeader[]; body: string; } -export interface HeaderEntry { +export interface ProjectRequestHeader { key: string; value: string; isEnabled: boolean; diff --git a/src/features/projects-management/ProjectsManagementContext.ts b/src/features/projects-management/ProjectsManagementContext.ts new file mode 100644 index 0000000..d742482 --- /dev/null +++ b/src/features/projects-management/ProjectsManagementContext.ts @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; +import { ProjectListingItem } from "./projects-management-entities"; + +export interface ProjectsManagementContextValue { + items: ProjectListingItem[]; + create: (name: string) => Promise; + isLoading: boolean; + isError: Error | null; +} + +export const ProjectsManagementContext = + createContext(null); + +export function useProjectsManagement(): ProjectsManagementContextValue { + const contextValue = useContext(ProjectsManagementContext); + if (contextValue === null) { + throw new Error("Hook used outside its context."); + } + + return contextValue; +} diff --git a/src/features/projects-management/ProjectsManagementContextProvider.tsx b/src/features/projects-management/ProjectsManagementContextProvider.tsx new file mode 100644 index 0000000..efb593c --- /dev/null +++ b/src/features/projects-management/ProjectsManagementContextProvider.tsx @@ -0,0 +1,50 @@ +import { ReactNode, useCallback, useEffect, useState } from "react"; +import { + persistNewProjectListingItem, + retrieveProjectsListing, +} from "./opfs-projects-listing-service"; +import { ProjectListingItem } from "./projects-management-entities"; +import { ProjectsManagementContext } from "./ProjectsManagementContext"; + +export function ProjectsManagementContextProvider({ + children, +}: { + children: ReactNode; +}) { + const [items, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setError] = useState(null); + + const pullProjectsListing = useCallback(async () => { + setError(null); + setIsLoading(true); + retrieveProjectsListing() + .then((projects) => setProjects(projects.items)) + .catch((error) => setError(error)) + .finally(() => setIsLoading(false)); + }, []); + + useEffect(() => { + pullProjectsListing(); + }, [pullProjectsListing]); + + const create = useCallback( + async (name: string) => { + setError(null); + setIsLoading(true); + persistNewProjectListingItem(name) + .catch((error) => setError(error)) + .finally(() => setIsLoading(false)) + .then(() => pullProjectsListing()); + }, + [pullProjectsListing] + ); + + return ( + + {children} + + ); +} diff --git a/src/features/projects-management/ProjectsManagementPage.tsx b/src/features/projects-management/ProjectsManagementPage.tsx new file mode 100644 index 0000000..e449462 --- /dev/null +++ b/src/features/projects-management/ProjectsManagementPage.tsx @@ -0,0 +1,65 @@ +import { FormEvent, useCallback, useState } from "react"; +import { useProjectsManagement } from "./ProjectsManagementContext"; +import { ProjectsManagementContextProvider } from "./ProjectsManagementContextProvider"; + +export function ProjectsManagementPage() { + return ( + +
+ + +
+
+ ); +} + +function ProjectsList() { + const { items: projects, isLoading, isError } = useProjectsManagement(); + + if (isLoading) { + return
Loading...
; + } + + if (isError) { + return
Error: {isError.message}
; + } + + return ( +
    + {projects.map((project) => ( +
  • {project.name}
  • + ))} +
+ ); +} + +function ProjectForm() { + const { create } = useProjectsManagement(); + + const [name, setName] = useState(""); + + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + create(name); + }, + [create, name] + ); + + return ( +
+ + setName(event.target.value)} + autoComplete="off" + maxLength={50} + spellCheck={false} + required + /> + +
+ ); +} diff --git a/src/features/projects-management/opfs-projects-listing-service.ts b/src/features/projects-management/opfs-projects-listing-service.ts new file mode 100644 index 0000000..37dbf6a --- /dev/null +++ b/src/features/projects-management/opfs-projects-listing-service.ts @@ -0,0 +1,62 @@ +import { v4 as uuidv4 } from "uuid"; +import { Project } from "../../entities/management"; +import { + makeOpfsFileAdapter, + makeOpfsMainDirAdapter, +} from "../../services/origin-private-file-system"; +import { + ProjectListing, + ProjectListingItem, +} from "./projects-management-entities"; + +export async function retrieveProjectsListing(): Promise { + const opfsDir = await makeOpfsMainDirAdapter({ + subdir: PROJECTS_OPFS_SUBDIRECTORY, + }); + const filenames = await opfsDir.retrieveFilenames(); + return { + items: filenames.map((filename) => { + const rFilename = + /(?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})_(?[A-Za-z ]+)\.json/; + const match = filename.match(rFilename); + if (!match || !match.groups) { + throw new Error( + `Invalid project filename (corrupted data?): ${filename}` + ); + } + + return { + uuid: match.groups.uuid, + name: match.groups.name, + }; + }), + }; +} + +export async function persistNewProjectListingItem( + name: string +): Promise { + if (!name) { + throw new Error("Project name cannot be empty."); + } + + const item: ProjectListingItem = { + uuid: uuidv4(), + name: name.trim().replace(".json", ""), + }; + + const file = await makeOpfsFileAdapter({ + filename: `${item.uuid}_${item.name}.json`, + subdir: PROJECTS_OPFS_SUBDIRECTORY, + }); + await file.persist({ + uuid: item.uuid, + name: item.name, + sections: [], + requests: [], + }); + + return item; +} + +export const PROJECTS_OPFS_SUBDIRECTORY = "projects"; diff --git a/src/features/projects-management/projects-management-entities.ts b/src/features/projects-management/projects-management-entities.ts new file mode 100644 index 0000000..80b3557 --- /dev/null +++ b/src/features/projects-management/projects-management-entities.ts @@ -0,0 +1,8 @@ +export type ProjectListing = { + items: ProjectListingItem[]; +}; + +export interface ProjectListingItem { + uuid: string; + name: string; +} diff --git a/src/services/opfs-client-session.ts b/src/services/opfs-client-session.ts index 1c2be9c..afcf946 100644 --- a/src/services/opfs-client-session.ts +++ b/src/services/opfs-client-session.ts @@ -1,7 +1,9 @@ import { validate as validateUuid } from "uuid"; -import { makeOpfsAdapterSingleton } from "./origin-private-file-system"; +import { makeOpfsFileAdapterSingleton } from "./origin-private-file-system"; -const getOpfsAdapter = makeOpfsAdapterSingleton("client-session.txt"); +const getOpfsAdapter = makeOpfsFileAdapterSingleton({ + filename: "client-session.txt", +}); export async function retrieveClientSessionUuid(): Promise { const opfs = await getOpfsAdapter(); diff --git a/src/services/origin-private-file-system.test.ts b/src/services/origin-private-file-system.test.ts index 8c74635..2eaf971 100644 --- a/src/services/origin-private-file-system.test.ts +++ b/src/services/origin-private-file-system.test.ts @@ -1,6 +1,6 @@ import { isPersistenceSupported, - makeOpfsAdapterSingleton, + makeOpfsFileAdapterSingleton, } from "./origin-private-file-system"; describe("Origin private file system (OPFS) adapter", () => { @@ -8,17 +8,19 @@ describe("Origin private file system (OPFS) adapter", () => { Object.defineProperty(global.navigator, "storage", { value: { getDirectory: jest.fn().mockResolvedValue({ - getFileHandle: jest.fn().mockResolvedValue({ - getFile: jest.fn().mockResolvedValue({ - text: jest - .fn() - .mockResolvedValue( - '{ "taste": "pepsicola", "surprise": false }' - ), - }), - createWritable: jest.fn().mockResolvedValue({ - write: jest.fn(), - close: jest.fn(), + getDirectoryHandle: jest.fn().mockResolvedValue({ + getFileHandle: jest.fn().mockResolvedValue({ + getFile: jest.fn().mockResolvedValue({ + text: jest + .fn() + .mockResolvedValue( + '{ "taste": "pepsicola", "surprise": false }' + ), + }), + createWritable: jest.fn().mockResolvedValue({ + write: jest.fn(), + close: jest.fn(), + }), }), }), }), @@ -28,23 +30,23 @@ describe("Origin private file system (OPFS) adapter", () => { }); it("Can generate specific singletons (for more pragmatic usage)", async () => { - const getOpfsAdapter = makeOpfsAdapterSingleton( - "something-in-the-way.txt" - ); + const getOpfsAdapter = makeOpfsFileAdapterSingleton({ + filename: "something-in-the-way.txt", + }); const opfs1 = await getOpfsAdapter(); const opfs2 = await getOpfsAdapter(); expect(opfs1).toBe(opfs2); }); it("Necessarily means that two different specific singletons are indeed different references", async () => { - const getOpfsAdapter1 = makeOpfsAdapterSingleton( - "the-man-who-sold-the-world.txt" - ); + const getOpfsAdapter1 = makeOpfsFileAdapterSingleton({ + filename: "the-man-who-sold-the-world.txt", + }); const opfs1 = await getOpfsAdapter1(); - const getOpfsAdapter2 = makeOpfsAdapterSingleton( - "i-never-lost-control.txt" - ); + const getOpfsAdapter2 = makeOpfsFileAdapterSingleton({ + filename: "i-never-lost-control.txt", + }); const opfs2 = await getOpfsAdapter2(); expect(opfs1).not.toBe(opfs2); @@ -62,16 +64,18 @@ describe("Origin private file system (OPFS) adapter", () => { Object.defineProperty(global.navigator, "storage", { value: { getDirectory: jest.fn().mockResolvedValue({ - getFileHandle: getFileHandleMock, + getDirectoryHandle: jest.fn().mockResolvedValue({ + getFileHandle: getFileHandleMock, + }), }), }, writable: true, }); - const getOpfsAdapter = makeOpfsAdapterSingleton<{ + const getOpfsAdapter = makeOpfsFileAdapterSingleton<{ taste: string; surprise: boolean; - }>("lana.json"); + }>({ filename: "lana.json" }); const opfs = await getOpfsAdapter(); const retrieved = await opfs.retrieve(); expect(retrieved).toEqual({ taste: "pepsicola", surprise: false }); @@ -85,9 +89,11 @@ describe("Origin private file system (OPFS) adapter", () => { Object.defineProperty(global.navigator, "storage", { value: { getDirectory: jest.fn().mockResolvedValue({ - getFileHandle: jest.fn().mockResolvedValue({ - getFile: jest.fn().mockResolvedValue({ - text: jest.fn().mockResolvedValue(""), + getDirectoryHandle: jest.fn().mockResolvedValue({ + getFileHandle: jest.fn().mockResolvedValue({ + getFile: jest.fn().mockResolvedValue({ + text: jest.fn().mockResolvedValue(""), + }), }), }), }), @@ -95,10 +101,10 @@ describe("Origin private file system (OPFS) adapter", () => { writable: true, }); - const getOpfsAdapter = makeOpfsAdapterSingleton<{ + const getOpfsAdapter = makeOpfsFileAdapterSingleton<{ taste: string; surprise: boolean; - }>("lana_banana.json"); + }>({ filename: "lana_banana.json" }); const opfs = await getOpfsAdapter(); const retrieved = await opfs.retrieve(); expect(retrieved).toEqual(null); @@ -117,16 +123,18 @@ describe("Origin private file system (OPFS) adapter", () => { Object.defineProperty(global.navigator, "storage", { value: { getDirectory: jest.fn().mockResolvedValue({ - getFileHandle: getFileHandleMock, + getDirectoryHandle: jest.fn().mockResolvedValue({ + getFileHandle: getFileHandleMock, + }), }), }, writable: true, }); - const getOpfsAdapter = makeOpfsAdapterSingleton<{ + const getOpfsAdapter = makeOpfsFileAdapterSingleton<{ handsOn: string; nameOn: string; - }>("delrey.json"); + }>({ filename: "delrey.json" }); const opfs = await getOpfsAdapter(); await opfs.persist({ handsOn: "hips", nameOn: "lips" }); @@ -152,16 +160,18 @@ describe("Origin private file system (OPFS) adapter", () => { Object.defineProperty(global.navigator, "storage", { value: { getDirectory: jest.fn().mockResolvedValue({ - getFileHandle: getFileHandleMock, + getDirectoryHandle: jest.fn().mockResolvedValue({ + getFileHandle: getFileHandleMock, + }), }), }, writable: true, }); - const getOpfsAdapter = makeOpfsAdapterSingleton<{ + const getOpfsAdapter = makeOpfsFileAdapterSingleton<{ handsOn: string; nameOn: string; - }>("delrey.json"); + }>({ filename: "delrey.json" }); const opfs = await getOpfsAdapter(); await expect(() => diff --git a/src/services/origin-private-file-system.ts b/src/services/origin-private-file-system.ts index 272f71e..b29975b 100644 --- a/src/services/origin-private-file-system.ts +++ b/src/services/origin-private-file-system.ts @@ -1,27 +1,46 @@ -export interface OriginPrivateFileSystemAdapter { +export interface OriginPrivateFileSystemFileAdapterOptions { + filename: string; + subdir?: string; +} + +export interface OriginPrivateFileSystemFileAdapter { retrieve: () => Promise; persist: (data: T) => Promise; } -export function makeOpfsAdapterSingleton( - filename: string -): () => Promise> { - let opfsAdapter: OriginPrivateFileSystemAdapter; +export function makeOpfsFileAdapterSingleton( + options: OriginPrivateFileSystemFileAdapterOptions +): () => Promise> { + let opfsAdapter: OriginPrivateFileSystemFileAdapter; return async () => { if (!opfsAdapter) { - opfsAdapter = await makeOpfsAdapter(filename); + opfsAdapter = await makeOpfsFileAdapter(options); } return opfsAdapter; }; } -export async function makeOpfsAdapter( - filename: string -): Promise> { +export async function makeOpfsFileAdapter({ + filename, + subdir, +}: OriginPrivateFileSystemFileAdapterOptions): Promise< + OriginPrivateFileSystemFileAdapter +> { + if (subdir && (subdir.includes("/") || subdir.includes("_"))) { + throw new Error('Subdirectory cannot contain "/" or "_".'); + } + const opfsRoot = await navigator.storage.getDirectory(); - const fileHandle = await opfsRoot.getFileHandle(filename, { + + const dirPath = subdir + ? MAIN_OPFS_DIRECTORY + "__" + subdir + : MAIN_OPFS_DIRECTORY; + const directoryHandle = await opfsRoot.getDirectoryHandle(dirPath, { + create: true, + }); + const fileHandle = await directoryHandle.getFileHandle(filename, { create: true, }); @@ -46,8 +65,52 @@ export async function makeOpfsAdapter( }; } +export interface OriginPrivateFileSystemDirAdapter { + retrieveFilenames: () => Promise; +} + +export interface OriginPrivateFileSystemDirAdapterOptions { + subdir?: string; +} + +export async function makeOpfsMainDirAdapter({ + subdir, +}: OriginPrivateFileSystemDirAdapterOptions): Promise { + if (subdir && (subdir.includes("/") || subdir.includes("_"))) { + throw new Error('Subdirectory cannot contain "/" or "_".'); + } + + const opfsRoot = await navigator.storage.getDirectory(); + + const dirPath = subdir + ? MAIN_OPFS_DIRECTORY + "__" + subdir + : MAIN_OPFS_DIRECTORY; + const directoryHandle = await opfsRoot.getDirectoryHandle(dirPath, { + create: true, + }); + + const retrieveFilenames = async () => { + // Typing workaround for: https://github.com/microsoft/TypeScript/issues/56360 + const dirEntriesIterator = ( + directoryHandle as unknown as { + entries: () => AsyncIterableIterator<[string, FileSystemFileHandle]>; + } + ).entries(); + + const filenames: string[] = []; + for await (const [filename] of dirEntriesIterator) filenames.push(filename); + return filenames; + }; + + return { + retrieveFilenames, + }; +} + export function isPersistenceSupported(): boolean { return ( typeof window.FileSystemFileHandle?.prototype?.createWritable === "function" ); } + +export const MAIN_OPFS_DIRECTORY = "postmaiden.com";