Skip to content

Commit

Permalink
Add "Group type" field to group creation form
Browse files Browse the repository at this point in the history
This allows setting the group type to "private", "restricted" or "open" when
creating or editing a group.

See #8898.
  • Loading branch information
robertknight committed Oct 3, 2024
1 parent 9d70221 commit 5813192
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 28 deletions.
74 changes: 65 additions & 9 deletions h/static/scripts/group-forms/components/CreateEditGroupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import {
Button,
CancelIcon,
Input,
RadioGroup,
Textarea,
useWarnOnPageUnload,
} from '@hypothesis/frontend-shared';
import { readConfig } from '../config';
import { callAPI, CreateUpdateGroupAPIResponse } from '../utils/api';
import { callAPI } from '../utils/api';
import type {
CreateUpdateGroupAPIRequest,
CreateUpdateGroupAPIResponse,
GroupType,
} from '../utils/api';
import { setLocation } from '../utils/set-location';
import SaveStateIcon from './SaveStateIcon';

Expand Down Expand Up @@ -41,16 +47,18 @@ function CharacterCounter({
}

function Label({
id,
htmlFor,
text,
required,
}: {
htmlFor: string;
id?: string;
htmlFor?: string;
text: string;
required?: boolean;
}) {
return (
<label htmlFor={htmlFor}>
<label className="font-bold" id={id} htmlFor={htmlFor}>
{text}
{required && <Star />}
</label>
Expand Down Expand Up @@ -131,6 +139,10 @@ export default function CreateEditGroupForm() {

const [name, setName] = useState(group?.name ?? '');
const [description, setDescription] = useState(group?.description ?? '');
const [groupType, setGroupType] = useState<GroupType>(
group?.type ?? 'private',
);

const [errorMessage, setErrorMessage] = useState('');
const [saveState, setSaveState] = useState<
'unmodified' | 'unsaved' | 'saving' | 'saved'
Expand Down Expand Up @@ -170,13 +182,16 @@ export default function CreateEditGroupForm() {
setSaveState('saving');

try {
const body: CreateUpdateGroupAPIRequest = {
name,
description,
type: groupType,
};

response = (await callAPI(config.api.createGroup.url, {
method: config.api.createGroup.method,
headers: config.api.createGroup.headers,
json: {
name,
description,
},
json: body,
})) as CreateUpdateGroupAPIResponse;
} catch (apiError) {
setErrorMessage(apiError.message);
Expand All @@ -195,10 +210,17 @@ export default function CreateEditGroupForm() {
let response: CreateUpdateGroupAPIResponse;

try {
const body: CreateUpdateGroupAPIRequest = {
id: group!.pubid,
name,
description,
type: groupType,
};

response = (await callAPI(config.api.updateGroup!.url, {
method: config.api.updateGroup!.method,
headers: config.api.updateGroup!.headers,
json: { id: group!.pubid, name, description },
json: body,
})) as CreateUpdateGroupAPIResponse;

// Mark form as saved, unless user edited it while saving.
Expand All @@ -225,6 +247,8 @@ export default function CreateEditGroupForm() {
heading = 'Create a new private group';
}

const groupTypeLabel = useId();

return (
<div className="text-grey-6 text-sm/relaxed">
<h1 className="mt-14 mb-8 text-grey-7 text-xl/none" data-testid="header">
Expand Down Expand Up @@ -253,7 +277,39 @@ export default function CreateEditGroupForm() {
classes="h-24"
/>

<div className="flex items-center gap-x-4">
{config.features.group_type && (
<>
<Label id={groupTypeLabel} text="Group type" />
<RadioGroup<GroupType>
aria-labelledby={groupTypeLabel}
data-testid="group-type"
direction="vertical"
selected={groupType}
onChange={setGroupType}
>
<RadioGroup.Radio
value="private"
subtitle="Only members can create and read annotations."
>
Private
</RadioGroup.Radio>
<RadioGroup.Radio
value="restricted"
subtitle="Only members can create annotations, anyone can read them."
>
Restricted
</RadioGroup.Radio>
<RadioGroup.Radio
value="open"
subtitle="Anyone can create and read annotations."
>
Open
</RadioGroup.Radio>
</RadioGroup>
</>
)}

<div className="flex items-center gap-x-4 mt-2">
<div data-testid="error-container" role="alert">
{errorMessage && (
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { delay, waitForElement } from '@hypothesis/frontend-testing';

import {
Expand Down Expand Up @@ -29,7 +30,7 @@ describe('CreateEditGroupForm', () => {
group: null,
},
features: {
group_type: false,
group_type: true,
},
};

Expand Down Expand Up @@ -67,6 +68,18 @@ describe('CreateEditGroupForm', () => {
return Boolean(component.prop('error'));
};

const getSelectedGroupType = wrapper => {
return wrapper.find('[data-testid="group-type"]').prop('selected');
};

const setSelectedGroupType = (wrapper, newType) => {
const radioGroup = wrapper.find('[data-testid="group-type"]');
act(() => {
radioGroup.prop('onChange')(newType);
});
wrapper.update();
};

const getElements = wrapper => {
return {
header: {
Expand Down Expand Up @@ -215,6 +228,11 @@ describe('CreateEditGroupForm', () => {
assert.equal(submitButtonEl.text(), 'Create group');
assert.isFalse(wrapper.exists('[data-testid="back-link"]'));
assert.isFalse(wrapper.exists('[data-testid="error-message"]'));

if (groupTypeFlag) {
assert.equal(getSelectedGroupType(wrapper), 'private');
}

await assertInLoadingState(wrapper, false);
assert.isFalse(savedConfirmationShowing(wrapper));
});
Expand Down Expand Up @@ -252,32 +270,49 @@ describe('CreateEditGroupForm', () => {
assert.isFalse(savedConfirmationShowing(wrapper));
});

it('creates the group and redirects the browser', async () => {
const { wrapper, elements } = createWrapper();
const nameEl = elements.name.fieldEl;
const descriptionEl = elements.description.fieldEl;
const groupURL = 'https://example.com/group/foo';
fakeCallAPI.resolves({ links: { html: groupURL } });
[
{
name: 'Test group name',
description: 'Test description',
type: 'private',
},
{
name: 'Test group name',
description: 'Test description',
type: 'restricted',
},
{
name: 'Test group name',
description: 'Test description',
type: 'open',
},
].forEach(({ name, description, type }) => {
it('creates the group and redirects the browser', async () => {
const { wrapper, elements } = createWrapper();
const nameEl = elements.name.fieldEl;
const descriptionEl = elements.description.fieldEl;
const groupURL = 'https://example.com/group/foo';
fakeCallAPI.resolves({ links: { html: groupURL } });

const name = 'Test Group Name';
const description = 'Test description';
nameEl.getDOMNode().value = name;
nameEl.simulate('input');
descriptionEl.getDOMNode().value = description;
descriptionEl.simulate('input');
await wrapper.find('form[data-testid="form"]').simulate('submit');
nameEl.getDOMNode().value = name;
nameEl.simulate('input');
descriptionEl.getDOMNode().value = description;
descriptionEl.simulate('input');
setSelectedGroupType(wrapper, type);

assert.isTrue(
fakeCallAPI.calledOnceWithExactly(config.api.createGroup.url, {
await wrapper.find('form[data-testid="form"]').simulate('submit');

assert.calledOnceWithExactly(fakeCallAPI, config.api.createGroup.url, {
method: config.api.createGroup.method,
headers: config.api.createGroup.headers,
json: {
name,
description,
type,
},
}),
);
assert.isTrue(fakeSetLocation.calledOnceWithExactly(groupURL));
});
assert.calledOnceWithExactly(fakeSetLocation, groupURL);
});
});

it('shows an error message if callAPI() throws an error', async () => {
Expand All @@ -304,6 +339,9 @@ describe('CreateEditGroupForm', () => {
name: 'Test Name',
description: 'Test group description',
link: 'https://example.com/groups/testid',

// Set this to a non-default value.
type: 'open',
};
config.api.updateGroup = {
method: 'PATCH',
Expand All @@ -324,6 +362,7 @@ describe('CreateEditGroupForm', () => {
descriptionEl.getDOMNode().value,
config.context.group.description,
);
assert.equal(getSelectedGroupType(wrapper), config.context.group.type);
assert.equal(submitButtonEl.text(), 'Save changes');
assert.isTrue(wrapper.exists('[data-testid="back-link"]'));
assert.isFalse(wrapper.exists('[data-testid="error-message"]'));
Expand Down Expand Up @@ -364,10 +403,14 @@ describe('CreateEditGroupForm', () => {

const name = 'Edited Group Name';
const description = 'Edited group description';
const newGroupType = 'restricted';

nameEl.getDOMNode().value = name;
nameEl.simulate('input');
descriptionEl.getDOMNode().value = description;
descriptionEl.simulate('input');
wrapper.find(`[data-value="${newGroupType}"]`).simulate('click');

await wrapper.find('form[data-testid="form"]').simulate('submit');

assert.isTrue(
Expand All @@ -378,6 +421,7 @@ describe('CreateEditGroupForm', () => {
id: config.context.group.pubid,
name,
description,
type: newGroupType,
},
}),
);
Expand Down
3 changes: 3 additions & 0 deletions h/static/scripts/group-forms/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { GroupType } from './utils/api';

export type APIConfig = {
method: string;
url: string;
Expand All @@ -17,6 +19,7 @@ export type ConfigObject = {
name: string;
description: string;
link: string;
type: GroupType;
} | null;
};
features: {
Expand Down
17 changes: 17 additions & 0 deletions h/static/scripts/group-forms/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
/**
* Values for `type` field when creating or updating groups.
*/
export type GroupType = 'private' | 'restricted' | 'open';

/**
* Request to create or update a group.
*
* See https://h.readthedocs.io/en/latest/api-reference/v2/#tag/groups/paths/~1groups/post
*/
export type CreateUpdateGroupAPIRequest = {
id?: string;
name: string;
description?: string;
type?: GroupType;
};

/**
* A successful response from either h's create-new-group API or its update-group API:
*
Expand Down

0 comments on commit 5813192

Please sign in to comment.