Skip to content

Commit

Permalink
#106 - CRUD for users frontend (#108)
Browse files Browse the repository at this point in the history
* create basic crud layout

* refactor: decouple from 'demo' and '87-login-page-improvements' branches

* fix: duration input is cut when expanding the test

* fix: duration can be set to number the exceeds the max limit

* fix: remove copying for multiple quizzes

* fix: delete button deletes all questions

* style: answer check button color and position

* style: create new answers with empty content

* build: update packages

* fix: linting

* chore: merge button logic

* fix: remove time limits for datepicker

* fix: error message is not fully visible

* fix: `schedule_at` error message contains untranslated word 'now'

fix: make fix

* fix: wrong interpritation of time after quiz update

fix

* style: add animation to quizzes occurence

* format: remove unused import

* style: add animations for icon-buttons

* apply suggestion: change 'Stworzony' na 'Utworzony'

* fix: sorting isn' t preserved after page reload

* fix: deletion alert doesn't close when clicking "Usuń"

* chore: preserve state and scroll in request components by default

* fix: seeder creates quizzes that can't exist

* feat: add placeholders for empty textareas

* fix: "Zaproś uczestników" redirects to entry that doesn't yet exist

* fix: typo in "Zaproś uczestników"

* feat: add sorting by modification

* feat: make pre-publish validation message more specific

* fix: publish button doesn't switch its state

* feat: add " - kopia" to the end of the tile of a copied quiz

* format: `"` to `'`

* fix: sorting state is preserved between sessions

* style: change 'Nie można oddać testu.' to 'Nie można opublikować testu.'

* refactor: suggestions from code review

* chore: add archieved quizzes to seeder

* build: update packages

* feat: add dropdown pointer positioning

* fix: quiz title underline

* fix: duration input doesn't resize fully

* feat: error handle empty question

* refactor: link button styling

* create warning message box

* move sorting logic to a separate helper

* fix: cloning doesn't add data to database

* fix: error highliting on question deletion reappears on the next question

* feat: automatically remove empty answers

* fix: revert update logic

* create basic crud for schools

* simplify quizzes page

* fix linter errors

* fix code style

* rename value to address

* add regon to school

* fix code style

* add lintf

* create mobile version

* add newItem slot

* remove limit from school controller

* create CrudInput

* fix InputWrapper

* remove force-full-screen-nav

* add prop pointer-position to Dropdown

* change import button icon

* improve code style

* add comments explaining textarea height reset

* Replace ButtonFrame with Button

* allow manual implementation of the 'New Item' button

* improve ButtonFrame

* fix code style

* move resize none to vDynamicTextAreaHeight.ts

* fix InputWrapper

* add padding to ButtonFrame

* fix linter errors

* consider placeholder when calculating input width

* add fix-all

* update phony

* implement frontend for UsersPanel.vue

* fix new item mode

* fix CrudInput

* fix linter errors

* Update resources/js/Pages/Admin/SchoolsPanel.vue

Co-authored-by: Ewelina Skrzypacz <[email protected]>

* add regon validator

* fix school seeder

* fix code style

* add zip-code input

* validate the length of the inputs

* create icon button

* create SortHelper
Co-authored-by: Dominikaninn <[email protected]>

* add pagination to CRUDPage

* fix tests

* fix abort mock

* fix test

* fix abort mock

* fix test

* add information about the number of fetched schools

* make filter case-insensitive

* debounce searchbar

* fix code style

* fix sort test

* add icon button

* rename QuizLayout to QuizPage

* fix unsupported_field translation

* fix sorter href

* create no content component

* fix crud cancel button

* rename ExapnsionToggleDynamicIcon to ExpansionToggleDynamicIcon

* hide delete button if quiz is locked

* change user name to firstname

* fix code style

* fix NoContent

* fix typo

* Update resources/js/Pages/Admin/Quizzes.vue

Co-authored-by: Ewelina Skrzypacz <[email protected]>

* update rspo api url

* style: change searchbar design

* fix quiz filtering

* reverse default sorting order

* fix input size

* update sort option labels

* fix typo

* hide pagination button when there is no content

* fix test

* pagination draft

* add pagination to user panel

* fix sorting by school

* implement admin panel

* fix user tests

* fix code style

* fix admin tests

* fix console errors

* Apply suggestions from code review

Co-authored-by: Ewelina Skrzypacz <[email protected]>

* Fix not displaying username validation errors

* fix tests

* fix: Clear inputs not saving correctly on edit forms

* fix code style

* limit password to 255 characters

* refactor admin creation to auto-assign school and fix password update logic

* clear inputs after successful save request

* hide admin school in school input

* Apply suggestions from code review

Co-authored-by: Mykyta Mykulskyi <[email protected]>

* fix input animations

* fix user page title

* add gap-2 to user inputs

* remove text-center from crud-school-input

* change crud-school-input border color

* replace trash icon with UserMinusIcon

* remove school_id from user resource

* remove mobile-nav

* remove mobile-nav prop

* add format prop to CrudInput

* replace inputs with CrudInput

* fix code style

* remove default title implementation

* fix code style

* remove unnecessary default value reset after success update

* remove unused import

* remove randomness from test

* make newItemData partial

---------

Co-authored-by: Mykyta Mykulskyi <[email protected]>
Co-authored-by: Mik <Mykyta Mykulskyi>
Co-authored-by: Mykyta Mykulskyi <[email protected]>
Co-authored-by: Ewelina Skrzypacz <[email protected]>
  • Loading branch information
4 people authored Dec 12, 2024
1 parent 1a20886 commit 8ec02ac
Show file tree
Hide file tree
Showing 27 changed files with 730 additions and 200 deletions.
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ fix:
lintf:
@docker compose --file ${DOCKER_COMPOSE_FILE} exec --user "${CURRENT_USER_ID}:${CURRENT_USER_GROUP_ID}" ${DOCKER_COMPOSE_APP_CONTAINER} bash -c 'npm run lintf'

fix-all: fix lintf

analyse:
@docker compose --file ${DOCKER_COMPOSE_FILE} exec --user "${CURRENT_USER_ID}:${CURRENT_USER_GROUP_ID}" ${DOCKER_COMPOSE_APP_CONTAINER} bash -c 'composer analyse'

Expand All @@ -69,4 +71,4 @@ queue:
create-test-db:
@docker compose --file ${DOCKER_COMPOSE_FILE} exec ${DOCKER_COMPOSE_DATABASE_CONTAINER} bash -c 'createdb --username=${DATABASE_USERNAME} ${TEST_DATABASE_NAME} &> /dev/null && echo "Created database for tests (${TEST_DATABASE_NAME})." || echo "Database for tests (${TEST_DATABASE_NAME}) exists."'

.PHONY: init check-env-file build run stop restart shell shell-root test fix create-test-db queue analyse dev
.PHONY: init check-env-file build run stop restart shell shell-root test test-specific fix lintf fix-all create-test-db queue analyse dev
57 changes: 36 additions & 21 deletions app/Http/Controllers/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,43 @@

namespace App\Http\Controllers;

use App\Http\Requests\Auth\RegisterUserRequest;
use App\Http\Requests\UserRequest;
use App\Helpers\SortHelper;
use App\Http\Requests\AdminRequest;
use App\Http\Requests\Auth\RegisterAdminRequest;
use App\Http\Resources\UserResource;
use App\Models\School;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Inertia\Response;

class AdminController extends Controller
{
public function index(): Response
public function index(SortHelper $sorter): Response
{
$users = User::query()->role("admin")->with("school")->orderBy("id")->get();
$users = User::query()->role("admin")->with("school");

$query = $sorter->sort($users, ["id", "email", "updated_at", "created_at"], ["firstname"]);
$query = $this->sortByName($query, $sorter);
$query = $sorter->search($query, "firstname");

return Inertia::render("Admin/AdminsPanel", [
"users" => UserResource::collection($users),
"users" => UserResource::collection($query->paginate()),
]);
}

public function create(): Response
{
return Inertia::render("Admin/CreateAdmin");
}

public function store(RegisterUserRequest $request): RedirectResponse
public function store(RegisterAdminRequest $request): RedirectResponse
{
$school = School::query()->where(["is_admin_school" => true])->firstOrFail();
$userExists = User::query()->where("email", $request->email)->exists();

if (!$userExists) {
$user = new User($request->validated());
$user->password = Hash::make($request->password);
$user->school()->associate($school);
$user->save();
$user->syncRoles("admin");
event(new Registered($user));
Expand All @@ -48,22 +51,22 @@ public function store(RegisterUserRequest $request): RedirectResponse
->with("success", "Administrator został utworzony pomyślnie.");
}

public function edit(User $user): Response
public function update(AdminRequest $request, User $user): RedirectResponse
{
$this->authorize("update", $user);
$data = $request->validated();

return Inertia::render("Admin/EditAdmin", [
"user" => new UserResource($user),
"schools" => School::all(),
]);
}
if (!$request->has("password")) {
unset($data["password"]);
}

public function update(UserRequest $request, User $user): RedirectResponse
{
$this->authorize("update", $user);
$data = $request->validated();
$user->update($data);

if ($request->has("password")) {
$user->password = Hash::make($request->validated("password"));
$user->save();
}

return redirect()
->route("admin.admins.index")
->with("success", "Administrator zaktualizowany pomyślnie.");
Expand All @@ -76,4 +79,16 @@ public function destroy(User $user): RedirectResponse

return redirect()->back();
}

private function sortByName(Builder $query, SortHelper $sorter): Builder
{
[$field, $order] = $sorter->getSortParameters();

if ($field === "firstname") {
return $query->orderBy("firstname", $order)
->orderBy("surname", $order);
}

return $query;
}
}
7 changes: 5 additions & 2 deletions app/Http/Controllers/InviteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ private function sortBySchool(Builder $query, SortHelper $sorter): Builder
{
[$field, $order] = $sorter->getSortParameters();

if ($field === "schoolId") {
return $query->orderBy("school.name", $order);
if ($field === "school") {
return $query
->leftJoin("schools", "users.school_id", "=", "schools.id")
->orderBy("schools.name", $order)
->select("users.*");
}

return $query;
Expand Down
62 changes: 57 additions & 5 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,37 @@

namespace App\Http\Controllers;

use App\Helpers\SortHelper;
use App\Http\Requests\UserRequest;
use App\Http\Resources\SchoolResource;
use App\Http\Resources\UserResource;
use App\Models\School;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class UserController extends Controller
{
public function index(Request $request): Response
public function index(SortHelper $sorter, Request $request): Response
{
$users = User::query()->role("user")->with("school")->orderBy("id")->get();
$users = User::query()->role("user")->with("school");
$schools = School::query()
->where("is_admin_school", false)
->orderBy("name")
->get();

$query = $sorter->sort($users, ["id", "email", "updated_at", "created_at"], ["firstname", "school"]);
$query = $this->filterAnonymizedUsers($query, $request);
$query = $this->sortBySchool($query, $sorter);
$query = $this->sortByName($query, $sorter);
$query = $sorter->search($query, "firstname");

return Inertia::render("Admin/UsersPanel", [
"users" => UserResource::collection($users),
"users" => UserResource::collection($query->paginate()),
"schools" => SchoolResource::collection($schools),
]);
}

Expand All @@ -31,7 +46,7 @@ public function update(UserRequest $request, User $user): RedirectResponse

return redirect()
->route("admin.users.index")
->with("success", "Użytkownik zaktualizowany pomyślnie.");
->with("status", "Użytkownik zaktualizowany pomyślnie.");
}

public function anonymize(User $user): RedirectResponse
Expand All @@ -46,6 +61,43 @@ public function anonymize(User $user): RedirectResponse

return redirect()
->back()
->with("success", "Dane użytkownika zostały zanonimizowane.");
->with("status", "Dane użytkownika zostały zanonimizowane.");
}

private function sortByName(Builder $query, SortHelper $sorter): Builder
{
[$field, $order] = $sorter->getSortParameters();

if ($field === "firstname") {
return $query->orderBy("firstname", $order)
->orderBy("surname", $order);
}

return $query;
}

private function sortBySchool(Builder $query, SortHelper $sorter): Builder
{
[$field, $order] = $sorter->getSortParameters();

if ($field === "school") {
return $query->join("schools", "users.school_id", "=", "schools.id")
->orderBy("schools.city", $order)
->orderBy("schools.name", $order)
->select("users.*");
}

return $query;
}

private function filterAnonymizedUsers(Builder $query, Request $request): Builder
{
$showAnonymized = $request->query("anonymized", "false") === "true";

if (!$showAnonymized) {
return $query->where("is_anonymized", false);
}

return $query;
}
}
25 changes: 25 additions & 0 deletions app/Http/Requests/AdminRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class AdminRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
"email" => ["required", "string", "email", "max:255", "unique:users,email," . $this->user->id],
"firstname" => ["required", "string", "max:255"],
"surname" => ["required", "string", "max:255"],
"password" => ["nullable", "string", "min:8", "max:255"],
];
}
}
2 changes: 1 addition & 1 deletion app/Http/Requests/Auth/AuthenticateSessionRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public function rules(): array
{
return [
"email" => ["required", "email", "max:255"],
"password" => ["required", "string"],
"password" => ["required", "string", "min:8", "max:255"],
];
}
}
25 changes: 25 additions & 0 deletions app/Http/Requests/Auth/RegisterAdminRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;

class RegisterAdminRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
"email" => ["required", "string", "email:rfc,dns", "max:255"],
"firstname" => ["required", "string", "max:255"],
"surname" => ["required", "string", "max:255"],
"password" => ["required", "string", "min:8", "max:255"],
];
}
}
2 changes: 1 addition & 1 deletion app/Http/Requests/Auth/RegisterUserRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function rules(): array
"email" => ["required", "string", "email:rfc,dns", "max:255"],
"firstname" => ["required", "string", "max:255"],
"surname" => ["required", "string", "max:255"],
"password" => ["required", "string", "min:8"],
"password" => ["required", "string", "min:8", "max:255"],
"school_id" => ["required", "integer", "exists:schools,id", new IsSchoolValidForRegularUsers()],
];
}
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Requests/Auth/ResetPasswordRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public function rules(): array
return [
"token" => ["required"],
"email" => ["required", "email", "max:255"],
"password" => ["required", "min:8", "confirmed:password_confirmation"],
"password" => ["required", "min:8", "max:255", "confirmed:password_confirmation"],
];
}
}
2 changes: 2 additions & 0 deletions app/Http/Resources/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public function toArray(Request $request): array
"isAnonymized" => $this->is_anonymized,
"isAdmin" => $this->hasRole("admin"),
"isSuperAdmin" => $this->hasRole("super_admin"),
"createdAt" => $this->created_at,
"updatedAt" => $this->updated_at,
];
}
}
14 changes: 14 additions & 0 deletions database/factories/UserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

use function fake;

/**
* @extends Factory<User>
*/
Expand Down Expand Up @@ -58,4 +60,16 @@ public function unverifiedUser(): static
];
});
}

public function anonymized(): static
{
return $this->state(function (array $attributes) {
return [
"firstname" => "Anonymous",
"surname" => "User",
"email" => "anonymous" . fake()->unique()->randomNumber() . "@email",
"is_anonymized" => true,
];
});
}
}
14 changes: 11 additions & 3 deletions resources/js/Helpers/vDynamicInputWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ function initDynamicWidthCalc(input:HTMLInputElement & { _calculateDynamicWidth:
}

function calculateDynamicWidth(input:HTMLInputElement, binding?:DirectiveBinding<boolean>){
if (binding?.value === false) return
if (binding?.value === false) {
return
}

const context = canvas.getContext('2d')
if (!context) return

if (!context) {
return
}

const { fontWeight, fontSize, fontFamily } = window.getComputedStyle(input)
context.font = `${fontWeight} ${fontSize} ${fontFamily}`
const width = `${context.measureText(input.value).width}px`
const width = `${context.measureText(input.value || input.placeholder).width}px`

input.style.width = `clamp(1.1rem,${width},100%)`
}

Expand Down
Loading

0 comments on commit 8ec02ac

Please sign in to comment.