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;
}