Skip to content

Commit

Permalink
Add edit/create role form and page (#1508)
Browse files Browse the repository at this point in the history
* Begin creating role form

* Remove common_roles_view (replaced by common_role)

* biome

* Remove duplicate getRole

* Load permissions and display in MultiSelect

* Actually fetch roles in role admin page

* Fix imports

* Update router structure for roles

* Add link to Create role page

* SchemaType shortcut type

* Describe null value of hierarchy level

* Send create/edit request to backend

* Add create page to router

* Add margin to button row

* Allow MultiSelect to take ref
  • Loading branch information
robines authored Oct 30, 2024
1 parent be0e9a2 commit 9ff6451
Show file tree
Hide file tree
Showing 20 changed files with 339 additions and 51 deletions.
2 changes: 2 additions & 0 deletions backend/root/utils/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,8 @@
samfundet__saksdokument_detail = 'samfundet:saksdokument-detail'
samfundet__profile_list = 'samfundet:profile-list'
samfundet__profile_detail = 'samfundet:profile-detail'
samfundet__permissions_list = 'samfundet:permissions-list'
samfundet__permissions_detail = 'samfundet:permissions-detail'
samfundet__menu_list = 'samfundet:menu-list'
samfundet__menu_detail = 'samfundet:menu-detail'
samfundet__menu_items_list = 'samfundet:menu_items-list'
Expand Down
21 changes: 11 additions & 10 deletions frontend/src/Components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Icon } from '@iconify/react';
import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '~/Components';
import { useDesktop } from '~/hooks';
Expand All @@ -24,14 +24,10 @@ type MultiSelectProps<T> = {
* `options`: All possible options that can be selected.
* `selected`: Selected values if state is managed outside this component.
*/
export function MultiSelect<T>({
optionsLabel,
selectedLabel,
className,
selected: initialValues = [],
options = [],
onChange,
}: MultiSelectProps<T>) {
function MultiSelectInner<T>(
{ optionsLabel, selectedLabel, className, selected: initialValues = [], options = [], onChange }: MultiSelectProps<T>,
ref: React.Ref<HTMLDivElement>,
) {
const { t } = useTranslation();
const isDesktop = useDesktop();

Expand Down Expand Up @@ -66,7 +62,7 @@ export function MultiSelect<T>({
}

return (
<div className={classNames(styles.container, className)}>
<div className={classNames(styles.container, className)} ref={ref}>
<Input
type="text"
onChange={(e) => setSearch(e.target.value)}
Expand Down Expand Up @@ -94,3 +90,8 @@ export function MultiSelect<T>({
</div>
);
}

export const MultiSelect = React.forwardRef(MultiSelectInner) as <T>(
props: MultiSelectProps<T> & { ref?: React.Ref<HTMLDivElement> },
) => ReturnType<typeof MultiSelectInner>;
(MultiSelect as React.ForwardRefExoticComponent<unknown>).displayName = 'MultiSelect';
2 changes: 1 addition & 1 deletion frontend/src/PagesAdmin/RoleAdminPage/RoleAdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function RoleAdminPage() {
const { t } = useTranslation();

return (
<AdminPageLayout title={t(KEY.common_roles_view)}>
<AdminPageLayout title={t(KEY.common_role)}>
<div />
</AdminPageLayout>
);
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/PagesAdmin/RoleFormAdminPage/RoleFormAdminPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next';
import { useRouteLoaderData } from 'react-router-dom';
import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout';
import { RoleForm } from '~/PagesAdmin/RoleFormAdminPage/components';
import { KEY } from '~/i18n/constants';
import type { RoleLoader } from '~/router/loaders';
import { lowerCapitalize } from '~/utils';

export function RoleFormAdminPage() {
const { t } = useTranslation();
const data = useRouteLoaderData('role') as RoleLoader | undefined;

const title = lowerCapitalize(`${t(data?.role ? KEY.common_edit : KEY.common_create)} ${t(KEY.common_role)}`);
return (
<AdminPageLayout title={title}>
<RoleForm role={data?.role} />
</AdminPageLayout>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.action_row {
display: flex;
justify-content: flex-end;
margin: 1rem 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { z } from 'zod';
import {
Alert,
Button,
Dropdown,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '~/Components';
import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { MultiSelect } from '~/Components/MultiSelect';
import { createRole, editRole, getPermissions } from '~/api';
import type { RoleDto } from '~/dto';
import { KEY } from '~/i18n/constants';
import { ROLE_CONTENT_TYPE, ROLE_NAME } from '~/schema/role';
import styles from './RoleForm.module.scss';

const schema = z.object({
name: ROLE_NAME,
permissions: z.array(z.number()),
content_type: ROLE_CONTENT_TYPE.nullish(),
});

type SchemaType = z.infer<typeof schema>;

type ContentTypeSchemaType = z.infer<typeof ROLE_CONTENT_TYPE>;

type Props = {
role?: RoleDto;
};

export function RoleForm({ role }: Props) {
const { t } = useTranslation();

const {
data: allPermissions,
isLoading,
isError,
} = useQuery({
queryKey: ['permissions'],
queryFn: getPermissions,
});

const edit = useMutation({
mutationFn: editRole,
onSuccess: () => {
toast.success(t(KEY.common_save_successful));
},
});

const create = useMutation({
mutationFn: createRole,
onSuccess: () => {
toast.success(t(KEY.common_creation_successful));
},
});

const isPending = edit.isPending || create.isPending;

const permissionOptions = useMemo<DropdownOption<number>[]>(() => {
if (!allPermissions) {
return [];
}
return allPermissions.map((p) => ({
value: p.id,
label: p.name,
}));
}, [allPermissions]);

const selectedPermissions = useMemo<DropdownOption<number>[]>(() => {
if (!allPermissions || !role) {
return [];
}
const permissions = allPermissions.filter((p) => role.permissions.includes(p.id));
return permissions.map((p) => ({
value: p.id,
label: p.name,
}));
}, [role, allPermissions]);

const form = useForm<SchemaType>({
resolver: zodResolver(schema),
defaultValues: {
name: role?.name ?? '',
permissions: role?.permissions ?? [],
content_type: (role?.content_type ?? '') as ContentTypeSchemaType,
},
});

function onSubmit(values: SchemaType) {
console.log(values);
if (role) {
edit.mutate({ id: role.id, ...values });
} else {
create.mutate(values);
}
}

const contentTypeLabels: Record<ContentTypeSchemaType, string> = {
'': t(KEY.common_any),
organization: t(KEY.recruitment_organization),
gang: t(KEY.common_gang),
section: t(KEY.common_section),
};

const contentTypeOptions: DropdownOption<ContentTypeSchemaType>[] = ROLE_CONTENT_TYPE.options.map((ct) => ({
value: ct,
label: contentTypeLabels[ct],
}));

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t(KEY.common_name)}</FormLabel>
<FormControl>
<Input type="text" disabled={isLoading || isPending} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="content_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t(KEY.role_content_type)}</FormLabel>
<FormControl>
<Dropdown options={contentTypeOptions} disabled={isLoading || isPending} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="permissions"
render={({ field }) => (
<FormItem>
<FormLabel>{t(KEY.common_permissions)}</FormLabel>
<FormControl>
{isLoading ? (
<span>{t(KEY.common_loading)}...</span>
) : isError ? (
<Alert message={t(KEY.role_edit_could_not_load_permissions)} type="error" />
) : (
<MultiSelect options={permissionOptions} selected={selectedPermissions} {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<div className={styles.action_row}>
<Button type="submit" theme="green" disabled={isLoading || isPending}>
{t(KEY.common_save)}
</Button>
</div>
</form>
</Form>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RoleForm } from './RoleForm';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RoleForm } from './RoleForm';
1 change: 1 addition & 0 deletions frontend/src/PagesAdmin/RoleFormAdminPage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RoleFormAdminPage } from './RoleFormAdminPage';
47 changes: 29 additions & 18 deletions frontend/src/PagesAdmin/RolesAdminPage/RolesAdminPage.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,25 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { CrudButtons, Link } from '~/Components';
import { Button, CrudButtons, Link } from '~/Components';
import { Table } from '~/Components/Table';
import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout';
import type { RoleDto } from '~/dto';
import { getRoles } from '~/api';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
import { lowerCapitalize } from '~/utils';

export function RolesAdminPage() {
const { t } = useTranslation();

const navigate = useNavigate();

const [roles, setRoles] = useState<RoleDto[]>([
{
id: 1,
name: 'Opptaksansvarlig',
permissions: ['samfundet.test_permission', 'samfundet.user_create'],
},
{
id: 2,
name: 'Intervjuer',
permissions: [],
},
]);
const [loading, setLoading] = useState(false);
const { data: roles, isLoading } = useQuery({
queryKey: ['roles'],
queryFn: getRoles,
});

const columns = [
{ content: t(KEY.common_name), sortable: true },
Expand Down Expand Up @@ -53,15 +47,32 @@ export function RolesAdminPage() {
value: 0,
},
{
content: <CrudButtons onEdit={() => navigate('#')} />,
content: (
<CrudButtons
onEdit={() =>
navigate(
reverse({
pattern: ROUTES.frontend.admin_roles_edit,
urlParams: { roleId: r.id },
}),
)
}
/>
),
},
],
};
});
}, [roles]);

const header = (
<Button theme="success" link={ROUTES.frontend.admin_roles_create} rounded>
{lowerCapitalize(`${t(KEY.common_create)} ${t(KEY.common_role)}`)}
</Button>
);

return (
<AdminPageLayout title={t(KEY.common_roles)} loading={loading}>
<AdminPageLayout title={t(KEY.common_roles)} loading={isLoading} header={header}>
<Table data={data} columns={columns} />
</AdminPageLayout>
);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/PagesAdmin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export { RecruitmentUsersWithoutInterviewGangPage } from './RecruitmentUsersWith
export { RecruitmentUsersWithoutThreeInterviewCriteriaPage } from './RecruitmentUsersWithoutThreeInterviewCriteriaPage';
export { RolesAdminPage } from './RolesAdminPage';
export { RoleAdminPage } from './RoleAdminPage';
export { RoleFormAdminPage } from './RoleFormAdminPage';
export { CreateInterviewRoomPage, RoomAdminPage } from './RoomAdminPage';
export { SaksdokumentAdminPage } from './SaksdokumentAdminPage';
export { SaksdokumentFormAdminPage } from './SaksdokumentFormAdminPage';
Expand Down
Loading

0 comments on commit 9ff6451

Please sign in to comment.