diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts index c08e19f..2d3bc22 100644 --- a/src/__tests__/store.test.ts +++ b/src/__tests__/store.test.ts @@ -14,6 +14,8 @@ import { cancelRename, closeDeck, configureDecksBackend, + configureDecksManagement, + DECKS_MANAGEMENT_REFUSAL, renameDeck, getDecksState, openDeck, @@ -206,3 +208,37 @@ describe('revealed', () => { expect(getDecksState().revealed).toBe(before + 3); // addDeck opened it, then openDeck }); }); + +describe('managing decks, when the host says the reader may not', () => { + it('refuses rename and delete by every way in, and says why', async () => { + await addDeck({ collection: 'talks', slug: 'kept', spec: spec('Kept') }); + const id = 'talks/kept'; + expect(getDecksState().canManage).toBe(true); + configureDecksManagement(false); + // The dialogs do not open… + beginRename(id); + beginDelete(id); + expect(getDecksState().renaming).toBeUndefined(); + expect(getDecksState().deleting).toBeUndefined(); + expect(getDecksState().error).toBe(DECKS_MANAGEMENT_REFUSAL); + // …and the writes an agent or a command calls are refused too. + await expect(removeDeck(id)).rejects.toThrow(DECKS_MANAGEMENT_REFUSAL); + await expect(renameDeck(id, { title: 'Other' })).rejects.toThrow(DECKS_MANAGEMENT_REFUSAL); + expect(deckById(id)).toBeDefined(); + // Allowed again, the same calls go through. + configureDecksManagement(true); + beginDelete(id); + expect(getDecksState().deleting).toBe(id); + expect(await removeDeck(id)).toBe(true); + }); + + it('closes a dialog already open when management is taken away', async () => { + await addDeck({ collection: 'talks', slug: 'open-dialog', spec: spec('Open dialog') }); + const id = 'talks/open-dialog'; + beginRename(id); + expect(getDecksState().renaming).toBe(id); + configureDecksManagement(false); + expect(getDecksState().renaming).toBeUndefined(); + configureDecksManagement(true); + }); +}); diff --git a/src/plugin/DeckList.tsx b/src/plugin/DeckList.tsx index 445d7ad..2f90a83 100644 --- a/src/plugin/DeckList.tsx +++ b/src/plugin/DeckList.tsx @@ -21,7 +21,7 @@ import { KebabHorizontalIcon, PencilIcon, PlusIcon, - ProjectIcon, + ProjectRoadmapIcon, TrashIcon, } from '@primer/octicons-react'; import { deckId, type DeckEntry } from '../registry/catalog'; @@ -45,34 +45,39 @@ const FAMILY_OF_ONE = ''; * trailing action is a sibling of the row's button inside the `li`, and it * anchors a controlled menu. */ -const RowActions = ({ id, title }: { id: string; title: string }): JSX.Element => ( - - - - - - - beginRename(id)}> - - - - Rename… - - beginDelete(id)}> - - - - Delete… - - - - -); +const RowActions = ({ id, title }: { id: string; title: string }): JSX.Element => { + // Shown to everybody, pressable by whoever may manage decks: a reader who + // may not sees what there would be to do, and that it is not theirs. + const { canManage } = useDecksState(); + return ( + + + + + + + beginRename(id)}> + + + + Rename… + + beginDelete(id)}> + + + + Delete… + + + + + ); +}; /** * One deck: a button that opens it, and the kebab beside it — siblings, so @@ -121,7 +126,7 @@ const DeckRow = ({ entry, selected }: { entry: DeckEntry; selected: boolean }): }} > - + void>(); const set = (patch: Partial): void => { @@ -103,6 +115,10 @@ export const beginNewDeck = (): void => set({ creating: true }); export const cancelNewDeck = (): void => set({ creating: false }); /** Open the rename dialog for a deck — the open one when none is named. */ export const beginRename = (id?: string): void => { + if (!state.canManage) { + set({ error: DECKS_MANAGEMENT_REFUSAL }); + return; + } const target = id ?? state.selected; if (target && deckById(target)) { set({ renaming: target }); @@ -111,6 +127,10 @@ export const beginRename = (id?: string): void => { export const cancelRename = (): void => set({ renaming: undefined }); /** Open the delete confirmation for a deck — the open one when none is named. */ export const beginDelete = (id?: string): void => { + if (!state.canManage) { + set({ error: DECKS_MANAGEMENT_REFUSAL }); + return; + } const target = id ?? state.selected; if (target && deckById(target)) { set({ deleting: target }); @@ -119,10 +139,23 @@ export const beginDelete = (id?: string): void => { export const cancelDelete = (): void => set({ deleting: undefined }); export const configureDecksBackend = (backendUrl: string | undefined): void => set({ backendUrl: backendUrl?.replace(/\/+$/, '') || undefined }); +/** + * Say whether the reader may rename and delete decks. A host calls it when + * it knows who is reading; a dialog already open for someone who may not is + * closed. + */ +export const configureDecksManagement = (allowed: boolean): void => { + if (state.canManage === allowed) { + return; + } + set( + allowed ? { canManage: true } : { canManage: false, renaming: undefined, deleting: undefined }, + ); +}; /** For tests: back to the first frame. */ export const resetDecksState = (): void => { - state = { slide: 1, creating: false, revealed: 0 }; + state = FIRST_FRAME; listeners.forEach((listener) => listener()); }; @@ -368,6 +401,9 @@ export type RenameInput = { * a backend that is down leaves a message in the list, as every write does. */ export const renameDeck = async (id: string, input: RenameInput): Promise => { + if (!state.canManage) { + throw new Error(DECKS_MANAGEMENT_REFUSAL); + } const current = deckById(id); if (!current) { throw new Error(`There is no deck ${id} to rename.`); @@ -384,6 +420,9 @@ export const renameDeck = async (id: string, input: RenameInput): Promise => { + if (!state.canManage) { + throw new Error(DECKS_MANAGEMENT_REFUSAL); + } if (!deckById(id)) { return false; }