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 (
+
+ );
+}
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";