Skip to content

Commit

Permalink
Merge branch 'staging'
Browse files Browse the repository at this point in the history
  • Loading branch information
Mihoub2 committed Feb 20, 2023
2 parents 314d843 + b026243 commit 1b7f1e4
Show file tree
Hide file tree
Showing 17 changed files with 461 additions and 30 deletions.
6 changes: 4 additions & 2 deletions src/components/bloc/bloc.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function calcultateCount(data) {
return { current, inactive, forthcoming };
}

export default function Bloc({ children, data, error, isLoading, hideOnEmptyView, noBadge, isRelation }) {
export default function Bloc({ children, data, error, isLoading, hideOnEmptyView, noBadge, isRelation, forceActionDisplay }) {
const { editMode } = useEditMode();
const header = Children.toArray(children).find((child) => child.props.__TYPE === 'BlocTitle');
const editActions = Children.toArray(children).filter((child) => (child.props.__TYPE === 'BlocActionButton' && child.props.edit));
Expand All @@ -35,7 +35,7 @@ export default function Bloc({ children, data, error, isLoading, hideOnEmptyView
</Row>
</div>
<ButtonGroup size="sm" isInlineFrom="xs">
{(editMode) && editActions.map((element, i) => <span key={i}>{element}</span>)}
{(editMode || forceActionDisplay) && editActions.map((element, i) => <span key={i}>{element}</span>)}
{((data?.totalCount > 0)) && viewActions.map((element, i) => <span key={i}>{element}</span>)}
</ButtonGroup>
</Row>
Expand Down Expand Up @@ -87,6 +87,7 @@ Bloc.propTypes = {
isLoading: PropTypes.bool,
isRelation: PropTypes.bool,
noBadge: PropTypes.bool,
forceActionDisplay: PropTypes.bool,
};

Bloc.defaultProps = {
Expand All @@ -97,4 +98,5 @@ Bloc.defaultProps = {
isLoading: null,
isRelation: false,
noBadge: false,
forceActionDisplay: false,
};
134 changes: 134 additions & 0 deletions src/components/blocs/apikeys/components/apikeys-list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { Col, Container, Row, Text } from '@dataesr/react-dsfr';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { useState } from 'react';
import Button from '../../../button';
import CopyButton from '../../../copy/copy-button';
import { Spinner } from '../../../spinner';
import useSort from '../hooks/useSort';
import '../styles/data-list.scss';

export default function ApiKeysList({ data, deleteItem, highlight }) {
const [sort, setSort] = useSort({ field: 'createdAt', type: 'date', ascending: false });
const [actionnedItem, setActionnedItem] = useState();
const sortedData = sort.ascending ? data.sort(sort.sorter) : data.sort(sort.sorter).reverse();
return (
<Container fluid>
<Row alignItems="middle">
<Col n="12" className="tbl-line fr-py-1v fr-px-1w">
<Row alignItems="middle">
<Col n="3">
<Row
className={classNames('tbl-title__sort', { 'tbl-title__hover': (sort.field !== 'user') })}
alignItems="middle"
>
<Text className="fr-mb-0" bold>Utilisateur</Text>
<Button
tertiary
borderless
rounded
icon={`ri-arrow-${(sort.field === 'user' && !sort.ascending) ? 'up' : 'down'}-fill`}
onClick={() => setSort({ field: 'user', type: 'string' })}
/>
</Row>
</Col>
<Col n="2">
<Row
className={classNames('tbl-title__sort', { 'tbl-title__hover': (sort.field !== 'name') })}
alignItems="middle"
justifyContent="right"
>
<Button
tertiary
borderless
rounded
icon={`ri-arrow-${(sort.field === 'name' && !sort.ascending) ? 'up' : 'down'}-fill`}
onClick={() => setSort({ field: 'name', type: 'string' })}
/>
<Text className="fr-mb-0" bold>Nom de la clé</Text>
</Row>
</Col>
<Col n="3">
<Row
className={classNames('tbl-title__sort')}
justifyContent="right"
alignItems="middle"
>
<Text className="fr-mb-0" bold>Clé API</Text>
</Row>
</Col>
<Col n="3">
<Row
className={classNames('tbl-title__sort', { 'tbl-title__hover': (sort.field !== 'createdAt') })}
justifyContent="right"
alignItems="middle"
>
<Button
tertiary
borderless
rounded
icon={`ri-arrow-${(sort.field === 'createdAt' && !sort.ascending) ? 'up' : 'down'}-fill`}
onClick={() => setSort({ field: 'createdAt', type: 'date' })}
/>
<Text className="fr-mb-0" bold>Créé le</Text>
</Row>
</Col>
<Col n="1" />
</Row>
</Col>
{sortedData.map((item) => (
<Col n="12" key={item.id} className={classNames('tbl-line tbl-line__item fr-py-1w fr-px-1w', { 'tbl-highlight': (highlight === item.id) })}>
<Row alignItems="middle">
<Col n="3">
<Text className="fr-mb-0">
{item.user}
</Text>
</Col>
<Col n="2"><Row justifyContent="right"><Text className="fr-mb-0">{item.name}</Text></Row></Col>
<Col n="3">
<Row justifyContent="right">
<Text className="fr-mb-0">
{`${item.apiKey.split('-')[0]}-*****`}
</Text>
<CopyButton copyText={item.apiKey} />
</Row>
</Col>
<Col n="3">
<Row justifyContent="right">
<Text className="fr-mb-0">
{new Date(item.createdAt).toLocaleDateString([], { year: 'numeric', month: 'long', day: 'numeric' })}
</Text>
</Row>
</Col>
<Col n="1">
<Row justifyContent="right">
{(actionnedItem !== item.id) && (
<Button
onClick={() => { setActionnedItem(item.id); deleteItem(item.id); }}
tertiary
rounded
borderless
color="error"
title="Supprimer"
icon="ri-delete-bin-line"
/>
)}
{(actionnedItem === item.id) && <Spinner size={32} />}
</Row>
</Col>
</Row>
</Col>
))}
</Row>
</Container>
);
}

ApiKeysList.propTypes = {
data: PropTypes.array.isRequired,
deleteItem: PropTypes.func.isRequired,
highlight: PropTypes.string,
};
ApiKeysList.defaultProps = {
highlight: null,
};
113 changes: 113 additions & 0 deletions src/components/blocs/apikeys/components/form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Col, Container, Row, Select, TextInput } from '@dataesr/react-dsfr';
import useForm from '../../../../hooks/useForm';
import api from '../../../../utils/api';
import FormFooter from '../../../forms/form-footer';
import SearchBar from '../../../search-bar';

function validate(body) {
const validationErrors = {};
if (!body.userId) { validationErrors.userId = "L'utilisateur doit être séléctionné"; }
if (!body.role) { validationErrors.role = 'Le rôle est obligatoire.'; }
if (!body.name) { validationErrors.type = 'Le nom de la clé est obligatoire'; }
return validationErrors;
}

function sanitize(form) {
const fields = ['name', 'userId', 'role'];
const body = {};
Object.keys(form).forEach((key) => { if (fields.includes(key)) { body[key] = form[key]; } });
return body;
}

export default function ApiKeyForm({ onSave }) {
const { form, updateForm, errors } = useForm({ role: 'reader' }, validate);
const [showErrors, setShowErrors] = useState(false);
const [userQuery, setUserQuery] = useState('');
const [userOptions, setUserOptions] = useState('');
const [isSearching, setIsSearching] = useState(false);

const handleSubmit = () => {
if (Object.keys(errors).length !== 0) return setShowErrors(true);
const body = sanitize(form);
return onSave(body);
};

const handleUserSelect = ({ id: userId, name: username }) => {
updateForm({ userId, username });
setUserQuery('');
setUserOptions([]);
};

const handleUserDelete = () => {
updateForm({ userId: null, username: null });
setUserQuery('');
setUserOptions([]);
};

useEffect(() => {
const getAutocompleteResult = async () => {
setIsSearching(true);
const response = await api.get(`/autocomplete?query=${userQuery}&types=users`);
setUserOptions(response.data?.data);
setIsSearching(false);
};
if (userQuery) { getAutocompleteResult(); } else { setUserOptions([]); }
}, [userQuery]);

return (
<form>
<Container fluid>
<Row gutters>
<Col n="12">
<SearchBar
required
buttonLabel="Rechercher un utilisateur"
isSearching={isSearching}
label="Sélectionner un utilisateur"
onChange={(e) => { setUserQuery(e.target.value); }}
onSelect={handleUserSelect}
options={userOptions}
scope={form.username}
onDeleteScope={handleUserDelete}
placeholder={form.username ? null : 'Rechercher un utilisateur...'}
value={userQuery || ''}
message={
showErrors && errors.userId
? errors.userId
: null
}
messageType={showErrors && errors.userId ? 'error' : ''}
/>
</Col>
<Col n="12">
<TextInput
label="Nom de la clé"
required
value={form.name}
onChange={(e) => updateForm({ name: e.target.value })}
message={(showErrors && errors.name) ? errors.name : null}
messageType={(showErrors && errors.name) ? 'error' : ''}
/>
</Col>
<Col n="12">
<Select
label="Rôle associé à la clé"
options={[{ value: 'reader', label: 'Invité (lecture uniquement)' }, { value: 'user', label: 'Utilisateur (lecture & écriture)' }, { value: 'admin', label: 'Administrateur' }]}
selected={form.role}
onChange={(e) => updateForm({ role: e.target.value })}
required
message={(showErrors && errors.role) ? errors.role : null}
messageType={(showErrors && errors.role) ? 'error' : ''}
/>
</Col>
</Row>
<FormFooter onSaveHandler={handleSubmit} />
</Container>
</form>
);
}
ApiKeyForm.propTypes = {
onSave: PropTypes.func.isRequired,
};
21 changes: 21 additions & 0 deletions src/components/blocs/apikeys/hooks/useSort.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useReducer } from 'react';

const sorters = {
string: (field) => (a, b) => a[field]?.localeCompare(b[field]),
date: (field) => (a, b) => new Date(a[field]) - new Date(b[field]),
number: (field) => (a, b) => a[field] - b[field],
};

export default function useSort({ field, type, ascending = true }) {
const reducer = (state, action) => {
if (state.field === action.field) {
return { ...state, ascending: !state.ascending };
}
const fieldType = action.type || 'string';
return { field: action.field, ascending: true, sorter: sorters[fieldType](action.field) };
};

const [sort, setSort] = useReducer(reducer, { field, sorter: sorters[type](field), ascending });

return [sort, setSort];
}
55 changes: 55 additions & 0 deletions src/components/blocs/apikeys/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Modal, ModalContent, ModalTitle } from '@dataesr/react-dsfr';
import { useState } from 'react';
import { Bloc, BlocActionButton, BlocContent, BlocModal, BlocTitle } from '../../bloc';
import useFetch from '../../../hooks/useFetch';
import useNotice from '../../../hooks/useNotice';
import api from '../../../utils/api';
import { deleteError, deleteSuccess, saveError, saveSuccess } from '../../../utils/notice-contents';
import ApiKeysList from './components/apikeys-list';
import ApiKeyForm from './components/form';

export default function ApiKeys() {
const { data, isLoading, error, reload } = useFetch('/admin/api-keys?limit=5000');
const [showModal, setShowModal] = useState(false);
const { notice } = useNotice();
const [newKey, setNewKey] = useState();

const onSave = (body) => api.post('/admin/api-keys', body)
.then((response) => {
notice(saveSuccess);
reload();
setNewKey(response.data.id);
})
.catch(() => notice(saveError))
.finally(() => setShowModal(false));

const onDelete = (id) => api.delete(`/admin/api-keys/${id}`)
.then(() => {
notice(deleteSuccess);
reload();
})
.catch(() => notice(deleteError))
.finally(() => setShowModal(false));
const apiKeys = data?.data?.map((item) => ({ ...item, user: `${item?.user?.firstName} ${item?.user?.lastName}`.trim() })) || [];
return (
<Bloc isLoading={isLoading} error={error} data={data} forceActionDisplay>
<BlocTitle as="h3" look="h4">
Clés API
</BlocTitle>
<BlocActionButton onClick={() => setShowModal((prev) => !prev)}>
Ajouter une clé api
</BlocActionButton>
<BlocContent>
<ApiKeysList data={apiKeys} deleteItem={onDelete} highlight={newKey} />
</BlocContent>
<BlocModal>
<Modal isOpen={showModal} size="lg" hide={() => setShowModal(false)}>
<ModalTitle>Nouvelle Clé API</ModalTitle>
<ModalContent>
<ApiKeyForm onSave={onSave} />
</ModalContent>
</Modal>
</BlocModal>
</Bloc>
);
}
Loading

0 comments on commit 1b7f1e4

Please sign in to comment.