Skip to content

Commit d73f253

Browse files
authored
Merge pull request #1070 from joshunrau/update-group
2 parents 2eed15e + 9bb3c90 commit d73f253

File tree

9 files changed

+5277
-3157
lines changed

9 files changed

+5277
-3157
lines changed

apps/api/src/users/users.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,14 @@ export class UsersService {
107107
return user;
108108
}
109109

110-
async updateById(id: string, data: UpdateUserDto, { ability }: EntityOperationOptions = {}) {
110+
async updateById(id: string, { groupIds, ...data }: UpdateUserDto, { ability }: EntityOperationOptions = {}) {
111111
return this.userModel.update({
112-
data,
112+
data: {
113+
...data,
114+
groups: {
115+
connect: groupIds?.map((id) => ({ id }))
116+
}
117+
},
113118
where: { AND: [accessibleQuery(ability, 'update', 'User')], id }
114119
});
115120
}

apps/web/.storybook/main.cts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ const config: StorybookConfig = {
2626
files: '**/*.stories.@(js|jsx|ts|tsx)',
2727
titlePrefix: 'Components'
2828
},
29+
{
30+
directory: '../src/features/admin/components',
31+
files: '**/*.stories.@(js|jsx|ts|tsx)',
32+
titlePrefix: 'Admin'
33+
},
2934
{
3035
directory: '../src/features/auth/components',
3136
files: '**/*.stories.@(js|jsx|ts|tsx)',

apps/web/src/components/WithFallback.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { useEffect, useState } from 'react';
44

55
import { LoadingFallback } from './LoadingFallback';
66

7-
const MIN_DELAY = 300; // ms
8-
97
function isDataReady<TProps extends { data: unknown }>(
108
props: TProps
119
): props is TProps & { data: NonNullable<TProps['data']> } {
@@ -14,9 +12,12 @@ function isDataReady<TProps extends { data: unknown }>(
1412

1513
export function WithFallback<TProps extends { [key: string]: unknown }>({
1614
Component,
15+
minDelay = 300, // ms
1716
props
1817
}: {
1918
Component: React.FC<TProps>;
19+
/** the minimum duration to suspend in ms */
20+
minDelay?: number;
2021
props: TProps extends { data: infer TData extends NonNullable<unknown> }
2122
? Omit<TProps, 'data'> & { data: null | TData | undefined }
2223
: never;
@@ -29,7 +30,7 @@ export function WithFallback<TProps extends { [key: string]: unknown }>({
2930
if (!isMinDelayComplete) {
3031
timeout = setTimeout(() => {
3132
setIsMinDelayComplete(true);
32-
}, MIN_DELAY);
33+
}, minDelay);
3334
}
3435
return () => clearTimeout(timeout);
3536
}, []);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
3+
import { UpdateUserForm } from './UpdateUserForm';
4+
5+
type Story = StoryObj<typeof UpdateUserForm>;
6+
7+
export default { component: UpdateUserForm } as Meta<typeof UpdateUserForm>;
8+
9+
export const Default: Story = {
10+
args: {
11+
data: {
12+
disableDelete: false,
13+
groupOptions: {},
14+
initialValues: {
15+
additionalPermissions: [
16+
{
17+
action: 'create',
18+
subject: 'User'
19+
}
20+
]
21+
}
22+
},
23+
onDelete: () => alert('Delete!'),
24+
onSubmit: (data) => alert(JSON.stringify({ data }))
25+
}
26+
};
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { isAllUndefined } from '@douglasneuroinformatics/libjs';
2+
import { Button, Form } from '@douglasneuroinformatics/libui/components';
3+
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
4+
import type { FormTypes } from '@opendatacapture/runtime-core';
5+
import { $UserPermission } from '@opendatacapture/schemas/user';
6+
import type { Promisable } from 'type-fest';
7+
import { z } from 'zod';
8+
9+
const $UpdateUserFormData = z
10+
.object({
11+
additionalPermissions: z.array($UserPermission.partial()).optional(),
12+
groupIds: z.set(z.string())
13+
})
14+
.transform((arg) => {
15+
const firstPermission = arg.additionalPermissions?.[0];
16+
if (firstPermission && isAllUndefined(firstPermission)) {
17+
arg.additionalPermissions?.pop();
18+
}
19+
return arg;
20+
})
21+
.superRefine((arg, ctx) => {
22+
arg.additionalPermissions?.forEach((permission, i) => {
23+
Object.entries(permission).forEach(([key, val]) => {
24+
if ((val satisfies string) === undefined) {
25+
ctx.addIssue({
26+
code: z.ZodIssueCode.invalid_type,
27+
expected: 'string',
28+
path: ['additionalPermissions', i, key],
29+
received: 'undefined'
30+
});
31+
}
32+
});
33+
});
34+
});
35+
36+
type UpdateUserFormData = z.infer<typeof $UpdateUserFormData>;
37+
38+
export type UpdateUserFormInputData = {
39+
disableDelete: boolean;
40+
groupOptions: {
41+
[id: string]: string;
42+
};
43+
initialValues: FormTypes.PartialNullableData<UpdateUserFormData>;
44+
};
45+
46+
export const UpdateUserForm: React.FC<{
47+
data: UpdateUserFormInputData;
48+
onDelete: () => void;
49+
onSubmit: (data: UpdateUserFormData) => Promisable<void>;
50+
}> = ({ data, onDelete, onSubmit }) => {
51+
const { disableDelete, groupOptions, initialValues } = data;
52+
const { t } = useTranslation();
53+
54+
return (
55+
<Form
56+
additionalButtons={{
57+
left: (
58+
<Button className="w-full" disabled={disableDelete} type="button" variant="danger" onClick={onDelete}>
59+
{t('core.delete')}
60+
</Button>
61+
)
62+
}}
63+
content={[
64+
{
65+
description: t({
66+
en: 'IMPORTANT: These permissions are not specific to any group. To manage granular permissions, please use the API.',
67+
fr: "IMPORTANT : Ces autorisations ne sont pas spécifiques à un groupe. Pour gérer des autorisations granulaires, veuillez utiliser l'API."
68+
}),
69+
fields: {
70+
additionalPermissions: {
71+
fieldset: {
72+
action: {
73+
kind: 'string',
74+
label: t({
75+
en: 'Action',
76+
fr: 'Action'
77+
}),
78+
options: {
79+
create: t({
80+
en: 'Create',
81+
fr: 'Créer'
82+
}),
83+
delete: t({
84+
en: 'Delete',
85+
fr: 'Effacer'
86+
}),
87+
manage: t({
88+
en: 'Manage (All)',
89+
fr: 'Gérer (Tout)'
90+
}),
91+
read: t({
92+
en: 'Read',
93+
fr: 'Lire'
94+
}),
95+
update: t({
96+
en: 'Update',
97+
fr: 'Mettre à jour'
98+
})
99+
},
100+
variant: 'select'
101+
},
102+
subject: {
103+
kind: 'string',
104+
label: t({
105+
en: 'Resource',
106+
fr: 'Resource'
107+
}),
108+
options: {
109+
all: t({
110+
en: 'All',
111+
fr: 'Tous'
112+
}),
113+
Assignment: t({
114+
en: 'Assignment',
115+
fr: 'Devoir'
116+
}),
117+
Group: t({
118+
en: 'Group',
119+
fr: 'Groupe'
120+
}),
121+
Instrument: t({
122+
en: 'Instrument',
123+
fr: 'Instrument'
124+
}),
125+
InstrumentRecord: t({
126+
en: 'Instrument Record',
127+
fr: "Enregistrement de l'instrument"
128+
}),
129+
Session: t({
130+
en: 'Session',
131+
fr: 'Session'
132+
}),
133+
Subject: t({
134+
en: 'Subject',
135+
fr: 'Client'
136+
}),
137+
User: t({
138+
en: 'User',
139+
fr: 'Utilisateur'
140+
})
141+
},
142+
variant: 'select'
143+
}
144+
},
145+
kind: 'record-array',
146+
label: t({
147+
en: 'Permission',
148+
fr: 'Autorisations supplémentaires'
149+
})
150+
}
151+
},
152+
title: t({
153+
en: 'Authorization',
154+
fr: 'Autorisation'
155+
})
156+
},
157+
{
158+
fields: {
159+
groupIds: {
160+
kind: 'set',
161+
label: 'Group IDs',
162+
options: groupOptions,
163+
variant: 'listbox'
164+
}
165+
},
166+
title: t({
167+
en: 'Groups',
168+
fr: 'Groupes'
169+
})
170+
}
171+
]}
172+
initialValues={initialValues}
173+
key={JSON.stringify(initialValues)}
174+
submitBtnLabel={t('core.save')}
175+
validationSchema={$UpdateUserFormData}
176+
onSubmit={onSubmit}
177+
/>
178+
);
179+
};

0 commit comments

Comments
 (0)