Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
cancelRename,
closeDeck,
configureDecksBackend,
configureDecksManagement,
DECKS_MANAGEMENT_REFUSAL,
renameDeck,
getDecksState,
openDeck,
Expand Down Expand Up @@ -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);
});
});
65 changes: 35 additions & 30 deletions src/plugin/DeckList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
KebabHorizontalIcon,
PencilIcon,
PlusIcon,
ProjectIcon,
ProjectRoadmapIcon,
TrashIcon,
} from '@primer/octicons-react';
import { deckId, type DeckEntry } from '../registry/catalog';
Expand All @@ -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 => (
<ActionMenu>
<ActionMenu.Anchor>
<IconButton
icon={KebabHorizontalIcon}
aria-label={`Actions for ${title}`}
size="small"
variant="invisible"
/>
</ActionMenu.Anchor>
<ActionMenu.Overlay width="small">
<ActionList>
<ActionList.Item onSelect={() => beginRename(id)}>
<ActionList.LeadingVisual>
<PencilIcon />
</ActionList.LeadingVisual>
Rename…
</ActionList.Item>
<ActionList.Item variant="danger" onSelect={() => beginDelete(id)}>
<ActionList.LeadingVisual>
<TrashIcon />
</ActionList.LeadingVisual>
Delete…
</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
);
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 (
<ActionMenu>
<ActionMenu.Anchor>
<IconButton
icon={KebabHorizontalIcon}
aria-label={`Actions for ${title}`}
size="small"
variant="invisible"
/>
</ActionMenu.Anchor>
<ActionMenu.Overlay width="small">
<ActionList>
<ActionList.Item disabled={!canManage} onSelect={() => beginRename(id)}>
<ActionList.LeadingVisual>
<PencilIcon />
</ActionList.LeadingVisual>
Rename…
</ActionList.Item>
<ActionList.Item variant="danger" disabled={!canManage} onSelect={() => beginDelete(id)}>
<ActionList.LeadingVisual>
<TrashIcon />
</ActionList.LeadingVisual>
Delete…
</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
);
};

/**
* One deck: a button that opens it, and the kebab beside it — siblings, so
Expand Down Expand Up @@ -121,7 +126,7 @@ const DeckRow = ({ entry, selected }: { entry: DeckEntry; selected: boolean }):
}}
>
<Box sx={{ display: 'flex', color: 'fg.muted', flex: '0 0 auto' }}>
<ProjectIcon />
<ProjectRoadmapIcon />
</Box>
<Box
sx={{
Expand Down
43 changes: 41 additions & 2 deletions src/plugin/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export type DecksState = {
backendUrl?: string;
/** The last thing the backend said that went wrong, for the list to show. */
error?: string;
/**
* Whether the reader may rename and delete decks. True unless the host
* says otherwise (`configureDecksManagement`): a host with accounts turns
* it off for whoever is not an administrator. It gates every way in — the
* row's menu, the palette, the keystroke, and the commands an agent calls.
*/
canManage: boolean;
/**
* Bumped whenever someone asks to *see* decks — a deck opened, the list
* shown — even when nothing else changed. A host with its own notion of
Expand All @@ -59,7 +66,12 @@ export type DecksState = {
revealed: number;
};

let state: DecksState = { slide: 1, creating: false, revealed: 0 };
const FIRST_FRAME: DecksState = { slide: 1, creating: false, revealed: 0, canManage: true };

let state: DecksState = FIRST_FRAME;

/** What a reader who may not manage decks is told, wherever they tried from. */
export const DECKS_MANAGEMENT_REFUSAL = 'Only an administrator can rename or delete a deck.';
const listeners = new Set<() => void>();

const set = (patch: Partial<DecksState>): void => {
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
Expand All @@ -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());
};

Expand Down Expand Up @@ -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<DeckWriteResult> => {
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.`);
Expand All @@ -384,6 +420,9 @@ export const renameDeck = async (id: string, input: RenameInput): Promise<DeckWr

/** Remove a deck from the catalog, and from the server when there is one. */
export const removeDeck = async (id: string): Promise<boolean> => {
if (!state.canManage) {
throw new Error(DECKS_MANAGEMENT_REFUSAL);
}
if (!deckById(id)) {
return false;
}
Expand Down
Loading