@zauru-sdk/components 6.9.0 → 6.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/dist/AccessControl/WebappEmployeeAccess.d.ts +36 -0
- package/dist/AccessControl/WebappRoleManagement.d.ts +22 -0
- package/dist/AccessControl/index.d.ts +2 -0
- package/dist/NavBar/NavBar.d.ts +1 -1
- package/dist/NavBar/NavBar.types.d.ts +1 -0
- package/dist/Tab/WebappSettingsChrome.d.ts +12 -0
- package/dist/Tab/index.d.ts +1 -0
- package/dist/esm/AccessControl/WebappEmployeeAccess.js +103 -0
- package/dist/esm/AccessControl/WebappRoleManagement.js +77 -0
- package/dist/esm/AccessControl/index.js +2 -0
- package/dist/esm/Form/SelectField/index.js +11 -5
- package/dist/esm/NavBar/NavBar.js +6 -5
- package/dist/esm/Tab/PermissionMatrix.js +1 -1
- package/dist/esm/Tab/WebappSettingsChrome.js +24 -0
- package/dist/esm/Tab/index.js +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/index.d.ts +1 -0
- package/package.json +7 -7
- package/src/AccessControl/WebappEmployeeAccess.tsx +275 -0
- package/src/AccessControl/WebappRoleManagement.tsx +207 -0
- package/src/AccessControl/index.ts +2 -0
- package/src/Form/SelectField/index.tsx +35 -16
- package/src/NavBar/NavBar.tsx +43 -3
- package/src/NavBar/NavBar.types.ts +1 -0
- package/src/Tab/PermissionMatrix.tsx +2 -0
- package/src/Tab/WebappSettingsChrome.tsx +54 -0
- package/src/Tab/index.ts +1 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EmployeeGraphQL,
|
|
3
|
+
SelectFieldOption,
|
|
4
|
+
WebAppRowGraphQL,
|
|
5
|
+
WebappEmployeeRole,
|
|
6
|
+
WebappRole,
|
|
7
|
+
} from "@zauru-sdk/types";
|
|
8
|
+
import { isSuperAdminEmail } from "@zauru-sdk/utils";
|
|
9
|
+
import { useMemo, type ReactNode } from "react";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { Button } from "../Buttons/Button.js";
|
|
12
|
+
import { ButtonSectionContainer } from "../Containers/ButtonSectionContainer.js";
|
|
13
|
+
import { DoubleFieldContainer } from "../Form/FieldContainer/DoubleFieldContainer.js";
|
|
14
|
+
import { ReactZodForm } from "../Form/ReactZodForm/index.js";
|
|
15
|
+
import { SelectField } from "../Form/SelectField/index.js";
|
|
16
|
+
import { ZauruTable } from "../Table/ZauruTable.js";
|
|
17
|
+
|
|
18
|
+
const addEmployeeSchema = z.object({
|
|
19
|
+
employee_id: z.coerce.number().min(1, "El empleado es requerido"),
|
|
20
|
+
role_id: z.coerce.number().min(1, "El rol es requerido"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type WebappEmployeeAssignmentChange<
|
|
24
|
+
TAssignment extends WebappEmployeeRole,
|
|
25
|
+
> = {
|
|
26
|
+
employee: EmployeeGraphQL;
|
|
27
|
+
assignment?: WebAppRowGraphQL<TAssignment>;
|
|
28
|
+
roleId: number | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type WebappEmployeeAssignmentDetails<
|
|
32
|
+
TAssignment extends WebappEmployeeRole,
|
|
33
|
+
> = {
|
|
34
|
+
employee: EmployeeGraphQL;
|
|
35
|
+
assignment?: WebAppRowGraphQL<TAssignment>;
|
|
36
|
+
disabled: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type Props<TAssignment extends WebappEmployeeRole> = {
|
|
40
|
+
employees: EmployeeGraphQL[];
|
|
41
|
+
roles: WebAppRowGraphQL<WebappRole>[];
|
|
42
|
+
assignments: WebAppRowGraphQL<TAssignment>[];
|
|
43
|
+
canAssign?: boolean;
|
|
44
|
+
loading?: boolean;
|
|
45
|
+
description?: string;
|
|
46
|
+
superAdminLabel?: string;
|
|
47
|
+
addEmployeeTitle?: string;
|
|
48
|
+
addEmployeeDescription?: string;
|
|
49
|
+
noEmployeesToAddText?: string;
|
|
50
|
+
noRolesText?: string;
|
|
51
|
+
assignmentDetailsColumn?: {
|
|
52
|
+
name: string;
|
|
53
|
+
minWidth?: string;
|
|
54
|
+
grow?: number;
|
|
55
|
+
};
|
|
56
|
+
renderAssignmentDetails?: (
|
|
57
|
+
details: WebappEmployeeAssignmentDetails<TAssignment>,
|
|
58
|
+
) => ReactNode;
|
|
59
|
+
superAdminEmailSuffixes?: readonly string[];
|
|
60
|
+
onAssignRole: (
|
|
61
|
+
change: WebappEmployeeAssignmentChange<TAssignment>,
|
|
62
|
+
) => void;
|
|
63
|
+
onAddEmployee: (employeeId: number, roleId: number) => void;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function WebappEmployeeAccess<
|
|
67
|
+
TAssignment extends WebappEmployeeRole = WebappEmployeeRole,
|
|
68
|
+
>({
|
|
69
|
+
employees,
|
|
70
|
+
roles,
|
|
71
|
+
assignments,
|
|
72
|
+
canAssign = false,
|
|
73
|
+
loading = false,
|
|
74
|
+
description =
|
|
75
|
+
"Solo aparecen quienes ya tienen acceso: rol asignado o super admin. Quitar el rol los saca de la lista.",
|
|
76
|
+
superAdminLabel = "Super admin (todos los permisos)",
|
|
77
|
+
addEmployeeTitle = "Agregar empleado",
|
|
78
|
+
addEmployeeDescription =
|
|
79
|
+
"Busca entre quienes aún no están en la lista y asígnales un rol.",
|
|
80
|
+
noEmployeesToAddText = "No quedan empleados activos por agregar.",
|
|
81
|
+
noRolesText = "Crea al menos un rol antes de agregar empleados.",
|
|
82
|
+
assignmentDetailsColumn,
|
|
83
|
+
renderAssignmentDetails,
|
|
84
|
+
superAdminEmailSuffixes,
|
|
85
|
+
onAssignRole,
|
|
86
|
+
onAddEmployee,
|
|
87
|
+
}: Props<TAssignment>) {
|
|
88
|
+
const roleOptions: SelectFieldOption[] = useMemo(
|
|
89
|
+
() => [
|
|
90
|
+
{ value: "", label: "Sin rol" },
|
|
91
|
+
...roles.map((role) => ({
|
|
92
|
+
value: String(role.id),
|
|
93
|
+
label: role.data.name,
|
|
94
|
+
})),
|
|
95
|
+
],
|
|
96
|
+
[roles],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const addRoleOptions: SelectFieldOption[] = useMemo(
|
|
100
|
+
() =>
|
|
101
|
+
roles.map((role) => ({
|
|
102
|
+
value: String(role.id),
|
|
103
|
+
label: role.data.name,
|
|
104
|
+
})),
|
|
105
|
+
[roles],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const assignmentByEmployee = useMemo(() => {
|
|
109
|
+
const map = new Map<number, WebAppRowGraphQL<TAssignment>>();
|
|
110
|
+
for (const row of assignments) {
|
|
111
|
+
map.set(Number(row.data.employee_id), row);
|
|
112
|
+
}
|
|
113
|
+
return map;
|
|
114
|
+
}, [assignments]);
|
|
115
|
+
|
|
116
|
+
const listedEmployees = useMemo(
|
|
117
|
+
() =>
|
|
118
|
+
employees
|
|
119
|
+
.filter(
|
|
120
|
+
(employee) =>
|
|
121
|
+
isSuperAdminEmail(employee.email, superAdminEmailSuffixes) ||
|
|
122
|
+
assignmentByEmployee.has(employee.id),
|
|
123
|
+
)
|
|
124
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
125
|
+
[assignmentByEmployee, employees, superAdminEmailSuffixes],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const addEmployeeOptions: SelectFieldOption[] = useMemo(() => {
|
|
129
|
+
const listedIds = new Set(listedEmployees.map((employee) => employee.id));
|
|
130
|
+
return employees
|
|
131
|
+
.filter((employee) => !listedIds.has(employee.id))
|
|
132
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
133
|
+
.map((employee) => ({
|
|
134
|
+
value: employee.id,
|
|
135
|
+
label: employee.email
|
|
136
|
+
? `${employee.name} (${employee.email})`
|
|
137
|
+
: employee.name,
|
|
138
|
+
}));
|
|
139
|
+
}, [employees, listedEmployees]);
|
|
140
|
+
|
|
141
|
+
const columns = [
|
|
142
|
+
{
|
|
143
|
+
name: "Empleado",
|
|
144
|
+
sortable: true,
|
|
145
|
+
grow: 1,
|
|
146
|
+
minWidth: "12rem",
|
|
147
|
+
selector: (employee: EmployeeGraphQL) => employee.name,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "Email",
|
|
151
|
+
sortable: true,
|
|
152
|
+
grow: 1,
|
|
153
|
+
minWidth: "14rem",
|
|
154
|
+
selector: (employee: EmployeeGraphQL) => employee.email || "—",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "Rol",
|
|
158
|
+
minWidth: "14rem",
|
|
159
|
+
cell: (employee: EmployeeGraphQL) => {
|
|
160
|
+
const assignment = assignmentByEmployee.get(employee.id);
|
|
161
|
+
const superAdmin = isSuperAdminEmail(
|
|
162
|
+
employee.email,
|
|
163
|
+
superAdminEmailSuffixes,
|
|
164
|
+
);
|
|
165
|
+
const currentRole = assignment
|
|
166
|
+
? String(assignment.data.role_id)
|
|
167
|
+
: "";
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="w-56 py-2">
|
|
171
|
+
{superAdmin ? (
|
|
172
|
+
<p className="text-sm text-gray-600">{superAdminLabel}</p>
|
|
173
|
+
) : (
|
|
174
|
+
<SelectField
|
|
175
|
+
name={`role_${employee.id}`}
|
|
176
|
+
options={roleOptions}
|
|
177
|
+
defaultValue={
|
|
178
|
+
roleOptions.find(
|
|
179
|
+
(option) => option.value === currentRole,
|
|
180
|
+
) ?? roleOptions[0]
|
|
181
|
+
}
|
|
182
|
+
disabled={!canAssign || loading}
|
|
183
|
+
onChange={(option) => {
|
|
184
|
+
const value = option?.value?.toString() ?? "";
|
|
185
|
+
onAssignRole({
|
|
186
|
+
employee,
|
|
187
|
+
assignment,
|
|
188
|
+
roleId: value ? Number(value) : null,
|
|
189
|
+
});
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
...(assignmentDetailsColumn && renderAssignmentDetails
|
|
198
|
+
? [
|
|
199
|
+
{
|
|
200
|
+
name: assignmentDetailsColumn.name,
|
|
201
|
+
minWidth: assignmentDetailsColumn.minWidth ?? "16rem",
|
|
202
|
+
grow: assignmentDetailsColumn.grow ?? 1,
|
|
203
|
+
cell: (employee: EmployeeGraphQL) => {
|
|
204
|
+
const assignment = assignmentByEmployee.get(employee.id);
|
|
205
|
+
return renderAssignmentDetails({
|
|
206
|
+
employee,
|
|
207
|
+
assignment,
|
|
208
|
+
disabled: !canAssign || !assignment || loading,
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
]
|
|
213
|
+
: []),
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="mt-4 space-y-6">
|
|
218
|
+
<p className="text-sm text-gray-600">{description}</p>
|
|
219
|
+
<ZauruTable
|
|
220
|
+
columns={columns}
|
|
221
|
+
data={listedEmployees}
|
|
222
|
+
loading={loading}
|
|
223
|
+
offlineSearch={["name", "email"]}
|
|
224
|
+
/>
|
|
225
|
+
{canAssign ? (
|
|
226
|
+
roles.length === 0 ? (
|
|
227
|
+
<p className="text-sm text-gray-600" role="status">
|
|
228
|
+
{noRolesText}
|
|
229
|
+
</p>
|
|
230
|
+
) : addEmployeeOptions.length > 0 ? (
|
|
231
|
+
<div className="rounded-md border border-gray-200 bg-white p-4">
|
|
232
|
+
<ReactZodForm
|
|
233
|
+
schema={addEmployeeSchema}
|
|
234
|
+
onSubmit={(values) =>
|
|
235
|
+
onAddEmployee(
|
|
236
|
+
Number(values.employee_id),
|
|
237
|
+
Number(values.role_id),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
>
|
|
241
|
+
<h2 className="text-base font-semibold text-gray-900">
|
|
242
|
+
{addEmployeeTitle}
|
|
243
|
+
</h2>
|
|
244
|
+
<p className="mb-4 mt-1 text-sm text-gray-600">
|
|
245
|
+
{addEmployeeDescription}
|
|
246
|
+
</p>
|
|
247
|
+
<DoubleFieldContainer alignCenter={false}>
|
|
248
|
+
<SelectField
|
|
249
|
+
name="employee_id"
|
|
250
|
+
title="Empleado"
|
|
251
|
+
hint="Escribe para filtrar por nombre o correo"
|
|
252
|
+
options={addEmployeeOptions}
|
|
253
|
+
required
|
|
254
|
+
/>
|
|
255
|
+
<SelectField
|
|
256
|
+
name="role_id"
|
|
257
|
+
title="Rol"
|
|
258
|
+
options={addRoleOptions}
|
|
259
|
+
required
|
|
260
|
+
/>
|
|
261
|
+
</DoubleFieldContainer>
|
|
262
|
+
<ButtonSectionContainer>
|
|
263
|
+
<Button loading={loading} title="Agregar" />
|
|
264
|
+
</ButtonSectionContainer>
|
|
265
|
+
</ReactZodForm>
|
|
266
|
+
</div>
|
|
267
|
+
) : (
|
|
268
|
+
<p className="text-sm text-gray-600" role="status">
|
|
269
|
+
{noEmployeesToAddText}
|
|
270
|
+
</p>
|
|
271
|
+
)
|
|
272
|
+
) : null}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { PencilSvg, TrashSvg } from "@zauru-sdk/icons";
|
|
2
|
+
import type {
|
|
3
|
+
WebAppRowGraphQL,
|
|
4
|
+
WebappRole,
|
|
5
|
+
} from "@zauru-sdk/types";
|
|
6
|
+
import { useSyncExternalStore } from "react";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { Button } from "../Buttons/Button.js";
|
|
9
|
+
import { ButtonSectionContainer } from "../Containers/ButtonSectionContainer.js";
|
|
10
|
+
import { ReactZodForm } from "../Form/ReactZodForm/index.js";
|
|
11
|
+
import { TextField } from "../Form/TextField/index.js";
|
|
12
|
+
import { createModal } from "../Modal/Modal.js";
|
|
13
|
+
import { ZauruTable } from "../Table/ZauruTable.js";
|
|
14
|
+
|
|
15
|
+
const roleSchema = z.object({
|
|
16
|
+
name: z.string().min(1, "El nombre es requerido"),
|
|
17
|
+
description: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const subscribeToBrowser = () => () => undefined;
|
|
21
|
+
const getBrowserSnapshot = () => true;
|
|
22
|
+
const getServerSnapshot = () => false;
|
|
23
|
+
|
|
24
|
+
export type WebappRoleValues = {
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Props = {
|
|
30
|
+
roles: WebAppRowGraphQL<WebappRole>[];
|
|
31
|
+
canCreate?: boolean;
|
|
32
|
+
canEdit?: boolean;
|
|
33
|
+
canDelete?: boolean;
|
|
34
|
+
loading?: boolean;
|
|
35
|
+
createTitle?: string;
|
|
36
|
+
createButtonLabel?: string;
|
|
37
|
+
editTitle?: string;
|
|
38
|
+
deleteTitle?: string;
|
|
39
|
+
deleteDescription?: string;
|
|
40
|
+
onCreate: (values: WebappRoleValues) => void;
|
|
41
|
+
onUpdate: (roleId: number, values: WebappRoleValues) => void;
|
|
42
|
+
onDelete: (roleId: number) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function WebappRoleManagement({
|
|
46
|
+
roles,
|
|
47
|
+
canCreate = false,
|
|
48
|
+
canEdit = false,
|
|
49
|
+
canDelete = false,
|
|
50
|
+
loading = false,
|
|
51
|
+
createTitle = "Crear rol",
|
|
52
|
+
createButtonLabel = "Crear rol",
|
|
53
|
+
editTitle = "Editar rol",
|
|
54
|
+
deleteTitle = "¿Eliminar este rol?",
|
|
55
|
+
deleteDescription = "No se puede eliminar si hay empleados asignados.",
|
|
56
|
+
onCreate,
|
|
57
|
+
onUpdate,
|
|
58
|
+
onDelete,
|
|
59
|
+
}: Props) {
|
|
60
|
+
const isClient = useSyncExternalStore(
|
|
61
|
+
subscribeToBrowser,
|
|
62
|
+
getBrowserSnapshot,
|
|
63
|
+
getServerSnapshot,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const editRole = async (role: WebAppRowGraphQL<WebappRole>) => {
|
|
67
|
+
let name = role.data.name;
|
|
68
|
+
let description = role.data.description ?? "";
|
|
69
|
+
const response = await createModal({
|
|
70
|
+
title: editTitle,
|
|
71
|
+
okButtonText: "Guardar",
|
|
72
|
+
description: (
|
|
73
|
+
<>
|
|
74
|
+
<TextField
|
|
75
|
+
title="Nombre"
|
|
76
|
+
name="name"
|
|
77
|
+
defaultValue={name}
|
|
78
|
+
onChange={(value) => {
|
|
79
|
+
name = value;
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
<TextField
|
|
83
|
+
className="mt-4"
|
|
84
|
+
title="Descripción"
|
|
85
|
+
name="description"
|
|
86
|
+
defaultValue={description}
|
|
87
|
+
onChange={(value) => {
|
|
88
|
+
description = value;
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
</>
|
|
92
|
+
),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (response === "OK") {
|
|
96
|
+
onUpdate(role.id, { name, description });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const confirmDelete = async (roleId: number) => {
|
|
101
|
+
const response = await createModal({
|
|
102
|
+
title: deleteTitle,
|
|
103
|
+
description: deleteDescription,
|
|
104
|
+
});
|
|
105
|
+
if (response === "OK") {
|
|
106
|
+
onDelete(roleId);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const columns = [
|
|
111
|
+
{
|
|
112
|
+
name: "Nombre",
|
|
113
|
+
sortable: true,
|
|
114
|
+
grow: 1,
|
|
115
|
+
selector: (role: WebAppRowGraphQL<WebappRole>) => role.data.name,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "Descripción",
|
|
119
|
+
grow: 2,
|
|
120
|
+
selector: (role: WebAppRowGraphQL<WebappRole>) =>
|
|
121
|
+
role.data.description || "—",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "Permisos",
|
|
125
|
+
width: "8%",
|
|
126
|
+
selector: (role: WebAppRowGraphQL<WebappRole>) =>
|
|
127
|
+
role.data.permission_keys?.length ?? 0,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "",
|
|
131
|
+
grow: 0,
|
|
132
|
+
minWidth: "90px",
|
|
133
|
+
maxWidth: "90px",
|
|
134
|
+
cell: (role: WebAppRowGraphQL<WebappRole>) => (
|
|
135
|
+
<div className="flex gap-2">
|
|
136
|
+
{canDelete ? (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
title="Eliminar"
|
|
140
|
+
aria-label={`Eliminar rol ${role.data.name}`}
|
|
141
|
+
disabled={loading}
|
|
142
|
+
className="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
143
|
+
onClick={() => void confirmDelete(role.id)}
|
|
144
|
+
>
|
|
145
|
+
<TrashSvg />
|
|
146
|
+
</button>
|
|
147
|
+
) : null}
|
|
148
|
+
{canEdit ? (
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
title="Editar"
|
|
152
|
+
aria-label={`Editar rol ${role.data.name}`}
|
|
153
|
+
disabled={loading}
|
|
154
|
+
className="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
155
|
+
onClick={() => void editRole(role)}
|
|
156
|
+
>
|
|
157
|
+
<PencilSvg />
|
|
158
|
+
</button>
|
|
159
|
+
) : null}
|
|
160
|
+
</div>
|
|
161
|
+
),
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
if (!isClient) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="mt-4 space-y-6">
|
|
171
|
+
<ZauruTable
|
|
172
|
+
columns={columns}
|
|
173
|
+
data={[...roles].sort((a, b) =>
|
|
174
|
+
a.data.name.localeCompare(b.data.name),
|
|
175
|
+
)}
|
|
176
|
+
loading={loading}
|
|
177
|
+
offlineSearch={["data.name", "data.description"]}
|
|
178
|
+
/>
|
|
179
|
+
{canCreate ? (
|
|
180
|
+
<div className="rounded-md border border-gray-200 bg-white p-4">
|
|
181
|
+
<ReactZodForm
|
|
182
|
+
schema={roleSchema}
|
|
183
|
+
onSubmit={(values) =>
|
|
184
|
+
onCreate({
|
|
185
|
+
name: String(values.name ?? ""),
|
|
186
|
+
description: String(values.description ?? ""),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
>
|
|
190
|
+
<h2 className="mb-4 text-base font-semibold text-gray-900">
|
|
191
|
+
{createTitle}
|
|
192
|
+
</h2>
|
|
193
|
+
<TextField name="name" title="Nombre del rol" required />
|
|
194
|
+
<TextField
|
|
195
|
+
className="mt-4"
|
|
196
|
+
name="description"
|
|
197
|
+
title="Descripción"
|
|
198
|
+
/>
|
|
199
|
+
<ButtonSectionContainer>
|
|
200
|
+
<Button loading={loading} title={createButtonLabel} />
|
|
201
|
+
</ButtonSectionContainer>
|
|
202
|
+
</ReactZodForm>
|
|
203
|
+
</div>
|
|
204
|
+
) : null}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -4,6 +4,7 @@ import React, {
|
|
|
4
4
|
useEffect,
|
|
5
5
|
useState,
|
|
6
6
|
useRef,
|
|
7
|
+
useId,
|
|
7
8
|
KeyboardEvent,
|
|
8
9
|
useCallback,
|
|
9
10
|
} from "react";
|
|
@@ -89,6 +90,10 @@ export const SelectField = (props: Props) => {
|
|
|
89
90
|
);
|
|
90
91
|
const [isTabPressed, setIsTabPressed] = useState<boolean>(false);
|
|
91
92
|
const [isSearching, setIsSearching] = useState<boolean>(false);
|
|
93
|
+
const generatedInputId = useId();
|
|
94
|
+
const inputId = id ?? (name ? `${name}-input` : generatedInputId);
|
|
95
|
+
const errorId = `${inputId}-error`;
|
|
96
|
+
const hintId = `${inputId}-hint`;
|
|
92
97
|
|
|
93
98
|
// Use refs to store stable references to callbacks
|
|
94
99
|
const onChangeRef = useRef(onChange);
|
|
@@ -409,12 +414,11 @@ export const SelectField = (props: Props) => {
|
|
|
409
414
|
return (
|
|
410
415
|
<>
|
|
411
416
|
{title && (
|
|
412
|
-
<
|
|
413
|
-
htmlFor={error ? `${name}-error` : `${name}-success`}
|
|
417
|
+
<span
|
|
414
418
|
className={`block text-sm font-medium text-${color}-700 dark:text-${color}-500`}
|
|
415
419
|
>
|
|
416
420
|
{title}
|
|
417
|
-
</
|
|
421
|
+
</span>
|
|
418
422
|
)}
|
|
419
423
|
<LoadingInputSkeleton />
|
|
420
424
|
{helpText && (
|
|
@@ -432,7 +436,7 @@ export const SelectField = (props: Props) => {
|
|
|
432
436
|
<div className={`col-span-6 sm:col-span-3 ${className}`} ref={selectRef}>
|
|
433
437
|
{title && (
|
|
434
438
|
<label
|
|
435
|
-
htmlFor={
|
|
439
|
+
htmlFor={inputId}
|
|
436
440
|
className={`block text-sm font-medium ${
|
|
437
441
|
color === "red"
|
|
438
442
|
? "text-red-700 dark:text-red-500"
|
|
@@ -447,7 +451,7 @@ export const SelectField = (props: Props) => {
|
|
|
447
451
|
<div className="flex items-center">
|
|
448
452
|
<input
|
|
449
453
|
type="text"
|
|
450
|
-
id={
|
|
454
|
+
id={inputId}
|
|
451
455
|
value={inputValue}
|
|
452
456
|
onFocus={() => setIsOpen(true)}
|
|
453
457
|
onKeyDown={handleKeyDown}
|
|
@@ -459,14 +463,21 @@ export const SelectField = (props: Props) => {
|
|
|
459
463
|
onChange={handleInputChange}
|
|
460
464
|
onBlur={handleBlur}
|
|
461
465
|
required={required}
|
|
466
|
+
aria-invalid={Boolean(error)}
|
|
467
|
+
aria-describedby={error ? errorId : hint ? hintId : undefined}
|
|
462
468
|
/>
|
|
463
469
|
{isClearable && (value || valueMulti.length > 0) && (
|
|
464
470
|
<button
|
|
465
471
|
type="button"
|
|
466
472
|
onClick={handleClear}
|
|
467
|
-
|
|
473
|
+
aria-label={
|
|
474
|
+
isMulti
|
|
475
|
+
? "Limpiar todas las opciones seleccionadas"
|
|
476
|
+
: "Limpiar la opción seleccionada"
|
|
477
|
+
}
|
|
478
|
+
className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-gray-600 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
|
|
468
479
|
>
|
|
469
|
-
|
|
480
|
+
<span aria-hidden="true">×</span>
|
|
470
481
|
</button>
|
|
471
482
|
)}
|
|
472
483
|
{helpText && (
|
|
@@ -542,32 +553,40 @@ export const SelectField = (props: Props) => {
|
|
|
542
553
|
)}
|
|
543
554
|
</div>
|
|
544
555
|
{isMulti && valueMulti.length > 0 && (
|
|
545
|
-
<
|
|
556
|
+
<ul
|
|
557
|
+
className="mt-2 flex max-h-20 flex-wrap gap-1.5 overflow-auto overscroll-contain rounded-md border border-blue-100 bg-blue-50/40 p-1.5"
|
|
558
|
+
aria-label="Opciones seleccionadas"
|
|
559
|
+
>
|
|
546
560
|
{valueMulti.map((option, index) => (
|
|
547
|
-
<
|
|
561
|
+
<li
|
|
548
562
|
key={`${option.value}-${index}`}
|
|
549
|
-
className="
|
|
563
|
+
className="inline-flex max-w-full shrink-0 items-center rounded bg-blue-100 py-0.5 pl-2.5 pr-0.5 text-xs font-semibold text-blue-800"
|
|
550
564
|
>
|
|
551
|
-
{option.label}
|
|
565
|
+
<span className="whitespace-nowrap">{option.label}</span>
|
|
552
566
|
<button
|
|
553
567
|
type="button"
|
|
554
568
|
onClick={() => handleRemoveMultiValue(option)}
|
|
555
|
-
|
|
569
|
+
aria-label={`Quitar ${option.label}`}
|
|
570
|
+
className="ml-1 inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-blue-700 hover:bg-blue-200 hover:text-blue-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
556
571
|
>
|
|
557
|
-
|
|
572
|
+
<span aria-hidden="true">×</span>
|
|
558
573
|
</button>
|
|
559
|
-
</
|
|
574
|
+
</li>
|
|
560
575
|
))}
|
|
561
|
-
</
|
|
576
|
+
</ul>
|
|
562
577
|
)}
|
|
563
578
|
{error && (
|
|
564
|
-
<p
|
|
579
|
+
<p
|
|
580
|
+
id={errorId}
|
|
581
|
+
className={`mt-2 text-sm text-${color}-600 dark:text-${color}-500`}
|
|
582
|
+
>
|
|
565
583
|
<span className="font-medium">Oops!</span>{" "}
|
|
566
584
|
{error?.message?.toString() || "Error desconocido"}
|
|
567
585
|
</p>
|
|
568
586
|
)}
|
|
569
587
|
{!error && hint && (
|
|
570
588
|
<p
|
|
589
|
+
id={hintId}
|
|
571
590
|
className={`mt-2 italic text-sm text-${color}-500 dark:text-${color}-400`}
|
|
572
591
|
>
|
|
573
592
|
{hint}
|
package/src/NavBar/NavBar.tsx
CHANGED
|
@@ -15,7 +15,12 @@ import type {
|
|
|
15
15
|
import { Link, useNavigate, useLocation } from "@remix-run/react";
|
|
16
16
|
import { useAppSelector } from "@zauru-sdk/redux";
|
|
17
17
|
|
|
18
|
-
const OptionsDropDownButton = ({
|
|
18
|
+
const OptionsDropDownButton = ({
|
|
19
|
+
color,
|
|
20
|
+
options,
|
|
21
|
+
name,
|
|
22
|
+
ariaLabel,
|
|
23
|
+
}: EntityProps & { ariaLabel?: string }) => {
|
|
19
24
|
const [showOptionsMenu, setShowOptionsMenu] = useState(true);
|
|
20
25
|
|
|
21
26
|
return (
|
|
@@ -23,8 +28,14 @@ const OptionsDropDownButton = ({ color, options, name }: EntityProps) => {
|
|
|
23
28
|
<div className="flex justify-center">
|
|
24
29
|
<div className="relative inline-block">
|
|
25
30
|
<button
|
|
31
|
+
type="button"
|
|
32
|
+
aria-label={
|
|
33
|
+
ariaLabel ?? (typeof name === "string" ? name : "Menú")
|
|
34
|
+
}
|
|
35
|
+
aria-haspopup="menu"
|
|
36
|
+
aria-expanded={!showOptionsMenu}
|
|
26
37
|
onClick={() => setShowOptionsMenu(!showOptionsMenu)}
|
|
27
|
-
className={`relative flex cursor-pointer items-center p-2 text-xs text-white ${color.bg700} active:${color.bg900} border border-transparent rounded-full uppercase focus:ring-
|
|
38
|
+
className={`relative flex cursor-pointer items-center p-2 text-xs text-white ${color.bg700} active:${color.bg900} border border-transparent rounded-full uppercase focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2`}
|
|
28
39
|
>
|
|
29
40
|
{name ?? <OpcionButtonSvgIcon />}
|
|
30
41
|
<DropDownArrowSvgIcon />
|
|
@@ -128,6 +139,7 @@ export const NavBar = ({
|
|
|
128
139
|
selectedColor,
|
|
129
140
|
version,
|
|
130
141
|
reloadCatalogsOption,
|
|
142
|
+
settingsOption,
|
|
131
143
|
showDarkModeToggle = false,
|
|
132
144
|
}: NavBarProps) => {
|
|
133
145
|
const color: ColorInterface = COLORS[selectedColor];
|
|
@@ -191,7 +203,35 @@ export const NavBar = ({
|
|
|
191
203
|
{loggedIn && (
|
|
192
204
|
<OptionsDropDownButton
|
|
193
205
|
color={color}
|
|
206
|
+
ariaLabel="Menú de usuario"
|
|
194
207
|
options={[
|
|
208
|
+
settingsOption && (
|
|
209
|
+
<Link
|
|
210
|
+
key="configuracion"
|
|
211
|
+
className="block cursor-pointer px-4 py-3 text-sm text-gray-600 capitalize transition-colors duration-200 transform dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 dark:hover:text-white"
|
|
212
|
+
to="/configuracion"
|
|
213
|
+
>
|
|
214
|
+
<div className="mx-auto pt-2">
|
|
215
|
+
<svg
|
|
216
|
+
className="h-5 w-5 mr-1"
|
|
217
|
+
width="24"
|
|
218
|
+
height="24"
|
|
219
|
+
fill="none"
|
|
220
|
+
stroke="currentColor"
|
|
221
|
+
strokeWidth="1.5"
|
|
222
|
+
viewBox="0 0 24 24"
|
|
223
|
+
strokeLinecap="round"
|
|
224
|
+
strokeLinejoin="round"
|
|
225
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
226
|
+
aria-hidden="true"
|
|
227
|
+
>
|
|
228
|
+
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
|
|
229
|
+
<path d="M19.4 13a7.7 7.7 0 0 0 .06-2l2.1-1.64-2-3.46-2.55 1a7.7 7.7 0 0 0-1.73-1L14.7 2H9.3l-.58 2.9a7.7 7.7 0 0 0-1.73 1l-2.55-1-2 3.46L4.54 11a7.7 7.7 0 0 0 0 2l-2.1 1.64 2 3.46 2.55-1a7.7 7.7 0 0 0 1.73 1L9.3 22h5.4l.58-2.9a7.7 7.7 0 0 0 1.73-1l2.55 1 2-3.46Z" />
|
|
230
|
+
</svg>
|
|
231
|
+
<span>Configuración</span>
|
|
232
|
+
</div>
|
|
233
|
+
</Link>
|
|
234
|
+
),
|
|
195
235
|
reloadCatalogsOption && (
|
|
196
236
|
<Link
|
|
197
237
|
key="recargar-catalogos"
|
|
@@ -247,7 +287,7 @@ export const NavBar = ({
|
|
|
247
287
|
</ul>
|
|
248
288
|
</>
|
|
249
289
|
);
|
|
250
|
-
}, [items, loggedIn, hiddenItemsChange, showDarkModeToggle, isDarkMode]);
|
|
290
|
+
}, [items, loggedIn, hiddenItemsChange, reloadCatalogsOption, settingsOption, showDarkModeToggle, isDarkMode]);
|
|
251
291
|
|
|
252
292
|
return (
|
|
253
293
|
<nav className={`py-3 ${color.bg600}`}>
|