diff --git a/modules/nuc_api/utils/use_api_errors.ts b/modules/nuc_api/utils/use_api_errors.ts index 2d17bb6..4f193d6 100644 --- a/modules/nuc_api/utils/use_api_errors.ts +++ b/modules/nuc_api/utils/use_api_errors.ts @@ -5,22 +5,44 @@ import type { } from 'nucleify' import { useAtomicToast } from 'nucleify' +function apiFailureToError(error: unknown): Error { + if (error instanceof Error) return error + if (typeof error === 'string') return new Error(error) + if (error && typeof error === 'object' && 'data' in error) { + const data = (error as { data: unknown }).data + if (data && typeof data === 'object' && data !== null) { + const d = data as Record + if (typeof d.error === 'string') return new Error(d.error) + if (typeof d.message === 'string') return new Error(d.message) + if (d.error != null) return new Error(String(d.error)) + } + const status = (error as { response?: { status?: number } }).response?.status + if (status != null) return new Error(`Request failed (${status})`) + } + return new Error('Request failed') +} + export function useApiErrors(): UseApiErrorsInterface { const { flashToast }: UseToastInterface = useAtomicToast() function apiErrors(error: ErrorResponseInterface | Error | unknown): void { if (error && typeof error === 'object' && 'data' in error) { - const data = error.data as { error?: string; errors?: string } + const data = error.data as { + error?: string + errors?: string | Record + } if (data?.error) { flashToast(data.error, 'error') } else if (data?.errors) { flashToast(data.errors, 'error') - setTimeout(() => { - document - .querySelector('.p-toast-summary') - ?.classList.add('validation-errors') - }) + if (typeof data.errors !== 'string') { + setTimeout(() => { + document + .querySelector('.p-toast-summary') + ?.classList.add('validation-errors') + }) + } } else if (error) { if (error instanceof Error) { flashToast(error.message, 'error') @@ -33,7 +55,7 @@ export function useApiErrors(): UseApiErrorsInterface { flashToast('An unknown error occurred', 'error') } - throw error + throw apiFailureToError(error) } if (error instanceof Error) { diff --git a/modules/nuc_dialog/types/interfaces.ts b/modules/nuc_dialog/types/interfaces.ts index d7097c2..5d97c9a 100644 --- a/modules/nuc_dialog/types/interfaces.ts +++ b/modules/nuc_dialog/types/interfaces.ts @@ -8,8 +8,8 @@ import type { } from './functions' export interface NucDialogInterface extends DialogInterface { - entity?: ObjectType - action?: ActionType + entity?: AdTypeType + action?: ActionType | 'share' title?: string fields?: Array<{ name: string diff --git a/modules/nuc_share/NucShare.jsx b/modules/nuc_share/NucShare.jsx new file mode 100644 index 0000000..deda78e --- /dev/null +++ b/modules/nuc_share/NucShare.jsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react' + +import { AdPopover } from 'atomic' + +import { useShareRequests } from './atomic/utils/requests' +import { isMobile } from '../nuc_media/utils/is_mobile' +import { NucSharePopover } from './components/share-popover' +import './_index.scss' + +export function NucShare({ position }) { + const { loading, loadAll } = useShareRequests() + + useEffect(() => { + loadAll() + }, [loadAll]) + + if (loading) return null + + return ( + + + + ) +} diff --git a/modules/nuc_share/README.md b/modules/nuc_share/README.md new file mode 100644 index 0000000..9dc0d68 --- /dev/null +++ b/modules/nuc_share/README.md @@ -0,0 +1,9 @@ +#   nuc_share + +Module that contains entity sharing functions. + +
+ +

    Contributors


+ + diff --git a/modules/nuc_share/_index.scss b/modules/nuc_share/_index.scss new file mode 100644 index 0000000..2b66910 --- /dev/null +++ b/modules/nuc_share/_index.scss @@ -0,0 +1,20 @@ +.share-inbox-popover { + right: 0; + width: 100%; + height: 100%; + z-index: 100; + border-radius: 0; + overflow: hidden; + bottom: 0 !important; + + .p-popover-content { + height: 100%; + padding: 0; + } + + @media (min-width: $lg) { + height: 100vh; + max-width: 450px; + margin-left: 3px; + } +} diff --git a/modules/nuc_share/app/Http/Controllers/ShareController.php b/modules/nuc_share/app/Http/Controllers/ShareController.php new file mode 100644 index 0000000..e5c985c --- /dev/null +++ b/modules/nuc_share/app/Http/Controllers/ShareController.php @@ -0,0 +1,128 @@ +service = $service; + } + + /** + * Create share request + */ + public function create(Request $request): JsonResponse + { + try { + $validated = $request->validate([ + 'entity_ids' => 'required|array', + 'entity_ids.*' => 'required|integer', + 'entity_type' => 'required|string', + 'user_ids' => 'required|array', + 'user_ids.*' => 'required|integer|exists:users,id', + ]); + + $result = $this->service->createShareRequest( + $validated['entity_ids'], + $validated['entity_type'], + $validated['user_ids'] + ); + + return response()->json($result); + } catch (Exception $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + } + + /** + * Get received share requests + */ + public function received(): JsonResponse + { + try { + $requests = $this->service->getReceivedRequests(); + + return response()->json(['data' => $requests]); + } catch (Exception $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + } + + /** + * Get sent share requests + */ + public function sent(): JsonResponse + { + try { + $requests = $this->service->getSentRequests(); + + return response()->json(['data' => $requests]); + } catch (Exception $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + } + + /** + * Get pending requests count + */ + public function count(): JsonResponse + { + try { + $count = $this->service->getPendingCount(); + + return response()->json(['count' => $count]); + } catch (Exception $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + } + + /** + * Accept share request + */ + public function accept(int $id): JsonResponse + { + try { + $result = $this->service->acceptRequest($id); + + return response()->json($result); + } catch (Exception $e) { + return response()->json(['error' => $e->getMessage()], 400); + } + } + + /** + * Reject share request + */ + public function reject(int $id): JsonResponse + { + try { + $result = $this->service->rejectRequest($id); + + return response()->json($result); + } catch (Exception $e) { + return response()->json(['error' => $e->getMessage()], 400); + } + } + + /** + * Cancel sent share request + */ + public function cancel(int $id): JsonResponse + { + try { + $result = $this->service->cancelRequest($id); + + return response()->json($result); + } catch (Exception $e) { + return response()->json(['error' => $e->getMessage()], 400); + } + } +} diff --git a/modules/nuc_share/app/Models/ShareRequest.php b/modules/nuc_share/app/Models/ShareRequest.php new file mode 100644 index 0000000..81eea77 --- /dev/null +++ b/modules/nuc_share/app/Models/ShareRequest.php @@ -0,0 +1,46 @@ + 'array', + ]; + + public function sender(): BelongsTo + { + return $this->belongsTo(User::class, 'sender_id'); + } + + public function receiver(): BelongsTo + { + return $this->belongsTo(User::class, 'receiver_id'); + } + + public function scopePending($query) + { + return $query->where('status', 'pending'); + } + + public function scopeForReceiver($query, int $userId) + { + return $query->where('receiver_id', $userId); + } + + public function scopeForSender($query, int $userId) + { + return $query->where('sender_id', $userId); + } +} diff --git a/modules/nuc_share/app/Services/ShareService.php b/modules/nuc_share/app/Services/ShareService.php new file mode 100644 index 0000000..2e9787f --- /dev/null +++ b/modules/nuc_share/app/Services/ShareService.php @@ -0,0 +1,231 @@ +id(); + $requestsCreated = 0; + + foreach ($userIds as $receiverId) { + ShareRequest::create([ + 'sender_id' => $senderId, + 'receiver_id' => $receiverId, + 'entity_type' => $entityType, + 'entity_ids' => $entityIds, + 'status' => 'pending', + ]); + $requestsCreated++; + } + + return [ + 'message' => "Created {$requestsCreated} share requests", + ]; + } + + /** + * Get received pending share requests + * + * @return array + */ + public function getReceivedRequests(): array + { + return ShareRequest::forReceiver(auth()->id()) + ->pending() + ->with('sender:id,name,email') + ->orderBy('created_at', 'desc') + ->get() + ->toArray(); + } + + /** + * Get sent share requests + * + * @return array + */ + public function getSentRequests(): array + { + return ShareRequest::forSender(auth()->id()) + ->with('receiver:id,name,email') + ->orderBy('created_at', 'desc') + ->get() + ->toArray(); + } + + /** + * Get pending requests count + * + * @return int + */ + public function getPendingCount(): int + { + return ShareRequest::forReceiver(auth()->id())->pending()->count(); + } + + /** + * Accept share request - this calls the existing shareEntities logic + * + * @param int $requestId + * + * @return array + */ + public function acceptRequest(int $requestId): array + { + $request = ShareRequest::forReceiver(auth()->id())->pending()->find($requestId); + + if (!$request) { + throw new Exception('Share request not found'); + } + + // Use existing shareEntities logic - copy entities to receiver + $result = $this->shareEntitiesToUser( + $request->entity_ids, + $request->entity_type, + auth()->id() + ); + + $request->update(['status' => 'accepted']); + + return $result; + } + + /** + * Reject share request + * + * @param int $requestId + * + * @return array + */ + public function rejectRequest(int $requestId): array + { + $request = ShareRequest::forReceiver(auth()->id())->pending()->find($requestId); + + if (!$request) { + throw new Exception('Share request not found'); + } + + $request->update(['status' => 'rejected']); + + return ['message' => 'Share request rejected']; + } + + /** + * Cancel sent share request + * + * @param int $requestId + * + * @return array + */ + public function cancelRequest(int $requestId): array + { + $request = ShareRequest::forSender(auth()->id())->pending()->find($requestId); + + if (!$request) { + throw new Exception('Share request not found'); + } + + $request->delete(); + + return ['message' => 'Share request cancelled']; + } + + /** + * Share entities to specific user - copy entities to user's database + * + * @param array $entityIds + * @param string $entityType + * @param int $targetUserId + * + * @return array + */ + private function shareEntitiesToUser(array $entityIds, string $entityType, int $targetUserId): array + { + $modelClass = $this->getEntityClass($entityType); + $copiedCount = 0; + + foreach ($entityIds as $entityId) { + $originalEntity = $modelClass::find($entityId); + + if (!$originalEntity) { + continue; + } + + $this->copyEntityToUser($originalEntity, $targetUserId); + $copiedCount++; + } + + $this->logger->log( + auth()->user()->name, + "{$copiedCount} entities", + $entityType, + 'received via share' + ); + + return [ + 'message' => "Received {$copiedCount} entities", + 'copied_count' => $copiedCount, + ]; + } + + /** + * Copy entity to user's database + * + * @param Model $originalEntity + * @param int $targetUserId + * + * @return Model + */ + private function copyEntityToUser($originalEntity, int $targetUserId) + { + $attributes = $originalEntity->getAttributes(); + unset($attributes['id']); + unset($attributes['created_at']); + unset($attributes['updated_at']); + + $attributes['user_id'] = $targetUserId; + + $modelClass = get_class($originalEntity); + + return $modelClass::create($attributes); + } + + /** + * Get entity class name from entity type + * + * @param string $entityType + * + * @return string + */ + private function getEntityClass(string $entityType): string + { + $entityMap = [ + 'article' => \App\Models\Article::class, + 'contact' => \App\Models\Contact::class, + 'money' => \App\Models\Money::class, + 'file' => \App\Models\File::class, + 'documentation' => \App\Models\Documentation::class, + 'question' => \App\Models\Question::class, + 'technology' => \App\Models\Technology::class, + ]; + + return $entityMap[$entityType] ?? throw new Exception("Unknown entity type: {$entityType}"); + } +} diff --git a/modules/nuc_share/atomic/index.ts b/modules/nuc_share/atomic/index.ts new file mode 100644 index 0000000..956badd --- /dev/null +++ b/modules/nuc_share/atomic/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './utils' + diff --git a/modules/nuc_share/atomic/types/index.ts b/modules/nuc_share/atomic/types/index.ts new file mode 100644 index 0000000..a2623b8 --- /dev/null +++ b/modules/nuc_share/atomic/types/index.ts @@ -0,0 +1,2 @@ +export * from './interfaces' + diff --git a/modules/nuc_share/atomic/types/interfaces.ts b/modules/nuc_share/atomic/types/interfaces.ts new file mode 100644 index 0000000..ba52fa4 --- /dev/null +++ b/modules/nuc_share/atomic/types/interfaces.ts @@ -0,0 +1,30 @@ +export interface ShareRequestSender { + id: number + name: string + email: string +} + +export interface ShareRequestInterface { + id: number + sender_id: number + receiver_id: number + entity_type: string + entity_ids: number[] + status: 'pending' | 'accepted' | 'rejected' + created_at: string + sender?: ShareRequestSender + receiver?: ShareRequestSender +} + +export interface ShareRequestsInterface { + received: ShareRequestInterface[] + sent: ShareRequestInterface[] + pendingCount: number + loading: boolean + loadAll: () => Promise + acceptRequest: (id: number) => Promise + rejectRequest: (id: number) => Promise + cancelRequest: (id: number) => Promise +} + +export type ShareTabType = 'received' | 'sent' diff --git a/modules/nuc_share/atomic/utils/api_url.ts b/modules/nuc_share/atomic/utils/api_url.ts new file mode 100644 index 0000000..2de93f7 --- /dev/null +++ b/modules/nuc_share/atomic/utils/api_url.ts @@ -0,0 +1,3 @@ +export function apiUrl(): string { + return process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '') ?? '' +} diff --git a/modules/nuc_share/atomic/utils/index.ts b/modules/nuc_share/atomic/utils/index.ts new file mode 100644 index 0000000..8072924 --- /dev/null +++ b/modules/nuc_share/atomic/utils/index.ts @@ -0,0 +1,3 @@ +export * from './api_url' +export * from './requests' + diff --git a/modules/nuc_share/atomic/utils/requests.ts b/modules/nuc_share/atomic/utils/requests.ts new file mode 100644 index 0000000..3cca747 --- /dev/null +++ b/modules/nuc_share/atomic/utils/requests.ts @@ -0,0 +1,96 @@ +import { useState } from 'react' + +import { apiHandle } from '../../../nuc_api/utils/api_handle' +import { useApiSuccess } from '../../../nuc_api/utils/use_api_success' +import { useLoading } from '../../../nuc_loading/utils/use_loading' + +import type { ShareRequestInterface, ShareRequestsInterface } from '../types' + +import { apiUrl } from './api_url' + + +export function useShareRequests(): ShareRequestsInterface { + const [received, setReceived] = useState([]) + const [sent, setSent] = useState([]) + const [pendingCount, setPendingCount] = useState(0) + + const { loading, setLoading } = useLoading() + const { apiSuccess } = useApiSuccess() + + async function getReceived(): Promise { + await apiHandle({ + url: apiUrl() + '/share/received', + setLoading, + onSuccess: (response) => { + setReceived(response ?? []) + }, + }) + } + + async function getSent(): Promise { + await apiHandle({ + url: apiUrl() + '/share/sent', + setLoading, + onSuccess: (response) => { + setSent(response ?? []) + }, + }) + } + + async function getPendingCount(): Promise { + await apiHandle<{ count: number }>({ + url: apiUrl() + '/share/count', + onSuccess: (response) => { + setPendingCount(response.count ?? 0) + }, + }) + } + + async function loadAll(): Promise { + await Promise.all([getReceived(), getSent(), getPendingCount()]) + } + + async function acceptRequest(id: number): Promise { + await apiHandle<{ message: string }>({ + url: apiUrl() + '/share/' + id + '/accept', + method: 'POST', + setLoading, + onSuccess: (response) => { + apiSuccess(response, loadAll) + }, + }) + } + + async function rejectRequest(id: number): Promise { + await apiHandle<{ message: string }>({ + url: apiUrl() + '/share/' + id + '/reject', + method: 'POST', + setLoading, + onSuccess: (response) => { + apiSuccess(response, loadAll) + }, + }) + } + + async function cancelRequest(id: number): Promise { + await apiHandle<{ message: string }>({ + url: apiUrl() + '/share/' + id + '/cancel', + method: 'POST', + setLoading, + onSuccess: (response) => { + apiSuccess(response, loadAll) + }, + }) + } + + return { + received, + sent, + pendingCount, + loading, + loadAll, + acceptRequest, + rejectRequest, + cancelRequest, + } +} diff --git a/modules/nuc_share/components/index.ts b/modules/nuc_share/components/index.ts new file mode 100644 index 0000000..18e6637 --- /dev/null +++ b/modules/nuc_share/components/index.ts @@ -0,0 +1,6 @@ +export * from './share-checkbox' +export * from './share-dialog' +export * from './share-popover' +export * from './share-requests-item' +export * from './share-requests-list' +export * from './share-tabs' diff --git a/modules/nuc_share/components/share-checkbox/component.tsx b/modules/nuc_share/components/share-checkbox/component.tsx new file mode 100644 index 0000000..26d4e15 --- /dev/null +++ b/modules/nuc_share/components/share-checkbox/component.tsx @@ -0,0 +1,29 @@ +import { useEffect, useRef, type JSX } from 'react' + +import type { NucShareCheckboxInterface } from './types' + +export function NucShareCheckbox({ + adType, + checked, + indeterminate, + isAll, + onToggle, +}: NucShareCheckboxInterface): JSX.Element { + const checkboxRef = useRef(null) + + useEffect(() => { + if (checkboxRef.current) { + checkboxRef.current.indeterminate = isAll ? !!indeterminate : false + } + }, [indeterminate, isAll]) + + return ( + + ) +} diff --git a/modules/nuc_share/components/share-checkbox/index.ts b/modules/nuc_share/components/share-checkbox/index.ts new file mode 100644 index 0000000..5237e69 --- /dev/null +++ b/modules/nuc_share/components/share-checkbox/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './utils' + +export { NucShareCheckbox } from './component' + diff --git a/modules/nuc_share/components/share-checkbox/types/index.ts b/modules/nuc_share/components/share-checkbox/types/index.ts new file mode 100644 index 0000000..a2623b8 --- /dev/null +++ b/modules/nuc_share/components/share-checkbox/types/index.ts @@ -0,0 +1,2 @@ +export * from './interfaces' + diff --git a/modules/nuc_share/components/share-checkbox/types/interfaces.ts b/modules/nuc_share/components/share-checkbox/types/interfaces.ts new file mode 100644 index 0000000..37c9635 --- /dev/null +++ b/modules/nuc_share/components/share-checkbox/types/interfaces.ts @@ -0,0 +1,7 @@ +export interface NucShareCheckboxInterface { + adType?: AdTypeType + checked?: boolean + indeterminate?: boolean + isAll?: boolean + onToggle?: () => void +} diff --git a/modules/nuc_share/components/share-checkbox/utils/index.ts b/modules/nuc_share/components/share-checkbox/utils/index.ts new file mode 100644 index 0000000..03ac7a2 --- /dev/null +++ b/modules/nuc_share/components/share-checkbox/utils/index.ts @@ -0,0 +1,2 @@ +export * from './use_share_selection' + diff --git a/modules/nuc_share/components/share-checkbox/utils/use_share_selection.ts b/modules/nuc_share/components/share-checkbox/utils/use_share_selection.ts new file mode 100644 index 0000000..1673e79 --- /dev/null +++ b/modules/nuc_share/components/share-checkbox/utils/use_share_selection.ts @@ -0,0 +1,86 @@ +import { useCallback, useMemo, useState } from 'react' + +export interface UseShareSelectionReturn { + selected: Record + isSelected: (id: number) => boolean + isAllSelected: boolean + isIndeterminate: boolean + toggle: (id: number) => void + toggleAll: () => void + selectAll: () => void + deselectAll: () => void + getSelectedItems: () => T[] + clear: () => void +} + +export function useShareSelection( + items: { id: number }[] | undefined +): UseShareSelectionReturn { + const [selected, setSelected] = useState>({}) + + const selectedCount = useMemo(() => Object.values(selected).filter(Boolean).length, [selected]) + + const isAllSelected = useMemo(() => { + if (!items || items.length === 0) return false + return items.every((item) => selected[item.id] === true) + }, [items, selected]) + + const isIndeterminate = useMemo(() => { + if (!items || items.length === 0) return false + return selectedCount > 0 && selectedCount < items.length + }, [items, selectedCount]) + + const isSelected = useCallback((id: number): boolean => selected[id] === true, [selected]) + + const toggle = useCallback((id: number): void => { + setSelected((prev) => ({ + ...prev, + [id]: !prev[id], + })) + }, []) + + const selectAll = useCallback((): void => { + if (!items) return + + const newSelected: Record = {} + items.forEach((item) => { + newSelected[item.id] = true + }) + setSelected(newSelected) + }, [items]) + + const deselectAll = useCallback((): void => { + setSelected({}) + }, []) + + const toggleAll = useCallback((): void => { + if (isAllSelected) { + deselectAll() + } else { + selectAll() + } + }, [deselectAll, isAllSelected, selectAll]) + + const getSelectedItems = (): T[] => { + const typedItems = items as T[] | undefined + if (!typedItems) return [] + return typedItems.filter((item) => selected[item.id] === true) + } + + const clear = useCallback((): void => { + setSelected({}) + }, []) + + return { + selected, + isSelected, + isAllSelected, + isIndeterminate, + toggle, + toggleAll, + selectAll, + deselectAll, + getSelectedItems, + clear, + } +} diff --git a/modules/nuc_share/components/share-dialog/_index.scss b/modules/nuc_share/components/share-dialog/_index.scss new file mode 100644 index 0000000..05c90d0 --- /dev/null +++ b/modules/nuc_share/components/share-dialog/_index.scss @@ -0,0 +1,51 @@ +.share-dialog { + h3 { + margin: 0; + } + + &-content { + display: flex; + flex-direction: column; + gap: 1rem; + } + + &-subtitle { + margin-bottom: 0.5rem; + } + + &-friends { + max-height: 300px; + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: auto; + } + + &-friend { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.25rem; + border-radius: 0.5rem; + cursor: pointer; + + &-info { + display: flex; + flex-direction: column; + gap: 0.125rem; + } + + &-email { + font-size: 0.75rem; + font-weight: 300; + } + } + + &-footer { + width: 100%; + display: flex; + justify-content: space-between; + gap: 0.5rem; + } +} + diff --git a/modules/nuc_share/components/share-dialog/component.tsx b/modules/nuc_share/components/share-dialog/component.tsx new file mode 100644 index 0000000..e945171 --- /dev/null +++ b/modules/nuc_share/components/share-dialog/component.tsx @@ -0,0 +1,85 @@ +'use client' + +import type { JSX } from 'react' + +import { AdHeading } from '../../../../atomic/atom/heading' +import { NucDialog } from '../../../nuc_dialog' +import { NucShareCheckbox } from '../share-checkbox' +import type { NucShareDialogInterface } from './types' +import { useShareDialog } from './utils/use_share_dialog' + +import './_index.scss' + +export function NucShareDialog(props: NucShareDialogInterface): JSX.Element { + const { + friends, + selectedEntities, + loading, + isConfirmDisabled, + handleShare, + handleCancel, + toggleFriend, + isFriendSelected, + } = useShareDialog(props) + + const { adType, visible } = props + + return ( + { + await handleShare() + }} + close={() => { + handleCancel() + }} + onHide={handleCancel} + > +
+
+ {selectedEntities.length > 0 ? ( +
+ Selected count: {selectedEntities.length} +
+ ) : ( +
+ No items selected. Select items in the table first. +
+ )} + +
+ +
+ +
+ {friends.map((friend) => ( + + ))} +
+
+
+
+ ) +} diff --git a/modules/nuc_share/components/share-dialog/index.ts b/modules/nuc_share/components/share-dialog/index.ts new file mode 100644 index 0000000..290ec0d --- /dev/null +++ b/modules/nuc_share/components/share-dialog/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './utils' + +export { NucShareDialog } from './component' + diff --git a/modules/nuc_share/components/share-dialog/types/index.ts b/modules/nuc_share/components/share-dialog/types/index.ts new file mode 100644 index 0000000..a2623b8 --- /dev/null +++ b/modules/nuc_share/components/share-dialog/types/index.ts @@ -0,0 +1,2 @@ +export * from './interfaces' + diff --git a/modules/nuc_share/components/share-dialog/types/interfaces.ts b/modules/nuc_share/components/share-dialog/types/interfaces.ts new file mode 100644 index 0000000..384792f --- /dev/null +++ b/modules/nuc_share/components/share-dialog/types/interfaces.ts @@ -0,0 +1,33 @@ +export interface NucShareDialogInterface { + adType?: AdTypeType + visible?: boolean + selectedEntities?: unknown[] + onUpdateVisible: (visible: boolean) => void +} + +export interface Friend { + id: number + name: string + email?: string +} + +export interface FriendshipListItemInterface { + status: 'pending' | 'accepted' | 'denied' | 'blocked' + friend: { + id: number + name: string + email?: string + } +} + +export interface UseShareDialogInterface { + friends: Friend[] + selectedFriendIds: number[] + selectedEntities: unknown[] + loading: boolean + isConfirmDisabled: boolean + handleShare: () => Promise + handleCancel: () => void + toggleFriend: (id: number) => void + isFriendSelected: (id: number) => boolean +} diff --git a/modules/nuc_share/components/share-dialog/utils/index.ts b/modules/nuc_share/components/share-dialog/utils/index.ts new file mode 100644 index 0000000..8f0f656 --- /dev/null +++ b/modules/nuc_share/components/share-dialog/utils/index.ts @@ -0,0 +1,2 @@ +export * from './use_share_dialog' + diff --git a/modules/nuc_share/components/share-dialog/utils/use_share_dialog.ts b/modules/nuc_share/components/share-dialog/utils/use_share_dialog.ts new file mode 100644 index 0000000..62e0cd6 --- /dev/null +++ b/modules/nuc_share/components/share-dialog/utils/use_share_dialog.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { apiHandle, useApiSuccess } from 'nucleify' + +import { apiUrl } from '../../../atomic/utils/api_url' +import type { + Friend, + FriendshipListItemInterface, + NucShareDialogInterface, + UseShareDialogInterface, +} from '../types' + +export function useShareDialog( + props: NucShareDialogInterface +): UseShareDialogInterface { + const [friends, setFriends] = useState([]) + const [selectedFriendIds, setSelectedFriendIds] = useState([]) + const [loading, setLoading] = useState(false) + + const { apiSuccess } = useApiSuccess() + + const selectedEntities = useMemo( + () => props.selectedEntities ?? [], + [props.selectedEntities] + ) + + const loadFriends = useCallback(async (): Promise => { + setLoading(true) + try { + await apiHandle({ + url: apiUrl() + '/friendship/all', + onSuccess: (response) => { + setFriends( + (response ?? []) + .filter((f) => f.status === 'accepted') + .map((f) => ({ + id: f.friend.id, + name: f.friend.name, + email: f.friend.email, + })) + ) + }, + }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (props.visible) { + void loadFriends() + } + }, [props.visible, loadFriends]) + + const toggleFriend = useCallback((id: number) => { + setSelectedFriendIds((prev) => + prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id] + ) + }, []) + + const isFriendSelected = useCallback( + (id: number): boolean => selectedFriendIds.includes(id), + [selectedFriendIds] + ) + + const handleShare = useCallback(async (): Promise => { + if (selectedFriendIds.length === 0 || selectedEntities.length === 0) { + return + } + + const entityIds = selectedEntities.map((e) => (e as { id: number }).id) + + await apiHandle<{ message: string }>({ + url: apiUrl() + '/share', + method: 'POST', + data: { + entity_ids: entityIds, + entity_type: props.adType, + user_ids: selectedFriendIds, + }, + setLoading, + onSuccess: (response: { message: string }) => { + apiSuccess( + response, + () => Promise.resolve(), + () => props.onUpdateVisible(false), + 'create' + ) + setSelectedFriendIds([]) + }, + }) + }, [ + apiSuccess, + props.adType, + props.onUpdateVisible, + selectedEntities, + selectedFriendIds, + ]) + + const handleCancel = useCallback(() => { + setSelectedFriendIds([]) + props.onUpdateVisible(false) + }, [props.onUpdateVisible]) + + const isConfirmDisabled = useMemo( + () => + selectedEntities.length === 0 || selectedFriendIds.length === 0, + [selectedEntities.length, selectedFriendIds.length] + ) + + return { + friends, + selectedFriendIds, + selectedEntities, + loading, + isConfirmDisabled, + handleShare, + handleCancel, + toggleFriend, + isFriendSelected, + } +} diff --git a/modules/nuc_share/components/share-popover/_index.scss b/modules/nuc_share/components/share-popover/_index.scss new file mode 100644 index 0000000..04c8b67 --- /dev/null +++ b/modules/nuc_share/components/share-popover/_index.scss @@ -0,0 +1,19 @@ +.share-popover-container { + padding: 1em; + height: 100%; + display: flex; + flex-direction: column; + gap: 1em; + background-color: $content-background; + + .share-popover-header { + margin: 0; + } + + .share-popover-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 1em; + } +} diff --git a/modules/nuc_share/components/share-popover/component.tsx b/modules/nuc_share/components/share-popover/component.tsx new file mode 100644 index 0000000..eb7bacd --- /dev/null +++ b/modules/nuc_share/components/share-popover/component.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useEffect, useState, type JSX } from 'react' + +import { AdHeading } from 'nucleify' + +import type { ShareTabType } from '../../atomic/types' +import { useShareRequests } from '../../atomic/utils/requests' + +import { NucShareRequestsList } from '../share-requests-list' +import { NucShareTabs } from '../share-tabs' + +import './_index.scss' + +export function NucSharePopover(): JSX.Element { + const [activeTab, setActiveTab] = useState('received') + + const { + received, + sent, + loadAll, + acceptRequest, + rejectRequest, + cancelRequest, + } = useShareRequests() + + useEffect(() => { + void loadAll() + }, []) + + return ( +
+
+ +
+ +
+ + + {activeTab === 'received' ? ( + void acceptRequest(id)} + onReject={(id) => void rejectRequest(id)} + /> + ) : null} + + {activeTab === 'sent' ? ( + void cancelRequest(id)} + /> + ) : null} +
+
+ ) +} diff --git a/modules/nuc_share/components/share-popover/index.ts b/modules/nuc_share/components/share-popover/index.ts new file mode 100644 index 0000000..61dff5d --- /dev/null +++ b/modules/nuc_share/components/share-popover/index.ts @@ -0,0 +1,2 @@ +export { NucSharePopover } from './component' + diff --git a/modules/nuc_share/components/share-requests-item/_index.scss b/modules/nuc_share/components/share-requests-item/_index.scss new file mode 100644 index 0000000..605567b --- /dev/null +++ b/modules/nuc_share/components/share-requests-item/_index.scss @@ -0,0 +1,40 @@ +.share-request-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75em; + border: 1px solid $p-card-border; + border-radius: 0.5em; + + .share-request-info { + display: flex; + gap: 0.75em; + align-items: center; + flex: 1; + + .share-request-details { + display: flex; + flex-direction: column; + gap: 0.25em; + + p { + margin: 0; + } + + .share-request-meta { + font-size: 0.875em; + opacity: 0.7; + } + } + } + + .share-request-actions { + display: flex; + gap: 0.5em; + + .p-button { + padding: 0.5em !important; + font-size: 1em; + } + } +} diff --git a/modules/nuc_share/components/share-requests-item/component.tsx b/modules/nuc_share/components/share-requests-item/component.tsx new file mode 100644 index 0000000..55f561f --- /dev/null +++ b/modules/nuc_share/components/share-requests-item/component.tsx @@ -0,0 +1,40 @@ +'use client' + +import type { JSX, ReactNode } from 'react' + +import type { ShareRequestInterface } from '../../atomic/types' + +import './_index.scss' + +type NucShareRequestsItemProps = { + request: ShareRequestInterface + isReceived?: boolean + actions?: ReactNode +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString() +} + +export function NucShareRequestsItem({ + request, + isReceived = false, + actions, +}: NucShareRequestsItemProps): JSX.Element { + const name = isReceived ? request.sender?.name : request.receiver?.name + + return ( +
+
+
+

{name}

+

+ {request.entity_ids.length} {request.entity_type}(s) •{' '} + {formatDate(request.created_at)} +

+
+
+
{actions}
+
+ ) +} diff --git a/modules/nuc_share/components/share-requests-item/index.ts b/modules/nuc_share/components/share-requests-item/index.ts new file mode 100644 index 0000000..4650116 --- /dev/null +++ b/modules/nuc_share/components/share-requests-item/index.ts @@ -0,0 +1,2 @@ +export { NucShareRequestsItem } from './component' + diff --git a/modules/nuc_share/components/share-requests-list/_index.scss b/modules/nuc_share/components/share-requests-list/_index.scss new file mode 100644 index 0000000..54d1260 --- /dev/null +++ b/modules/nuc_share/components/share-requests-list/_index.scss @@ -0,0 +1,13 @@ +.share-requests-list { + display: flex; + flex-direction: column; + gap: 0.5em; + overflow-y: auto; + flex: 1; + + &-empty { + padding: 1em; + text-align: center; + opacity: 0.7; + } +} diff --git a/modules/nuc_share/components/share-requests-list/component.tsx b/modules/nuc_share/components/share-requests-list/component.tsx new file mode 100644 index 0000000..f4f0d34 --- /dev/null +++ b/modules/nuc_share/components/share-requests-list/component.tsx @@ -0,0 +1,85 @@ +'use client' + +import type { JSX } from 'react' + +import { AdButton, AdTag } from 'nucleify' + +import type { ShareRequestInterface } from '../../atomic/types' + +import { NucShareRequestsItem } from '../share-requests-item' + +import './_index.scss' + +type NucShareRequestsListProps = { + requests?: ShareRequestInterface[] + isReceived?: boolean + onAccept?: (id: number) => void + onReject?: (id: number) => void + onCancel?: (id: number) => void +} + +export function NucShareRequestsList({ + requests = [], + isReceived = false, + onAccept, + onReject, + onCancel, +}: NucShareRequestsListProps): JSX.Element { + if (requests.length === 0) { + return ( +
+
+

No share requests

+
+
+ ) + } + + return ( +
+ {requests.map((request) => ( + + onAccept?.(request.id)} + /> + onReject?.(request.id)} + /> + + ) : request.status === 'pending' ? ( + onCancel?.(request.id)} + /> + ) : ( + + ) + } + /> + ))} +
+ ) +} diff --git a/modules/nuc_share/components/share-requests-list/index.ts b/modules/nuc_share/components/share-requests-list/index.ts new file mode 100644 index 0000000..aa8af7c --- /dev/null +++ b/modules/nuc_share/components/share-requests-list/index.ts @@ -0,0 +1,2 @@ +export { NucShareRequestsList } from './component' + diff --git a/modules/nuc_share/components/share-tabs/_index.scss b/modules/nuc_share/components/share-tabs/_index.scss new file mode 100644 index 0000000..8b7598a --- /dev/null +++ b/modules/nuc_share/components/share-tabs/_index.scss @@ -0,0 +1,11 @@ +.share-tabs { + display: flex; + gap: 0.5em; + flex-wrap: wrap; + + .tab-button { + flex: 1; + min-width: 100px; + font-size: 1rem; + } +} diff --git a/modules/nuc_share/components/share-tabs/component.tsx b/modules/nuc_share/components/share-tabs/component.tsx new file mode 100644 index 0000000..00a2ca7 --- /dev/null +++ b/modules/nuc_share/components/share-tabs/component.tsx @@ -0,0 +1,38 @@ +'use client' + +import type { JSX } from 'react' + +import { AdButton } from 'nucleify' + +import type { ShareTabType } from '../../atomic/types' + +import './_index.scss' + +type NucShareTabsProps = { + activeTab: ShareTabType + onUpdateActiveTab: (tab: ShareTabType) => void +} + +export function NucShareTabs({ + activeTab, + onUpdateActiveTab, +}: NucShareTabsProps): JSX.Element { + return ( +
+ onUpdateActiveTab('received')} + /> + onUpdateActiveTab('sent')} + /> +
+ ) +} diff --git a/modules/nuc_share/components/share-tabs/index.ts b/modules/nuc_share/components/share-tabs/index.ts new file mode 100644 index 0000000..6e8c12d --- /dev/null +++ b/modules/nuc_share/components/share-tabs/index.ts @@ -0,0 +1,2 @@ +export { NucShareTabs } from './component' + diff --git a/modules/nuc_share/config.json b/modules/nuc_share/config.json new file mode 100644 index 0000000..1033907 --- /dev/null +++ b/modules/nuc_share/config.json @@ -0,0 +1,8 @@ +{ + "name": "nuc_share", + "description": "Module that contains entity sharing functions.", + "version": "0.3.3", + "category": "core", + "installed": true, + "enabled": true +} diff --git a/modules/nuc_share/database/migrations/2026_01_11_000000_create_share_requests_table.php b/modules/nuc_share/database/migrations/2026_01_11_000000_create_share_requests_table.php new file mode 100644 index 0000000..d3f938e --- /dev/null +++ b/modules/nuc_share/database/migrations/2026_01_11_000000_create_share_requests_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('sender_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('receiver_id')->constrained('users')->onDelete('cascade'); + $table->string('entity_type'); + $table->json('entity_ids'); + $table->enum('status', ['pending', 'accepted', 'rejected'])->default('pending'); + $table->timestamps(); + + $table->index(['receiver_id', 'status']); + $table->index(['sender_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('share_requests'); + } +}; diff --git a/modules/nuc_share/index.ts b/modules/nuc_share/index.ts new file mode 100644 index 0000000..474c601 --- /dev/null +++ b/modules/nuc_share/index.ts @@ -0,0 +1,4 @@ +export { NucShare } from './NucShare.jsx' + +export * from './atomic' +export * from './components' diff --git a/modules/nuc_share/nuc_share.php b/modules/nuc_share/nuc_share.php new file mode 100644 index 0000000..c7d6ea7 --- /dev/null +++ b/modules/nuc_share/nuc_share.php @@ -0,0 +1,14 @@ +loadMigrationsFrom(__DIR__ . '/database/migrations'); + $this->loadRoutesFrom(__DIR__ . '/routes/api.php'); + } +} diff --git a/modules/nuc_share/routes/api.php b/modules/nuc_share/routes/api.php new file mode 100644 index 0000000..b2ea089 --- /dev/null +++ b/modules/nuc_share/routes/api.php @@ -0,0 +1,18 @@ +group(function (): void { + Route::middleware(['web', 'auth'])->group(function (): void { + Route::controller(ShareController::class)->prefix('share')->name('share.')->group(function (): void { + Route::post('/', 'create')->name('create'); + Route::get('/received', 'received')->name('received'); + Route::get('/sent', 'sent')->name('sent'); + Route::get('/count', 'count')->name('count'); + Route::post('/{id}/accept', 'accept')->name('accept'); + Route::post('/{id}/reject', 'reject')->name('reject'); + Route::post('/{id}/cancel', 'cancel')->name('cancel'); + }); + }); +}); diff --git a/modules/nuc_share/tests/Feature/Api/HTTP200Test.php b/modules/nuc_share/tests/Feature/Api/HTTP200Test.php new file mode 100644 index 0000000..89c5965 --- /dev/null +++ b/modules/nuc_share/tests/Feature/Api/HTTP200Test.php @@ -0,0 +1,86 @@ +group('share-api-200'); +uses()->group('api-200'); + +use App\Models\ShareRequest; + +beforeEach(function (): void { + $this->createUsers(); + $this->actingAs($this->admin); +}); + +describe('200', function (): void { + test('create share request api', function (): void { + $this->postJson('/api/share', [ + 'entity_ids' => [1, 2], + 'entity_type' => 'article', + 'user_ids' => [$this->user->id], + ]) + ->assertOk() + ->assertJsonStructure(['message']); + }); + + test('get received requests api', function (): void { + $this->getJson('/api/share/received') + ->assertOk() + ->assertJsonStructure(['data']); + }); + + test('get sent requests api', function (): void { + $this->getJson('/api/share/sent') + ->assertOk() + ->assertJsonStructure(['data']); + }); + + test('get pending count api', function (): void { + $this->getJson('/api/share/count') + ->assertOk() + ->assertJsonStructure(['count']); + }); + + test('accept share request api', function (): void { + $shareRequest = ShareRequest::create([ + 'sender_id' => $this->user->id, + 'receiver_id' => $this->admin->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + $this->postJson("/api/share/{$shareRequest->id}/accept") + ->assertOk(); + }); + + test('reject share request api', function (): void { + $shareRequest = ShareRequest::create([ + 'sender_id' => $this->user->id, + 'receiver_id' => $this->admin->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + $this->postJson("/api/share/{$shareRequest->id}/reject") + ->assertOk() + ->assertJson(['message' => 'Share request rejected']); + }); + + test('cancel share request api', function (): void { + $shareRequest = ShareRequest::create([ + 'sender_id' => $this->admin->id, + 'receiver_id' => $this->user->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + $this->postJson("/api/share/{$shareRequest->id}/cancel") + ->assertOk() + ->assertJson(['message' => 'Share request cancelled']); + }); +}); diff --git a/modules/nuc_share/tests/Feature/Api/HTTP401Test.php b/modules/nuc_share/tests/Feature/Api/HTTP401Test.php new file mode 100644 index 0000000..032c303 --- /dev/null +++ b/modules/nuc_share/tests/Feature/Api/HTTP401Test.php @@ -0,0 +1,67 @@ +group('share-api-401'); +uses()->group('api-401'); + +beforeEach(function (): void { + $this->createUsers(); +}); + +describe('401', function (): void { + apiTestArray([ + 'create share request api' => [ + 'method' => 'POST', + 'route' => 'share.create', + 'status' => 401, + 'data' => [ + 'entity_ids' => [1, 2], + 'entity_type' => 'article', + 'user_ids' => [1], + ], + 'fragment' => ['message' => 'Unauthenticated.'], + ], + 'get received requests api' => [ + 'method' => 'GET', + 'route' => 'share.received', + 'status' => 401, + 'fragment' => ['message' => 'Unauthenticated.'], + ], + 'get sent requests api' => [ + 'method' => 'GET', + 'route' => 'share.sent', + 'status' => 401, + 'fragment' => ['message' => 'Unauthenticated.'], + ], + 'get pending count api' => [ + 'method' => 'GET', + 'route' => 'share.count', + 'status' => 401, + 'fragment' => ['message' => 'Unauthenticated.'], + ], + 'accept share request api' => [ + 'method' => 'POST', + 'route' => 'share.accept', + 'status' => 401, + 'id' => 1, + 'fragment' => ['message' => 'Unauthenticated.'], + ], + 'reject share request api' => [ + 'method' => 'POST', + 'route' => 'share.reject', + 'status' => 401, + 'id' => 1, + 'fragment' => ['message' => 'Unauthenticated.'], + ], + 'cancel share request api' => [ + 'method' => 'POST', + 'route' => 'share.cancel', + 'status' => 401, + 'id' => 1, + 'fragment' => ['message' => 'Unauthenticated.'], + ], + ]); +}); diff --git a/modules/nuc_share/tests/Feature/Api/HTTP500Test.php b/modules/nuc_share/tests/Feature/Api/HTTP500Test.php new file mode 100644 index 0000000..c2b2eae --- /dev/null +++ b/modules/nuc_share/tests/Feature/Api/HTTP500Test.php @@ -0,0 +1,88 @@ +group('share-api-500'); +uses()->group('api-500'); + +use App\Services\ShareService; + +use function Pest\Laravel\mock; + +beforeEach(function (): void { + $this->createUsers(); + $this->actingAs($this->admin); + $this->service = mock(ShareService::class); +}); + +function mockShareServiceMethod($service, string $methodName): void +{ + $service + ->shouldReceive($methodName) + ->once() + ->andThrow(new Exception('Internal Server Error')); +} + +describe('500', function (): void { + test('create share request api', function (): void { + mockShareServiceMethod($this->service, 'createShareRequest'); + + $this->postJson('/api/share', [ + 'entity_ids' => [1, 2], + 'entity_type' => 'article', + 'user_ids' => [$this->user->id], + ]) + ->assertStatus(500) + ->assertJson(['error' => 'Internal Server Error']); + }); + + test('get received requests api', function (): void { + mockShareServiceMethod($this->service, 'getReceivedRequests'); + + $this->getJson('/api/share/received') + ->assertStatus(500) + ->assertJson(['error' => 'Internal Server Error']); + }); + + test('get sent requests api', function (): void { + mockShareServiceMethod($this->service, 'getSentRequests'); + + $this->getJson('/api/share/sent') + ->assertStatus(500) + ->assertJson(['error' => 'Internal Server Error']); + }); + + test('get pending count api', function (): void { + mockShareServiceMethod($this->service, 'getPendingCount'); + + $this->getJson('/api/share/count') + ->assertStatus(500) + ->assertJson(['error' => 'Internal Server Error']); + }); + + test('accept share request api', function (): void { + mockShareServiceMethod($this->service, 'acceptRequest'); + + $this->postJson('/api/share/1/accept') + ->assertStatus(400) + ->assertJsonStructure(['error']); + }); + + test('reject share request api', function (): void { + mockShareServiceMethod($this->service, 'rejectRequest'); + + $this->postJson('/api/share/1/reject') + ->assertStatus(400) + ->assertJsonStructure(['error']); + }); + + test('cancel share request api', function (): void { + mockShareServiceMethod($this->service, 'cancelRequest'); + + $this->postJson('/api/share/1/cancel') + ->assertStatus(400) + ->assertJsonStructure(['error']); + }); +}); diff --git a/modules/nuc_share/tests/Feature/Controllers/ShareControllerTest.php b/modules/nuc_share/tests/Feature/Controllers/ShareControllerTest.php new file mode 100644 index 0000000..69f6a53 --- /dev/null +++ b/modules/nuc_share/tests/Feature/Controllers/ShareControllerTest.php @@ -0,0 +1,125 @@ +group('share-controller'); + +use App\Http\Controllers\ShareController; +use App\Models\ShareRequest; +use App\Services\ShareService; +use Illuminate\Http\Request; + +beforeEach(function (): void { + $this->createUsers(); + $this->actingAs($this->admin); + $this->controller = app()->makeWith(ShareController::class, ['service' => app()->make(ShareService::class)]); +}); + +describe('200', function (): void { + test('received method returns empty array initially', function (): void { + $response = $this->controller->received(); + + expect($response->getStatusCode()) + ->toEqual(200) + ->and($response->getData(true)) + ->toEqual(['data' => []]); + }); + + test('sent method returns empty array initially', function (): void { + $response = $this->controller->sent(); + + expect($response->getStatusCode()) + ->toEqual(200) + ->and($response->getData(true)) + ->toEqual(['data' => []]); + }); + + test('count method returns zero initially', function (): void { + $response = $this->controller->count(); + + expect($response->getStatusCode()) + ->toEqual(200) + ->and($response->getData(true)) + ->toEqual(['count' => 0]); + }); + + test('create method creates share requests', function (): void { + $request = Request::create('/api/share', 'POST', [ + 'entity_ids' => [1, 2], + 'entity_type' => 'article', + 'user_ids' => [$this->user->id], + ]); + + $response = $this->controller->create($request); + + expect($response->getStatusCode()) + ->toEqual(200) + ->and($response->getData(true)['message']) + ->toContain('Created'); + }); + + test('reject method rejects share request', function (): void { + $shareRequest = ShareRequest::create([ + 'sender_id' => $this->user->id, + 'receiver_id' => $this->admin->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + $response = $this->controller->reject($shareRequest->id); + + expect($response->getStatusCode()) + ->toEqual(200) + ->and($response->getData(true)) + ->toEqual(['message' => 'Share request rejected']); + }); + + test('cancel method cancels share request', function (): void { + $shareRequest = ShareRequest::create([ + 'sender_id' => $this->admin->id, + 'receiver_id' => $this->user->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + $response = $this->controller->cancel($shareRequest->id); + + expect($response->getStatusCode()) + ->toEqual(200) + ->and($response->getData(true)) + ->toEqual(['message' => 'Share request cancelled']); + }); +}); + +describe('400', function (): void { + test('reject method returns 400 for non-existent request', function (): void { + $response = $this->controller->reject(99999); + + expect($response->getStatusCode()) + ->toEqual(400) + ->and($response->getData(true)['error']) + ->toContain('not found'); + }); + + test('cancel method returns 400 for non-existent request', function (): void { + $response = $this->controller->cancel(99999); + + expect($response->getStatusCode()) + ->toEqual(400) + ->and($response->getData(true)['error']) + ->toContain('not found'); + }); + + test('accept method returns 400 for non-existent request', function (): void { + $response = $this->controller->accept(99999); + + expect($response->getStatusCode()) + ->toEqual(400) + ->and($response->getData(true)['error']) + ->toContain('not found'); + }); +}); diff --git a/modules/nuc_share/tests/Feature/Services/ShareServiceTest.php b/modules/nuc_share/tests/Feature/Services/ShareServiceTest.php new file mode 100644 index 0000000..aa23a36 --- /dev/null +++ b/modules/nuc_share/tests/Feature/Services/ShareServiceTest.php @@ -0,0 +1,193 @@ +group('share-service'); + +use App\Models\ShareRequest; +use App\Models\User; +use App\Services\ShareService; + +beforeEach(function (): void { + $this->user = User::factory()->create(); + $this->otherUser = User::factory()->create(); + + $this->service = new ShareService; + $this->actingAs($this->user); +}); + +describe('createShareRequest', function (): void { + test('can create share request for single user', function (): void { + $response = $this->service->createShareRequest( + [1, 2], + 'article', + [$this->otherUser->id] + ); + + expect($response['message']) + ->toContain('Created 1 share requests') + ->and(ShareRequest::count()) + ->toBe(1); + }); + + test('can create share request for multiple users', function (): void { + $thirdUser = User::factory()->create(); + + $response = $this->service->createShareRequest( + [1], + 'article', + [$this->otherUser->id, $thirdUser->id] + ); + + expect($response['message']) + ->toContain('Created 2 share requests') + ->and(ShareRequest::count()) + ->toBe(2); + }); +}); + +describe('getReceivedRequests', function (): void { + test('returns empty array when no requests', function (): void { + $response = $this->service->getReceivedRequests(); + + expect($response) + ->toBeArray() + ->toBeEmpty(); + }); + + test('returns pending requests for current user', function (): void { + ShareRequest::create([ + 'sender_id' => $this->otherUser->id, + 'receiver_id' => $this->user->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + $response = $this->service->getReceivedRequests(); + + expect($response) + ->toBeArray() + ->toHaveCount(1); + }); + + test('does not return accepted requests', function (): void { + ShareRequest::create([ + 'sender_id' => $this->otherUser->id, + 'receiver_id' => $this->user->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'accepted', + ]); + + $response = $this->service->getReceivedRequests(); + + expect($response) + ->toBeArray() + ->toBeEmpty(); + }); +}); + +describe('getSentRequests', function (): void { + test('returns empty array when no requests sent', function (): void { + $response = $this->service->getSentRequests(); + + expect($response) + ->toBeArray() + ->toBeEmpty(); + }); + + test('returns all sent requests', function (): void { + ShareRequest::create([ + 'sender_id' => $this->user->id, + 'receiver_id' => $this->otherUser->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + $response = $this->service->getSentRequests(); + + expect($response) + ->toBeArray() + ->toHaveCount(1); + }); +}); + +describe('getPendingCount', function (): void { + test('returns zero when no pending requests', function (): void { + $response = $this->service->getPendingCount(); + + expect($response)->toBe(0); + }); + + test('returns correct count of pending requests', function (): void { + ShareRequest::create([ + 'sender_id' => $this->otherUser->id, + 'receiver_id' => $this->user->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + ShareRequest::create([ + 'sender_id' => $this->otherUser->id, + 'receiver_id' => $this->user->id, + 'entity_type' => 'task', + 'entity_ids' => [2], + 'status' => 'pending', + ]); + + $response = $this->service->getPendingCount(); + + expect($response)->toBe(2); + }); +}); + +describe('rejectRequest', function (): void { + test('can reject a pending request', function (): void { + $shareRequest = ShareRequest::create([ + 'sender_id' => $this->otherUser->id, + 'receiver_id' => $this->user->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + $response = $this->service->rejectRequest($shareRequest->id); + + expect($response['message']) + ->toBe('Share request rejected') + ->and($shareRequest->fresh()->status) + ->toBe('rejected'); + }); + + test('throws exception for non-existent request', function (): void { + $this->service->rejectRequest(99999); + })->throws(Exception::class, 'Share request not found'); +}); + +describe('cancelRequest', function (): void { + test('can cancel a sent request', function (): void { + $shareRequest = ShareRequest::create([ + 'sender_id' => $this->user->id, + 'receiver_id' => $this->otherUser->id, + 'entity_type' => 'article', + 'entity_ids' => [1], + 'status' => 'pending', + ]); + + $response = $this->service->cancelRequest($shareRequest->id); + + expect($response['message']) + ->toBe('Share request cancelled') + ->and(ShareRequest::find($shareRequest->id)) + ->toBeNull(); + }); + + test('throws exception for non-existent request', function (): void { + $this->service->cancelRequest(99999); + })->throws(Exception::class, 'Share request not found'); +}); diff --git a/modules/nuc_share/tests/Pest.php b/modules/nuc_share/tests/Pest.php new file mode 100644 index 0000000..838ff69 --- /dev/null +++ b/modules/nuc_share/tests/Pest.php @@ -0,0 +1,8 @@ +group('nuc-share') + ->in('.'); + +uses() + ->group('nuc-share-ft') + ->in('Feature'); + +/** + * Feature groups + */ +uses() + ->group('api') + ->in('Feature/Api'); + +uses() + ->group('feature') + ->in('Feature'); + +uses() + ->group('controllers') + ->in('Feature/Controllers'); + +uses() + ->group('share-controllers') + ->in('Feature/Controllers'); + +uses() + ->group('services') + ->in('Feature/Services'); + +uses() + ->group('share-services') + ->in('Feature/Services'); diff --git a/modules/nuc_share/tests/TestUses.php b/modules/nuc_share/tests/TestUses.php new file mode 100644 index 0000000..71046c8 --- /dev/null +++ b/modules/nuc_share/tests/TestUses.php @@ -0,0 +1,40 @@ +beforeEach(function (): void { + $this->artisan('migrate:fresh'); + }) + ->in('Feature'); +} else { + uses( + Tests\TestCase::class, + ) + ->in('Feature'); + + uses( + RefreshDatabase::class + ) + ->in( + // + ); + + uses( + DatabaseMigrations::class + ) + ->in( + 'Feature/Api/HTTP200Test.php', + 'Feature/Api/HTTP401Test.php', + 'Feature/Api/HTTP500Test.php', + + 'Feature/Controllers', + 'Feature/Services', + ); +} diff --git a/modules/nuc_share/vitests/constants/api/index.ts b/modules/nuc_share/vitests/constants/api/index.ts new file mode 100644 index 0000000..01d6ce3 --- /dev/null +++ b/modules/nuc_share/vitests/constants/api/index.ts @@ -0,0 +1,2 @@ +export * from './share' + diff --git a/modules/nuc_share/vitests/constants/api/share.ts b/modules/nuc_share/vitests/constants/api/share.ts new file mode 100644 index 0000000..524a42a --- /dev/null +++ b/modules/nuc_share/vitests/constants/api/share.ts @@ -0,0 +1,23 @@ +import type { ShareRequestInterface } from '../../../atomic/types' + +export const mockShareRequest: ShareRequestInterface = { + id: 99, + sender_id: 1, + receiver_id: 2, + entity_type: 'article', + entity_ids: [1, 2, 3], + status: 'pending', + created_at: new Date().toISOString(), + sender: { + id: 1, + name: 'Test Sender', + email: 'sender@example.com', + }, + receiver: { + id: 2, + name: 'Test Receiver', + email: 'receiver@example.com', + }, +} + +export const mockShareRequests: ShareRequestInterface[] = [mockShareRequest] diff --git a/modules/nuc_share/vitests/constants/index.ts b/modules/nuc_share/vitests/constants/index.ts new file mode 100644 index 0000000..0bf83f7 --- /dev/null +++ b/modules/nuc_share/vitests/constants/index.ts @@ -0,0 +1,2 @@ +export * from './api' + diff --git a/modules/nuc_share/vitests/index.ts b/modules/nuc_share/vitests/index.ts new file mode 100644 index 0000000..f23b1eb --- /dev/null +++ b/modules/nuc_share/vitests/index.ts @@ -0,0 +1,2 @@ +export * from './constants' + diff --git a/modules/nuc_share/vitests/utils/api/200.test.ts b/modules/nuc_share/vitests/utils/api/200.test.ts new file mode 100644 index 0000000..3c778e1 --- /dev/null +++ b/modules/nuc_share/vitests/utils/api/200.test.ts @@ -0,0 +1,121 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ShareRequestsInterface } from '../../../atomic/types' +import { useShareRequests } from '../../../atomic/utils/requests' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function createFetchMock(): ReturnType { + return vi.fn( + async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input) + const method = (init?.method ?? 'GET').toUpperCase() + + if (method === 'POST' && url.includes('/share/1/accept')) { + return jsonResponse({ data: { message: 'Accepted' } }) + } + if (method === 'POST' && url.includes('/share/1/reject')) { + return jsonResponse({ data: { message: 'Rejected' } }) + } + if (method === 'POST' && url.includes('/share/1/cancel')) { + return jsonResponse({ data: { message: 'Cancelled' } }) + } + if (url.includes('/share/received')) { + return jsonResponse({ data: [] }) + } + if (url.includes('/share/sent')) { + return jsonResponse({ data: [] }) + } + if (url.includes('/share/count')) { + return jsonResponse({ data: { count: 0 } }) + } + + return jsonResponse({ data: null }) + } + ) +} + +describe('useShareRequests', (): void => { + const originalFetch = globalThis.fetch + const originalApiUrl = process.env.NEXT_PUBLIC_API_URL + + let fetchMock: ReturnType + + beforeEach((): void => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_API_URL = 'http://test-api.test' + fetchMock = createFetchMock() + globalThis.fetch = fetchMock as typeof fetch + }) + + afterEach((): void => { + globalThis.fetch = originalFetch + process.env.NEXT_PUBLIC_API_URL = originalApiUrl + }) + + it('loadAll fetches received, sent, and count', async (): Promise => { + const { result } = renderHook((): ShareRequestsInterface => useShareRequests()) + + await act(async () => { + await result.current.loadAll() + }) + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('share/received'), + expect.objectContaining({ method: 'GET' }) + ) + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('share/sent'), + expect.objectContaining({ method: 'GET' }) + ) + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('share/count'), + expect.objectContaining({ method: 'GET' }) + ) + }) + + it('acceptRequest sends POST to accept endpoint', async (): Promise => { + const { result } = renderHook((): ShareRequestsInterface => useShareRequests()) + + await act(async () => { + await result.current.acceptRequest(1) + }) + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('share/1/accept'), + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('rejectRequest sends POST to reject endpoint', async (): Promise => { + const { result } = renderHook((): ShareRequestsInterface => useShareRequests()) + + await act(async () => { + await result.current.rejectRequest(1) + }) + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('share/1/reject'), + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('cancelRequest sends POST to cancel endpoint', async (): Promise => { + const { result } = renderHook((): ShareRequestsInterface => useShareRequests()) + + await act(async () => { + await result.current.cancelRequest(1) + }) + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('share/1/cancel'), + expect.objectContaining({ method: 'POST' }) + ) + }) +}) diff --git a/modules/nuc_share/vitests/utils/use_share_selection.test.ts b/modules/nuc_share/vitests/utils/use_share_selection.test.ts new file mode 100644 index 0000000..096531a --- /dev/null +++ b/modules/nuc_share/vitests/utils/use_share_selection.test.ts @@ -0,0 +1,125 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { useShareSelection } from '../../components/share-checkbox/utils/use_share_selection' + +describe('useShareSelection', (): void => { + const mockItems = [{ id: 1 }, { id: 2 }, { id: 3 }] + + it('initializes with empty selection', (): void => { + const { result } = renderHook(() => useShareSelection(mockItems)) + + expect(result.current.selected).toEqual({}) + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isIndeterminate).toBe(false) + }) + + it('toggle selects and deselects item', (): void => { + const { result } = renderHook(() => useShareSelection(mockItems)) + + expect(result.current.isSelected(1)).toBe(false) + + act(() => result.current.toggle(1)) + expect(result.current.isSelected(1)).toBe(true) + + act(() => result.current.toggle(1)) + expect(result.current.isSelected(1)).toBe(false) + }) + + it('selectAll selects all items', (): void => { + const { result } = renderHook(() => useShareSelection(mockItems)) + + act(() => result.current.selectAll()) + + expect(result.current.isAllSelected).toBe(true) + expect(result.current.isSelected(1)).toBe(true) + expect(result.current.isSelected(2)).toBe(true) + expect(result.current.isSelected(3)).toBe(true) + }) + + it('deselectAll clears selection', (): void => { + const { result } = renderHook(() => useShareSelection(mockItems)) + + act(() => result.current.selectAll()) + expect(result.current.isAllSelected).toBe(true) + + act(() => result.current.deselectAll()) + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isSelected(1)).toBe(false) + expect(result.current.isSelected(2)).toBe(false) + expect(result.current.isSelected(3)).toBe(false) + }) + + it('toggleAll selects all when none selected', (): void => { + const { result } = renderHook(() => useShareSelection(mockItems)) + + act(() => result.current.toggleAll()) + expect(result.current.isAllSelected).toBe(true) + }) + + it('toggleAll deselects all when all selected', (): void => { + const { result } = renderHook(() => useShareSelection(mockItems)) + + act(() => result.current.selectAll()) + expect(result.current.isAllSelected).toBe(true) + + act(() => result.current.toggleAll()) + expect(result.current.isAllSelected).toBe(false) + }) + + it('isIndeterminate is true when some but not all selected', (): void => { + const { result } = renderHook(() => useShareSelection(mockItems)) + + act(() => result.current.toggle(1)) + expect(result.current.isIndeterminate).toBe(true) + expect(result.current.isAllSelected).toBe(false) + + act(() => result.current.toggle(2)) + expect(result.current.isIndeterminate).toBe(true) + + act(() => result.current.toggle(3)) + expect(result.current.isIndeterminate).toBe(false) + expect(result.current.isAllSelected).toBe(true) + }) + + it('getSelectedItems returns selected items', (): void => { + const { result } = renderHook(() => useShareSelection(mockItems)) + + act(() => result.current.toggle(1)) + act(() => result.current.toggle(3)) + + const selectedItems = result.current.getSelectedItems<{ id: number }>() + expect(selectedItems).toHaveLength(2) + expect(selectedItems.map((i) => i.id)).toEqual([1, 3]) + }) + + it('clear removes all selections', (): void => { + const { result } = renderHook(() => useShareSelection(mockItems)) + + act(() => result.current.selectAll()) + expect(result.current.isAllSelected).toBe(true) + + act(() => result.current.clear()) + expect(result.current.isAllSelected).toBe(false) + }) + + it('handles empty items array', (): void => { + const { result } = renderHook(() => useShareSelection([])) + + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isIndeterminate).toBe(false) + + act(() => result.current.selectAll()) + expect(result.current.getSelectedItems()).toEqual([]) + }) + + it('handles undefined items', (): void => { + const { result } = renderHook(() => useShareSelection(undefined)) + + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isIndeterminate).toBe(false) + + act(() => result.current.selectAll()) + expect(result.current.getSelectedItems()).toEqual([]) + }) +})