Align apartment core validation errors (#43)
This commit is contained in:
1 parent
878da155bd
commit
b9f5540394
13 files changed
+454
-56
No files matched your search
@@ -22,6 +22,14 @@
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.core-field mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
.field-error {
|
||||
margin: -14px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@media (min-width: 1180px) {
|
||||
.form-page {
|
||||
max-width: 980px;
|
||||
|
||||
@@ -37,33 +37,55 @@
|
||||
<p>Les informations utilisées dans votre portefeuille.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom de l'appartement</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="name"
|
||||
[formField]="apartmentForm.name"
|
||||
placeholder="T2 Paris 15"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prix d'acquisition</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="purchasePrice"
|
||||
[formField]="apartmentForm.purchasePrice"
|
||||
/><span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse complète</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="address"
|
||||
[formField]="apartmentForm.address"
|
||||
placeholder="45 rue des Entrepreneurs, 75015 Paris"
|
||||
/></mat-form-field>
|
||||
<div class="core-field">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom de l'appartement</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="name"
|
||||
[formField]="apartmentForm.name"
|
||||
(input)="fieldChanged.emit('name')"
|
||||
placeholder="T2 Paris 15"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@if (nameError()) {
|
||||
<p class="field-error" role="alert">{{ nameError() }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="core-field">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prix d'acquisition</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="number"
|
||||
id="purchasePrice"
|
||||
[formField]="apartmentForm.purchasePrice"
|
||||
(input)="fieldChanged.emit('purchasePrice')"
|
||||
step="any"
|
||||
/>
|
||||
<span matTextSuffix>€</span></mat-form-field
|
||||
>
|
||||
@if (purchasePriceError()) {
|
||||
<p class="field-error" role="alert">{{ purchasePriceError() }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="core-field full-field">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Adresse complète</mat-label
|
||||
><input
|
||||
matInput
|
||||
type="text"
|
||||
id="address"
|
||||
[formField]="apartmentForm.address"
|
||||
(input)="fieldChanged.emit('address')"
|
||||
placeholder="45 rue des Entrepreneurs, 75015 Paris"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@if (addressError()) {
|
||||
<p class="field-error" role="alert">{{ addressError() }}</p>
|
||||
}
|
||||
</div>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Description ou notes</mat-label
|
||||
><textarea
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('ApartmentForm', () => {
|
||||
|
||||
form.save();
|
||||
|
||||
expect(form.error()).toContain('Veuillez remplir tous les champs obligatoires');
|
||||
expect(form.error()).toContain('Le nom de l’appartement est obligatoire.');
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -70,10 +70,70 @@ describe('ApartmentForm', () => {
|
||||
|
||||
form.save();
|
||||
|
||||
expect(form.error()).toContain('Veuillez remplir tous les champs obligatoires');
|
||||
expect(form.nameError()).toBe('Le nom de l’appartement est obligatoire.');
|
||||
expect(form.addressError()).toBe('L’adresse complète est obligatoire.');
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts every strictly positive purchase price, including a sub-euro value', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
const form = fixture.componentInstance;
|
||||
const saved = vi.fn();
|
||||
form.saved.subscribe(saved);
|
||||
form.apartmentModel.set({
|
||||
name: 'T2',
|
||||
address: '1 rue de Paris',
|
||||
purchasePrice: 0.5,
|
||||
description: '',
|
||||
privateAreas: [],
|
||||
legalProfile: emptyApartmentLegalProfile(),
|
||||
});
|
||||
|
||||
form.save();
|
||||
|
||||
expect(saved).toHaveBeenCalledWith(expect.objectContaining({ purchasePrice: 0.5 }));
|
||||
});
|
||||
|
||||
it('shows backend core errors beside their controls and clears one when it is edited', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
const form = fixture.componentInstance;
|
||||
const changed = vi.fn();
|
||||
form.fieldChanged.subscribe(changed);
|
||||
fixture.componentRef.setInput('saveFieldErrors', [
|
||||
{ field: 'name', code: 'not_blank' },
|
||||
{ field: 'address', code: 'not_blank' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(form.nameError()).toBe('Le nom de l’appartement est obligatoire.');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelectorAll('.field-error')[0].textContent).toContain(
|
||||
'Le nom de l’appartement est obligatoire.',
|
||||
);
|
||||
expect(fixture.nativeElement.querySelectorAll('.field-error')[1].textContent).toContain(
|
||||
'L’adresse complète est obligatoire.',
|
||||
);
|
||||
|
||||
fixture.nativeElement.querySelector('input#name').dispatchEvent(new Event('input'));
|
||||
|
||||
expect(changed).toHaveBeenCalledWith('name');
|
||||
});
|
||||
|
||||
it('keeps unknown server validation codes in the generic save alert', async () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
|
||||
fixture.componentRef.setInput('saveFieldErrors', [{ field: 'name', code: 'unknown' }]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.field-error')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.alert-error').textContent).toContain(
|
||||
'Veuillez corriger les champs signalés.',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a trimmed apartment draft when valid', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentForm);
|
||||
const form = fixture.componentInstance;
|
||||
|
||||
@@ -13,9 +13,17 @@ import {
|
||||
} from '../../services/apartment.service';
|
||||
import { ApartmentLegalProfileForm } from '../apartment-legal-profile-form/apartment-legal-profile-form';
|
||||
import { PrivateAreaEditor } from '../private-area-editor/private-area-editor';
|
||||
import { FieldValidationError } from '../../services/http-error-message';
|
||||
|
||||
const requiredFieldsError =
|
||||
'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).';
|
||||
const nameRequiredError = 'Le nom de l’appartement est obligatoire.';
|
||||
const addressRequiredError = 'L’adresse complète est obligatoire.';
|
||||
const purchasePriceError = 'Le prix d’acquisition doit être strictement positif.';
|
||||
type CoreField = 'name' | 'address' | 'purchasePrice';
|
||||
const serverErrorCodes: Record<CoreField, ReadonlySet<string>> = {
|
||||
name: new Set(['not_blank']),
|
||||
address: new Set(['not_blank']),
|
||||
purchasePrice: new Set(['positive']),
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-apartment-form',
|
||||
@@ -37,8 +45,10 @@ export class ApartmentForm {
|
||||
readonly apartment = input<Apartment | null>(null);
|
||||
readonly saving = input(false);
|
||||
readonly saveError = input('');
|
||||
readonly saveFieldErrors = input<FieldValidationError[]>([]);
|
||||
readonly closed = output<void>();
|
||||
readonly saved = output<Apartment>();
|
||||
readonly fieldChanged = output<string>();
|
||||
|
||||
readonly apartmentModel = signal<Apartment>({
|
||||
name: '',
|
||||
@@ -50,10 +60,10 @@ export class ApartmentForm {
|
||||
});
|
||||
|
||||
readonly apartmentForm = form(this.apartmentModel, (f) => {
|
||||
required(f.name, { message: requiredFieldsError });
|
||||
required(f.address, { message: requiredFieldsError });
|
||||
required(f.purchasePrice, { message: requiredFieldsError });
|
||||
min(f.purchasePrice, 1, { message: requiredFieldsError });
|
||||
required(f.name, { message: nameRequiredError });
|
||||
required(f.address, { message: addressRequiredError });
|
||||
required(f.purchasePrice, { message: purchasePriceError });
|
||||
min(f.purchasePrice, Number.MIN_VALUE, { message: purchasePriceError });
|
||||
min(f.legalProfile.habitableSurface, 0, {
|
||||
message: 'La surface habitable doit être positive ou nulle.',
|
||||
});
|
||||
@@ -87,7 +97,7 @@ export class ApartmentForm {
|
||||
!this.apartmentModel().address.trim()
|
||||
) {
|
||||
const firstError = this.apartmentForm().errorSummary()[0];
|
||||
this.error.set(firstError?.message ?? requiredFieldsError);
|
||||
this.error.set(firstError?.message ?? this.coreFieldError() ?? nameRequiredError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,6 +116,48 @@ export class ApartmentForm {
|
||||
this.apartmentModel.update((apartment) => ({ ...apartment, privateAreas }));
|
||||
}
|
||||
|
||||
nameError(): string {
|
||||
return this.fieldError('name', nameRequiredError, this.apartmentForm.name().touched());
|
||||
}
|
||||
|
||||
addressError(): string {
|
||||
return this.fieldError(
|
||||
'address',
|
||||
addressRequiredError,
|
||||
this.apartmentForm.address().touched(),
|
||||
);
|
||||
}
|
||||
|
||||
purchasePriceError(): string {
|
||||
return this.fieldError(
|
||||
'purchasePrice',
|
||||
purchasePriceError,
|
||||
this.apartmentForm.purchasePrice().touched(),
|
||||
);
|
||||
}
|
||||
|
||||
private coreFieldError(): string | null {
|
||||
return this.nameError() || this.addressError() || this.purchasePriceError() || null;
|
||||
}
|
||||
|
||||
private fieldError(path: CoreField, clientMessage: string, touched: boolean): string {
|
||||
if (
|
||||
touched &&
|
||||
((path === 'name' && !this.apartmentModel().name.trim()) ||
|
||||
(path === 'address' && !this.apartmentModel().address.trim()) ||
|
||||
(path === 'purchasePrice' && this.apartmentModel().purchasePrice <= 0))
|
||||
) {
|
||||
return clientMessage;
|
||||
}
|
||||
return this.hasKnownServerError(path) ? clientMessage : '';
|
||||
}
|
||||
|
||||
private hasKnownServerError(field: CoreField): boolean {
|
||||
return this.saveFieldErrors().some(
|
||||
(error) => error.field === field && serverErrorCodes[field].has(error.code),
|
||||
);
|
||||
}
|
||||
|
||||
private resetForm(apartment: Apartment | null): void {
|
||||
this.apartmentModel.set({
|
||||
id: apartment?.id,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
[apartment]="editingApartment()"
|
||||
[saving]="workspace.apartmentSaving(editingApartment()?.id)"
|
||||
[saveError]="workspace.apartmentSaveError(editingApartment()?.id)"
|
||||
[saveFieldErrors]="workspace.apartmentSaveFieldErrors(editingApartment()?.id)"
|
||||
(fieldChanged)="workspace.clearApartmentSaveFieldError($event, editingApartment()?.id)"
|
||||
(closed)="closeForm()"
|
||||
(saved)="saveApartment($event)"
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,9 @@ describe('ApartmentsPage', () => {
|
||||
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
|
||||
apartmentSaving: vi.fn(() => false),
|
||||
apartmentSaveError: vi.fn(() => ''),
|
||||
apartmentSaveFieldErrors: vi.fn(() => []),
|
||||
clearApartmentSaveError: vi.fn(),
|
||||
clearApartmentSaveFieldError: vi.fn(),
|
||||
deletingApartmentIds: signal<ReadonlySet<string>>(new Set()),
|
||||
apartmentDeleteErrors: signal<Record<string, string>>({}),
|
||||
loadApartments: () => undefined,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { saveFailure } from './http-error-message';
|
||||
|
||||
describe('saveFailure', () => {
|
||||
const fallback = 'Impossible d’enregistrer cet appartement.';
|
||||
|
||||
it('keeps only the structured validation contract', () => {
|
||||
expect(
|
||||
saveFailure(
|
||||
{
|
||||
status: 400,
|
||||
error: {
|
||||
code: 'validation_failed',
|
||||
errors: [{ field: 'name', code: 'not_blank' }],
|
||||
},
|
||||
},
|
||||
fallback,
|
||||
),
|
||||
).toEqual({
|
||||
message: 'Veuillez corriger les champs signalés.',
|
||||
fieldErrors: [{ field: 'name', code: 'not_blank' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a safe fallback for unknown, network, and unexpected failures', () => {
|
||||
expect(
|
||||
saveFailure({ status: 500, error: { message: 'internal detail' } }, fallback),
|
||||
).toEqual({
|
||||
message: fallback,
|
||||
fieldErrors: [],
|
||||
});
|
||||
expect(saveFailure({ status: 0 }, fallback)).toEqual({
|
||||
message: fallback,
|
||||
fieldErrors: [],
|
||||
});
|
||||
expect(saveFailure({}, fallback)).toEqual({ message: fallback, fieldErrors: [] });
|
||||
});
|
||||
|
||||
it('keeps a safe conflict message', () => {
|
||||
expect(
|
||||
saveFailure(
|
||||
{ status: 409, error: { message: 'Cet appartement est référencé.' } },
|
||||
fallback,
|
||||
),
|
||||
).toEqual({ message: 'Cet appartement est référencé.', fieldErrors: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,13 @@
|
||||
export interface FieldValidationError {
|
||||
field: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface SaveFailure {
|
||||
message: string;
|
||||
fieldErrors: FieldValidationError[];
|
||||
}
|
||||
|
||||
export function httpErrorMessage(error: unknown, fallback: string): string {
|
||||
return typeof error === 'object' &&
|
||||
error !== null &&
|
||||
@@ -9,3 +19,49 @@ export function httpErrorMessage(error: unknown, fallback: string): string {
|
||||
? error.error.message
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function saveFailure(error: unknown, fallback: string): SaveFailure {
|
||||
const validationErrors = validationErrorsFrom(error);
|
||||
if (validationErrors) {
|
||||
return {
|
||||
message: 'Veuillez corriger les champs signalés.',
|
||||
fieldErrors: validationErrors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: isConflict(error) ? httpErrorMessage(error, fallback) : fallback,
|
||||
fieldErrors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function validationErrorsFrom(error: unknown): FieldValidationError[] | null {
|
||||
if (
|
||||
typeof error !== 'object' ||
|
||||
error === null ||
|
||||
!('error' in error) ||
|
||||
typeof error.error !== 'object' ||
|
||||
error.error === null ||
|
||||
!('code' in error.error) ||
|
||||
error.error.code !== 'validation_failed' ||
|
||||
!('errors' in error.error) ||
|
||||
!Array.isArray(error.error.errors)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldErrors = error.error.errors.filter(
|
||||
(fieldError): fieldError is FieldValidationError =>
|
||||
typeof fieldError === 'object' &&
|
||||
fieldError !== null &&
|
||||
'field' in fieldError &&
|
||||
typeof fieldError.field === 'string' &&
|
||||
'code' in fieldError &&
|
||||
typeof fieldError.code === 'string',
|
||||
);
|
||||
return fieldErrors.length === error.error.errors.length ? fieldErrors : null;
|
||||
}
|
||||
|
||||
function isConflict(error: unknown): error is { status: number } {
|
||||
return typeof error === 'object' && error !== null && 'status' in error && error.status === 409;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { finalize, Observable } from 'rxjs';
|
||||
import { httpErrorMessage } from './http-error-message';
|
||||
import { FieldValidationError, httpErrorMessage, saveFailure } from './http-error-message';
|
||||
import { withoutRecordKey, withoutSetValue } from './immutable-state';
|
||||
|
||||
export interface LoadState {
|
||||
@@ -20,6 +20,7 @@ export class WorkspaceResource<T extends { id?: string }> {
|
||||
readonly loadState = signal<LoadState>({ loaded: false, loading: false, error: '' });
|
||||
readonly savingKeys = signal<ReadonlySet<string>>(new Set());
|
||||
readonly saveErrors = signal<Record<string, string>>({});
|
||||
readonly saveFieldErrors = signal<Record<string, FieldValidationError[]>>({});
|
||||
readonly deletingIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly deleteErrors = signal<Record<string, string>>({});
|
||||
private revision = 0;
|
||||
@@ -47,6 +48,7 @@ export class WorkspaceResource<T extends { id?: string }> {
|
||||
if (this.savingKeys().has(key)) return;
|
||||
this.savingKeys.update((keys) => new Set(keys).add(key));
|
||||
this.saveErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.saveFieldErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
request
|
||||
.pipe(finalize(() => this.savingKeys.update((keys) => withoutSetValue(keys, key))))
|
||||
.subscribe({
|
||||
@@ -55,8 +57,14 @@ export class WorkspaceResource<T extends { id?: string }> {
|
||||
onSuccess?.();
|
||||
this.load();
|
||||
},
|
||||
error: () =>
|
||||
this.saveErrors.update((errors) => ({ ...errors, [key]: this.messages.save })),
|
||||
error: (error) => {
|
||||
const failure = saveFailure(error, this.messages.save);
|
||||
this.saveErrors.update((errors) => ({ ...errors, [key]: failure.message }));
|
||||
this.saveFieldErrors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: failure.fieldErrors,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,6 +103,17 @@ export class WorkspaceResource<T extends { id?: string }> {
|
||||
|
||||
clearSaveError(key: string): void {
|
||||
this.saveErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.saveFieldErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
}
|
||||
|
||||
clearSaveFieldError(key: string, field: string): void {
|
||||
const fieldErrors = this.saveFieldErrors()[key];
|
||||
if (!fieldErrors?.some((error) => error.field === field)) return;
|
||||
const remaining = fieldErrors.filter((error) => error.field !== field);
|
||||
this.saveFieldErrors.update((errors) =>
|
||||
remaining.length ? { ...errors, [key]: remaining } : withoutRecordKey(errors, key),
|
||||
);
|
||||
if (!remaining.length) this.saveErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
}
|
||||
|
||||
private handleLoadError(revision: number): void {
|
||||
|
||||
@@ -298,6 +298,36 @@ describe('WorkspaceService', () => {
|
||||
expect(service.apartmentSaving(secondApartment.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves validation fields and clears only the corrected apartment field', () => {
|
||||
apartments.updateApartment.mockReturnValueOnce(
|
||||
throwError(() => ({
|
||||
status: 400,
|
||||
error: {
|
||||
code: 'validation_failed',
|
||||
errors: [
|
||||
{ field: 'name', code: 'not_blank' },
|
||||
{ field: 'address', code: 'not_blank' },
|
||||
],
|
||||
},
|
||||
})),
|
||||
);
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
|
||||
service.saveApartment(apartment, apartment.id);
|
||||
|
||||
expect(service.apartmentSaveFieldErrors(apartment.id)).toEqual([
|
||||
{ field: 'name', code: 'not_blank' },
|
||||
{ field: 'address', code: 'not_blank' },
|
||||
]);
|
||||
service.clearApartmentSaveFieldError('name', apartment.id);
|
||||
expect(service.apartmentSaveFieldErrors(apartment.id)).toEqual([
|
||||
{ field: 'address', code: 'not_blank' },
|
||||
]);
|
||||
expect(service.apartmentSaveError(apartment.id)).toBe(
|
||||
'Veuillez corriger les champs signalés.',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a confirmed deletion locally before reconciliation finishes', () => {
|
||||
const deleteResponse = new Subject<void>();
|
||||
const refreshResponse = new Subject<(typeof apartment)[]>();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { RentPayment, RentPaymentService } from './rent-payment.service';
|
||||
import { Tenant, TenantAttachment, TenantService } from './tenant.service';
|
||||
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
|
||||
import { WorkspaceResource } from './workspace-resource';
|
||||
import { FieldValidationError } from './http-error-message';
|
||||
|
||||
const newResourceKey = 'new';
|
||||
|
||||
@@ -108,10 +109,18 @@ export class WorkspaceService {
|
||||
return this.apartmentResource.saveErrors()[this.resourceKey(id)] ?? '';
|
||||
}
|
||||
|
||||
apartmentSaveFieldErrors(id?: string): FieldValidationError[] {
|
||||
return this.apartmentResource.saveFieldErrors()[this.resourceKey(id)] ?? [];
|
||||
}
|
||||
|
||||
clearApartmentSaveError(id?: string): void {
|
||||
this.apartmentResource.clearSaveError(this.resourceKey(id));
|
||||
}
|
||||
|
||||
clearApartmentSaveFieldError(field: string, id?: string): void {
|
||||
this.apartmentResource.clearSaveFieldError(this.resourceKey(id), field);
|
||||
}
|
||||
|
||||
deleteApartment(id: string): void {
|
||||
this.requestConfirmation(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user