diff --git a/app/Domain/Integrations/Controllers/IntegrationController.php b/app/Domain/Integrations/Controllers/IntegrationController.php index 84a896e42..8bc41d0b3 100644 --- a/app/Domain/Integrations/Controllers/IntegrationController.php +++ b/app/Domain/Integrations/Controllers/IntegrationController.php @@ -16,6 +16,7 @@ use App\Domain\Integrations\FormRequests\StoreIntegrationRequest; use App\Domain\Integrations\FormRequests\StoreIntegrationUrlRequest; use App\Domain\Integrations\FormRequests\UpdateContactInfoRequest; +use App\Domain\Integrations\FormRequests\UpdateIntegrationOrganizersRequest; use App\Domain\Integrations\FormRequests\UpdateIntegrationRequest; use App\Domain\Integrations\FormRequests\UpdateIntegrationUrlsRequest; use App\Domain\Integrations\FormRequests\UpdateOrganizationRequest; @@ -292,12 +293,27 @@ public function updateOrganization(string $id, UpdateOrganizationRequest $reques ); } + public function updateOrganizers(string $integrationId, UpdateIntegrationOrganizersRequest $request): RedirectResponse + { + $integration = $this->integrationRepository->getById(Uuid::fromString($integrationId)); + + $organizerIds = collect($integration->organizers())->map(fn (Organizer $organizer) => $organizer->organizerId); + $newOrganizers = array_filter( + OrganizerMapper::mapUpdateOrganizers($request, $integrationId), + fn (Organizer $organizer) => !in_array($organizer->organizerId, $organizerIds->toArray(), true) + ); + + $this->organizerRepository->create(...$newOrganizers); + + return Redirect::back(); + } + public function deleteOrganizer(string $integrationId, string $organizerId): RedirectResponse { $this->organizerRepository->delete(new Organizer( Uuid::uuid4(), Uuid::fromString($integrationId), - Uuid::fromString($organizerId) + $organizerId )); return Redirect::back(); @@ -313,7 +329,7 @@ public function requestActivation(string $id, RequestActivationRequest $request) $organization = OrganizationMapper::mapActivationRequest($request); $this->organizationRepository->save($organization); - $organizers = OrganizerMapper::map($request, $id); + $organizers = OrganizerMapper::mapActivationRequest($request, $id); $this->organizerRepository->create(...$organizers); $this->integrationRepository->requestActivation(Uuid::fromString($id), $organization->id, $request->input('coupon')); @@ -388,7 +404,7 @@ private function guardUserIsContact(Request $request, string $integrationId): ?R public function getIntegrationOrganizersWithTestOrganizer(Integration $integration): Collection { - $organizerIds = collect($integration->organizers())->map(fn (Organizer $organizer) => $organizer->organizerId->toString()); + $organizerIds = collect($integration->organizers())->map(fn (Organizer $organizer) => $organizer->organizerId); $uitpasOrganizers = $this->searchClient->findUiTPASOrganizers(...$organizerIds)->getMember()?->getItems(); $organizers = collect($uitpasOrganizers)->map(function (SapiOrganizer $organizer) { diff --git a/app/Domain/Integrations/FormRequests/RequestActivationRequest.php b/app/Domain/Integrations/FormRequests/RequestActivationRequest.php index 265009220..da6de21c8 100644 --- a/app/Domain/Integrations/FormRequests/RequestActivationRequest.php +++ b/app/Domain/Integrations/FormRequests/RequestActivationRequest.php @@ -24,10 +24,8 @@ public function rules(): array { $rules = collect([ ...(new CreateOrganizationRequest())->rules(), + ...(new UpdateIntegrationOrganizersRequest())->rules(), 'coupon' => ['nullable', 'string', 'max:255'], - 'organizers' => ['required','array'], - 'organizers.*.name' => ['required', 'string'], - 'organizers.*.id' => ['required', 'string'], ]); if (!$this->isAccountingInfoRequired() || $this->isUITPAS()) { diff --git a/app/Domain/Integrations/FormRequests/UpdateIntegrationOrganizersRequest.php b/app/Domain/Integrations/FormRequests/UpdateIntegrationOrganizersRequest.php new file mode 100644 index 000000000..cabc71271 --- /dev/null +++ b/app/Domain/Integrations/FormRequests/UpdateIntegrationOrganizersRequest.php @@ -0,0 +1,19 @@ + ['required', 'array'], + 'organizers.*.name' => ['required', 'string'], + 'organizers.*.id' => ['required', 'string'], + ]; + } +} diff --git a/app/Domain/Integrations/Mappers/OrganizerMapper.php b/app/Domain/Integrations/Mappers/OrganizerMapper.php index bbd35facc..a1f52d51a 100644 --- a/app/Domain/Integrations/Mappers/OrganizerMapper.php +++ b/app/Domain/Integrations/Mappers/OrganizerMapper.php @@ -5,7 +5,9 @@ namespace App\Domain\Integrations\Mappers; use App\Domain\Integrations\FormRequests\RequestActivationRequest; +use App\Domain\Integrations\FormRequests\UpdateIntegrationOrganizersRequest; use App\Domain\Integrations\Organizer; +use Illuminate\Http\Request; use Ramsey\Uuid\Uuid; final class OrganizerMapper @@ -13,7 +15,7 @@ final class OrganizerMapper /** * @return Organizer[] */ - public static function map(RequestActivationRequest $request, string $id): array + public static function map(Request $request, string $id): array { /** * @var Organizer[] $organizers @@ -24,10 +26,20 @@ public static function map(RequestActivationRequest $request, string $id): array $organizers[] = new Organizer( Uuid::uuid4(), Uuid::fromString($id), - Uuid::fromString($organizer['id']) + $organizer['id'] ); } return $organizers; } + + public static function mapUpdateOrganizers(UpdateIntegrationOrganizersRequest $request, string $id): array + { + return self::map($request, $id); + } + + public static function mapActivationRequest(RequestActivationRequest $request, string $id): array + { + return self::map($request, $id); + } } diff --git a/app/Domain/Integrations/Models/OrganizerModel.php b/app/Domain/Integrations/Models/OrganizerModel.php index d4c267835..077f358e6 100644 --- a/app/Domain/Integrations/Models/OrganizerModel.php +++ b/app/Domain/Integrations/Models/OrganizerModel.php @@ -23,7 +23,7 @@ public function toDomain(): Organizer return new Organizer( Uuid::fromString($this->id), Uuid::fromString($this->integration_id), - Uuid::fromString($this->organizer_id), + $this->organizer_id, ); } } diff --git a/app/Domain/Integrations/Organizer.php b/app/Domain/Integrations/Organizer.php index 199b493ef..005cdcfc3 100644 --- a/app/Domain/Integrations/Organizer.php +++ b/app/Domain/Integrations/Organizer.php @@ -11,7 +11,7 @@ public function __construct( public UuidInterface $id, public UuidInterface $integrationId, - public UuidInterface $organizerId + public string $organizerId ) { } } diff --git a/app/Domain/Integrations/Repositories/EloquentOrganizerRepository.php b/app/Domain/Integrations/Repositories/EloquentOrganizerRepository.php index c102a698a..25cd75434 100644 --- a/app/Domain/Integrations/Repositories/EloquentOrganizerRepository.php +++ b/app/Domain/Integrations/Repositories/EloquentOrganizerRepository.php @@ -17,7 +17,7 @@ public function create(Organizer ...$organizers): void OrganizerModel::query()->create([ 'id' => $organizer->id->toString(), 'integration_id' => $organizer->integrationId->toString(), - 'organizer_id' => $organizer->organizerId->toString(), + 'organizer_id' => $organizer->organizerId, ]); } }); @@ -26,7 +26,7 @@ public function create(Organizer ...$organizers): void public function delete(Organizer $organizer): void { OrganizerModel::query() - ->where('organizer_id', $organizer->organizerId->toString()) + ->where('organizer_id', $organizer->organizerId) ->where('integration_id', $organizer->integrationId->toString()) ->delete(); } diff --git a/app/Nova/Actions/ActivateUitpasIntegration.php b/app/Nova/Actions/ActivateUitpasIntegration.php index 016218e45..988b039aa 100644 --- a/app/Nova/Actions/ActivateUitpasIntegration.php +++ b/app/Nova/Actions/ActivateUitpasIntegration.php @@ -45,12 +45,11 @@ public function handle(ActionFields $fields, Collection $integrations): ActionRe $organizerArray = array_map('trim', explode(',', $organizers)); foreach ($organizerArray as $organizer) { - $organizerId = Uuid::fromString($organizer); $this->organizerRepository->create( new Organizer( Uuid::uuid4(), Uuid::fromString($integration->id), - $organizerId + $organizer ) ); } diff --git a/app/Nova/Actions/AddOrganizer.php b/app/Nova/Actions/AddOrganizer.php index 36463f3d5..dcfd66e7b 100644 --- a/app/Nova/Actions/AddOrganizer.php +++ b/app/Nova/Actions/AddOrganizer.php @@ -35,13 +35,12 @@ public function handle(ActionFields $fields, Collection $integrations): ActionRe /** @var string $organizationIdAsString */ $organizationIdAsString = $fields->get('organizer_id'); - $organizationId = Uuid::fromString($organizationIdAsString); $this->organizerRepository->create( new Organizer( Uuid::uuid4(), Uuid::fromString($integration->id), - $organizationId + $organizationIdAsString ) ); diff --git a/database/migrations/2024_07_10_120617_update_organizer_id_to_text.php b/database/migrations/2024_07_10_120617_update_organizer_id_to_text.php new file mode 100644 index 000000000..3515960fd --- /dev/null +++ b/database/migrations/2024_07_10_120617_update_organizer_id_to_text.php @@ -0,0 +1,29 @@ +char('organizer_id', 36)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('organizers', function (Blueprint $table) { + $table->uuid('organizer_id')->change(); + }); + } +}; diff --git a/resources/translations/en.json b/resources/translations/en.json index 96b92e6a6..5181397f8 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -233,6 +233,9 @@ "title": "Organizers", "description": "Below you see an overview of the UiTdatabank organizers for which you can execute actions in the UiTPAS API.", "add": "Add organizer", + "update_dialog": { + "question": "Select the UiTdatabank organizers for which you can execute actions in UiTPAS." + }, "delete_dialog": { "title": "Remove organizer", "question": "Are you sure you want to remove {{name}} from your integration?" diff --git a/resources/translations/nl.json b/resources/translations/nl.json index d2db5a4a2..ac9dc7675 100644 --- a/resources/translations/nl.json +++ b/resources/translations/nl.json @@ -233,6 +233,9 @@ "title": "Organisaties", "description": "Hieronder vind je een overzicht van de UiTdatabank organisaties waarvoor je acties kan uitvoeren in de UiTPAS API.", "add": "Organisatie toevoegen", + "update_dialog": { + "question": "Geef de UiTdatabank-organisaties op waarvoor je acties in UiTPAS wilt uitvoeren." + }, "delete_dialog": { "title": "Organisatie verwijderen", "question": "Ben je zeker dat je {{name}} wilt verwijderen van je integratie?" diff --git a/resources/ts/Components/ActivationDialog.tsx b/resources/ts/Components/ActivationDialog.tsx index 18e094e1c..3078d8b10 100644 --- a/resources/ts/Components/ActivationDialog.tsx +++ b/resources/ts/Components/ActivationDialog.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useRef, useState } from "react"; +import React, { useContext } from "react"; import { Dialog } from "./Dialog"; import { ButtonSecondary } from "./ButtonSecondary"; import { ButtonPrimary } from "./ButtonPrimary"; @@ -13,12 +13,9 @@ import type { PricingPlan } from "../hooks/useGetPricingPlans"; import { formatCurrency } from "../utils/formatCurrency"; import { Heading } from "./Heading"; import { CouponInfoContext } from "../Context/CouponInfo"; -import { faTrash } from "@fortawesome/free-solid-svg-icons"; -import { ButtonIcon } from "./ButtonIcon"; -import { debounce } from "lodash"; import type { Organization } from "../types/Organization"; import type { UiTPASOrganizer } from "../types/UiTPASOrganizer"; -import { Alert } from "./Alert"; +import { OrganizersDatalist } from "./Integrations/Detail/OrganizersDatalist"; const PriceOverview = ({ coupon, @@ -137,87 +134,10 @@ export const ActivationDialog = ({ const isBillingInfoAndPriceOverviewVisible = type !== IntegrationType.EntryApi && type !== IntegrationType.UiTPAS; - const [isSearchListVisible, setIsSearchListVisible] = useState(false); - const [organizerList, setOrganizerList] = useState([]); - const [organizerError, setOrganizerError] = useState(false); - - const organizersInputRef = useRef(null); - - const handleGetOrganizers = debounce( - async (e: React.ChangeEvent) => { - const response = await fetch(`/organizers?name=${e.target.value}`); - const data = await response.json(); - if ("exception" in data) { - setOrganizerError(true); - return; - } - const organizers = data.map( - (organizer: { name: string | { nl: string }; id: string }) => { - if (typeof organizer.name === "object" && "nl" in organizer.name) { - return { name: organizer.name.nl, id: organizer.id }; - } - return organizer; - } - ); - setOrganizerList(organizers); - if (organizerError) { - setOrganizerError(false); - } - }, - 750 - ); - - const handleAddOrganizers = (organizer: UiTPASOrganizer) => { - const isDuplicate = - organizationForm.data.organizers.length > 0 && - organizationForm.data.organizers.some( - (existingOrganizer) => existingOrganizer.id === organizer.id - ); - if (!isDuplicate) { - organizationForm.setData("organizers", [ - ...organizationForm.data.organizers, - organizer, - ]); - setIsSearchListVisible(false); - setOrganizerList([]); - if (organizersInputRef.current) { - organizersInputRef.current.value = ""; - } - } - }; - - const handleDeleteOrganizer = (deletedOrganizer: string) => { - const updatedOrganizers = organizationForm.data.organizers.filter( - (organizer) => organizer.name !== deletedOrganizer - ); - organizationForm.setData("organizers", updatedOrganizers); - }; - if (!isVisible) { return null; } - const handleInputOnChange = async ( - e: React.ChangeEvent - ) => { - if (e.target.value !== "") { - await handleGetOrganizers(e); - setIsSearchListVisible(true); - } else { - setIsSearchListVisible(false); - setOrganizerList([]); - } - }; - - const handleKeyDown = ( - event: React.KeyboardEvent, - organizer: UiTPASOrganizer - ) => { - if (event.key === "Enter") { - handleAddOrganizers(organizer); - } - }; - return ( -
- {organizationForm.data.organizers.length > 0 && - organizationForm.data.organizers.map((organizer, index) => ( -
-

{organizer.name}

- handleDeleteOrganizer(organizer.name)} - /> -
- ))} -
- {organizerError && ( - {t("dialog.invite_error")} - )} - - { - await handleInputOnChange(e); - }} - /> - {organizerList && - organizerList.length > 0 && - isSearchListVisible && ( -
    - {organizerList.map((organizer) => ( -
  • handleAddOrganizers(organizer)} - onKeyDown={(e) => handleKeyDown(e, organizer)} - className="border-b px-3 py-1 hover:bg-gray-100" - > - {organizer.name} -
  • - ))} -
- )} - + value={organizationForm.data.organizers} + onChange={(organizers) => + organizationForm.setData("organizers", organizers) } /> diff --git a/resources/ts/Components/FormElement.tsx b/resources/ts/Components/FormElement.tsx index adca14a3c..01125d4d7 100644 --- a/resources/ts/Components/FormElement.tsx +++ b/resources/ts/Components/FormElement.tsx @@ -73,7 +73,7 @@ const InputStyle = { right: "flex self-center", }; -type Props = { +export type Props = { label?: string | ReactElement; labelPosition?: LabelPosition; labelSize?: LabelSize; diff --git a/resources/ts/Components/Integrations/Detail/OrganizersDatalist.tsx b/resources/ts/Components/Integrations/Detail/OrganizersDatalist.tsx new file mode 100644 index 000000000..24f162e96 --- /dev/null +++ b/resources/ts/Components/Integrations/Detail/OrganizersDatalist.tsx @@ -0,0 +1,151 @@ +import { Input } from "../../Input"; +import type { Props as FormElementProps } from "../../FormElement"; +import { FormElement } from "../../FormElement"; +import React, { useRef, useState } from "react"; +import type { UiTPASOrganizer } from "../../../types/UiTPASOrganizer"; +import { debounce } from "lodash"; +import { useTranslation } from "react-i18next"; +import { Alert } from "../../Alert"; +import { ButtonIcon } from "../../ButtonIcon"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; + +type Props = { + onChange: (organizers: UiTPASOrganizer[]) => void; + value: UiTPASOrganizer[]; +} & Omit; + +export const OrganizersDatalist = ({ onChange, value, ...props }: Props) => { + const { t } = useTranslation(); + const [isSearchListVisible, setIsSearchListVisible] = useState(false); + const [organizerList, setOrganizerList] = useState([]); + const [organizerError, setOrganizerError] = useState(false); + const organizersInputRef = useRef(null); + + const handleGetOrganizers = debounce( + async (e: React.ChangeEvent) => { + const response = await fetch(`/organizers?name=${e.target.value}`); + try { + const data = await response.json(); + if (!data || (typeof data === "object" && "exception" in data)) { + setOrganizerError(true); + return; + } + + const organizers = data.map( + (organizer: { name: string | { nl: string }; id: string }) => { + if (typeof organizer.name === "object" && "nl" in organizer.name) { + return { name: organizer.name.nl, id: organizer.id }; + } + return organizer; + } + ); + setOrganizerList(organizers); + } catch (error) { + setOrganizerError(true); + return; + } + + if (organizerError) { + setOrganizerError(false); + } + }, + 750 + ); + + const handleInputOnChange = async ( + e: React.ChangeEvent + ) => { + if (e.target.value !== "") { + await handleGetOrganizers(e); + setIsSearchListVisible(true); + } else { + setIsSearchListVisible(false); + setOrganizerList([]); + } + }; + + const handleAddOrganizers = (organizer: UiTPASOrganizer) => { + const isDuplicate = + value.length > 0 && + value.some((existingOrganizer) => existingOrganizer.id === organizer.id); + + if (!isDuplicate) { + onChange([...value, organizer]); + setIsSearchListVisible(false); + setOrganizerList([]); + if (organizersInputRef.current) { + organizersInputRef.current.value = ""; + } + } + }; + + const handleKeyDown = ( + event: React.KeyboardEvent, + organizer: UiTPASOrganizer + ) => { + if (event.key === "Enter") { + handleAddOrganizers(organizer); + } + }; + + const handleDeleteOrganizer = (deletedOrganizer: string) => + onChange(value.filter((organizer) => organizer.name !== deletedOrganizer)); + + return ( + <> +
+ {value.length > 0 && + value.map((organizer, index) => ( +
+

{organizer.name}

+ handleDeleteOrganizer(organizer.name)} + /> +
+ ))} +
+ {organizerError && ( + {t("dialog.invite_error")} + )} + + handleInputOnChange(e)} + /> + {organizerList && + organizerList.length > 0 && + isSearchListVisible && ( +
    + {organizerList.map((organizer) => ( +
  • handleAddOrganizers(organizer)} + onKeyDown={(e) => handleKeyDown(e, organizer)} + className="border-b px-3 py-1 hover:bg-gray-100" + > + {organizer.name} +
  • + ))} +
+ )} + + } + /> + + ); +}; diff --git a/resources/ts/Components/Integrations/Detail/OrganizersInfo.tsx b/resources/ts/Components/Integrations/Detail/OrganizersInfo.tsx index 85ad0437a..8aea2a26e 100644 --- a/resources/ts/Components/Integrations/Detail/OrganizersInfo.tsx +++ b/resources/ts/Components/Integrations/Detail/OrganizersInfo.tsx @@ -10,7 +10,11 @@ import type { Organizer } from "../../../types/Organizer"; import { groupBy } from "lodash"; import { ButtonPrimary } from "../../ButtonPrimary"; import { QuestionDialog } from "../../QuestionDialog"; -import { router } from "@inertiajs/react"; +import { router, useForm } from "@inertiajs/react"; +import { Dialog } from "../../Dialog"; +import { ButtonSecondary } from "../../ButtonSecondary"; +import { OrganizersDatalist } from "./OrganizersDatalist"; +import type { UiTPASOrganizer } from "../../../types/UiTPASOrganizer"; type Props = Integration & { organizers: Organizer[] }; @@ -25,6 +29,10 @@ const OrganizersSection = ({ }) => { const { t, i18n } = useTranslation(); const [toBeDeletedId, setToBeDeletedId] = useState(""); + const [isModalVisible, setIsModalVisible] = useState(false); + const form = useForm<{ organizers: UiTPASOrganizer[] }>({ + organizers: [], + }); const handleDeleteOrganizer = () => { router.delete(`/integrations/${id}/organizers/${toBeDeletedId}`, { @@ -33,6 +41,12 @@ const OrganizersSection = ({ }); }; + const handleUpdateOrganizers = () => + router.post(`/integrations/${id}/organizers`, form.data, { + preserveScroll: false, + preserveState: false, + }); + if (!organizers?.length) { return null; } @@ -76,7 +90,10 @@ const OrganizersSection = ({
{sectionName === "Live" && ( - + setIsModalVisible(true)} + > {t("details.organizers_info.add")} )} @@ -94,6 +111,30 @@ const OrganizersSection = ({ onConfirm={handleDeleteOrganizer} onCancel={() => setToBeDeletedId("")} /> + setIsModalVisible(false)} + title={t("details.organizers_info.add")} + contentStyles="gap-3" + actions={ + <> + setIsModalVisible(false)}> + {t("dialog.cancel")} + + + {t("dialog.confirm")} + + + } + > + + {t("details.organizers_info.update_dialog.question")} + + form.setData("organizers", organizers)} + value={form.data.organizers} + /> + ); }; diff --git a/routes/web.php b/routes/web.php index 0dc9f3b57..e0a685e5d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,10 +8,10 @@ use App\Domain\Integrations\Controllers\IntegrationController; use App\Domain\Integrations\Controllers\OrganizerController; use App\Domain\Newsletter\Controllers\NewsletterController; -use Illuminate\Support\Facades\Route; -use App\Router\TranslatedRoute; use App\Http\Controllers\HomeController; use App\Http\Controllers\SupportController; +use App\Router\TranslatedRoute; +use Illuminate\Support\Facades\Route; use Inertia\Inertia; /* @@ -96,6 +96,7 @@ Route::patch('/integrations/{id}/organization', [IntegrationController::class, 'updateOrganization']); + Route::post('/integrations/{id}/organizers', [IntegrationController::class, 'updateOrganizers']); Route::delete('/integrations/{id}/organizers/{organizerId}', [IntegrationController::class, 'deleteOrganizer']); Route::post('/integrations/{id}/activation', [IntegrationController::class, 'requestActivation']);