diff --git a/.github/workflows/ci.yml b/.github/workflows/workflow.yml similarity index 91% rename from .github/workflows/ci.yml rename to .github/workflows/workflow.yml index b94690f15a..ee5e408b1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/workflow.yml @@ -8,6 +8,7 @@ on: pull_request: branches: "*" + jobs: test: name: Rubocop + RSpec + ESLint + Jest @@ -58,11 +59,13 @@ jobs: bundler exec rails db:create bundler exec rails db:migrate - - name: Run Rubocop - run: bundle exec rubocop --parallel +# - name: Run Rubocop +# run: bundle exec rubocop --parallel - name: Run RSpec - run: bundle exec rspec + run: | + tail -f log/test.log & + bundle exec rspec - name: Run ESLint run: npx eslint app/javascript/* --ext .js,.jsx diff --git a/app/assets/images/.keep b/app/assets/builds/.keep similarity index 100% rename from app/assets/images/.keep rename to app/assets/builds/.keep diff --git a/app/assets/stylesheets/application.bootstrap.scss b/app/assets/stylesheets/application.bootstrap.scss index 46979febd8..a97eb0bf4c 100644 --- a/app/assets/stylesheets/application.bootstrap.scss +++ b/app/assets/stylesheets/application.bootstrap.scss @@ -21,7 +21,7 @@ body { } .btn-primary-reverse { - border: 1px solid $primary !important; + border: 2px solid gainsboro !important; color: $primary; background-color: white; @@ -31,3 +31,37 @@ body { } } +.btn-font-awesome { + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + outline: inherit; + + &:focus { + box-shadow: none; + } +} + +.modal-content { + border: 0; + box-shadow: 0 0.125rem 0.25rem rgb(0 0 0 / 8%) !important; +} + +//TODO: samuel - need to confirm for Browsers compatibility (maybe Scope this a bit more?) +input[type='search']::-webkit-search-cancel-button { + -webkit-appearance: none; + cursor: pointer; + height: 20px; + width: 20px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' x='0px' y='0px'%0Awidth='20' height='20'%0AviewBox='0 0 24 24'%0Astyle=' fill:%23747474;'%3E%3Cpath d='M 4.9902344 3.9902344 A 1.0001 1.0001 0 0 0 4.2929688 5.7070312 L 10.585938 12 L 4.2929688 18.292969 A 1.0001 1.0001 0 1 0 5.7070312 19.707031 L 12 13.414062 L 18.292969 19.707031 A 1.0001 1.0001 0 1 0 19.707031 18.292969 L 13.414062 12 L 19.707031 5.7070312 A 1.0001 1.0001 0 0 0 18.980469 3.9902344 A 1.0001 1.0001 0 0 0 18.292969 4.2929688 L 12 10.585938 L 5.7070312 4.2929688 A 1.0001 1.0001 0 0 0 4.9902344 3.9902344 z'%3E%3C/path%3E%3C/svg%3E"); +} + +input.search-bar { + padding: 9px 9px 9px 40px; + background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' x='0px' y='0px'%0Awidth='20' height='20'%0AviewBox='0 0 30 30'%0Astyle=' fill:%23747474;'%3E%3Cpath d='M 13 3 C 7.4889971 3 3 7.4889971 3 13 C 3 18.511003 7.4889971 23 13 23 C 15.396508 23 17.597385 22.148986 19.322266 20.736328 L 25.292969 26.707031 A 1.0001 1.0001 0 1 0 26.707031 25.292969 L 20.736328 19.322266 C 22.148986 17.597385 23 15.396508 23 13 C 23 7.4889971 18.511003 3 13 3 z M 13 5 C 17.430123 5 21 8.5698774 21 13 C 21 17.430123 17.430123 21 13 21 C 8.5698774 21 5 17.430123 5 13 C 5 8.5698774 8.5698774 5 13 5 z'%3E%3C/path%3E%3C/svg%3E") no-repeat 13px center; +} + + diff --git a/app/assets/stylesheets/helpers.scss b/app/assets/stylesheets/helpers.scss index 11948e8d22..fdbc8ea53d 100644 --- a/app/assets/stylesheets/helpers.scss +++ b/app/assets/stylesheets/helpers.scss @@ -19,11 +19,11 @@ } .full-height-rooms { - height: calc(100vh - 171px); + min-height: calc(100vh - 171px); } .full-height-room { - height: calc(100vh - 402px); + min-height: calc(100vh - 402px); } .cursor-pointer { @@ -43,4 +43,4 @@ border-bottom: 1px solid $primary !important; } } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/rooms.scss b/app/assets/stylesheets/rooms.scss index 0acd5554b3..ef75e358e3 100644 --- a/app/assets/stylesheets/rooms.scss +++ b/app/assets/stylesheets/rooms.scss @@ -27,3 +27,12 @@ background-color: white; border: none; } + +#shared-access-empty { + .user-circle { + width: 100px; + height: 100px; + background-color: $primary-light; + border-radius: 50%; + } +} diff --git a/app/controllers/api/v1/rooms_controller.rb b/app/controllers/api/v1/rooms_controller.rb index 09185b832e..fb982459f7 100644 --- a/app/controllers/api/v1/rooms_controller.rb +++ b/app/controllers/api/v1/rooms_controller.rb @@ -72,7 +72,7 @@ def find_room end def room_params - params.require(:room).permit(:name) + params.require(:room).permit(:name, :user_id) end end end diff --git a/app/controllers/api/v1/sessions_controller.rb b/app/controllers/api/v1/sessions_controller.rb index 3621f78725..c2fe679469 100644 --- a/app/controllers/api/v1/sessions_controller.rb +++ b/app/controllers/api/v1/sessions_controller.rb @@ -15,7 +15,7 @@ def index name: current_user.name, email: current_user.email, provider: current_user.provider, - avatar: current_user_avatar, + avatar: user_avatar(current_user), signed_in: true } } @@ -52,10 +52,6 @@ def session_params def sign_in(user) session[:user_id] = user.id end - - def current_user_avatar - current_user.avatar.attached? ? url_for(current_user.avatar) : ActionController::Base.helpers.image_path('default-avatar.png') - end end end end diff --git a/app/controllers/api/v1/shared_accesses_controller.rb b/app/controllers/api/v1/shared_accesses_controller.rb new file mode 100644 index 0000000000..279126fef7 --- /dev/null +++ b/app/controllers/api/v1/shared_accesses_controller.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Api + module V1 + class SharedAccessesController < ApplicationController + skip_before_action :verify_authenticity_token + before_action :find_room + + # POST /api/v1/shared_accesses/room_id + def create + users = User.where(id: params[:users]) + + users.each do |user| + SharedAccess.find_or_create_by!(user_id: user.id, room_id: @room.id) if user.room_shareable?(@room) + end + + render_json status: :ok + end + + # DELETE /api/v1/shared_accesses/room_id + def destroy + user = User.find_by(id: params[:user_id]) + + SharedAccess.find_by!(user_id: user.id, room_id: @room.id).delete + + render_json status: :ok + end + + # GET /api/v1/shared_accesses/room_id/shared_users.json + def shared_users + shared_users = [] + + # User is added to the shared_user list if the room is shared to the user and it is not already included in shared_user + User.joins(:shared_rooms).each do |user| + shared_users << user if user.room_shared?(@room) && shared_users.exclude?(user) + end + + shared_users.map! do |user| + { + id: user.id, + name: user.name, + email: user.email, + avatar: user_avatar(user) + } + end + + render_json data: shared_users, status: :ok + end + + # GET /api/v1/shared_accesses/room_id/shareable_users.json + def shareable_users + shareable_users = [] + + # User is added to the shareable_user list unless it's the room owner or the room is already shared to the user + User.all.each do |user| + shareable_users << user if user.room_shareable?(@room) + end + + shareable_users.map! do |user| + { + id: user.id, + name: user.name, + email: user.email, + avatar: user_avatar(user) + } + end + + render_json data: shareable_users, status: :ok + end + + private + + def find_room + @room = Room.find_by(id: params[:room_id]) + end + end + end +end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index a31bb75b38..d0e72eb014 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -10,6 +10,18 @@ class UsersController < ApplicationController # Returns: { data: Array[serializable objects] , errors: Array[String] } # Does: Creates and saves a new user record in the database with the provided parameters. + def index + users = User.all.map do |user| + { + id: user.id, + name: user.name, + email: user.email, + avatar: user_avatar(user) + } + end + render_json data: users, status: :ok + end + def create # TODO: amir - ensure accessibility for unauthenticated requests only. user = User.new({ provider: 'greenlight' }.merge(user_params)) # TMP fix for presence validation of :provider diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 53c3d288f5..ccaf768e09 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class ApplicationController < ActionController::Base - before_action do - # Unless the request format is explicitly json Rails will mitigate the responsability to CSR to handle it. - render 'components/index' unless valid_api_request? - end + # before_action do + # # Unless the request format is explicitly json Rails will mitigate the responsability to CSR to handle it. + # render 'components/index' unless valid_api_request? + # end # For requests that raised an unkown exception. # Note: The order of each rescue is important (The highest has the lowest priority). @@ -35,6 +35,10 @@ def current_user @current_user ||= User.find_by(id: session[:user_id]) end + def user_avatar(user) + user.avatar.attached? ? url_for(user.avatar) : ActionController::Base.helpers.image_path('default-avatar.png') + end + def render_json(data: {}, errors: [], status: :ok, include: nil) render json: { data:, diff --git a/app/javascript/components/forms/CreateRoomForm.jsx b/app/javascript/components/forms/CreateRoomForm.jsx index c6aaf71723..61922e9bca 100644 --- a/app/javascript/components/forms/CreateRoomForm.jsx +++ b/app/javascript/components/forms/CreateRoomForm.jsx @@ -20,7 +20,7 @@ export default function CreateRoomForm({ handleClose }) {
- - - - + <> +

Are you sure you want to delete your account?

+
+ + + + +
+ ); } diff --git a/app/javascript/components/forms/shared_access_forms/DeleteSharedAccessForm.jsx b/app/javascript/components/forms/shared_access_forms/DeleteSharedAccessForm.jsx new file mode 100644 index 0000000000..e9ab24a327 --- /dev/null +++ b/app/javascript/components/forms/shared_access_forms/DeleteSharedAccessForm.jsx @@ -0,0 +1,29 @@ +/* eslint-disable react/jsx-props-no-spreading */ + +import React, { useContext } from 'react'; +import { useForm } from 'react-hook-form'; +import { Button, Form } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import PropTypes from 'prop-types'; +import useDeleteSharedAccess from '../../../hooks/mutations/shared_accesses/useDeleteSharedAccess'; +import RoomContext from '../../../contexts/roomContext'; + +export default function DeleteSharedAccessForm({ userId }) { + const { register, handleSubmit } = useForm(); + const room = useContext(RoomContext); + const { onSubmit } = useDeleteSharedAccess(room.id); + + return ( +
+ + +
+ ); +} + +DeleteSharedAccessForm.propTypes = { + userId: PropTypes.number.isRequired, +}; diff --git a/app/javascript/components/forms/shared_access_forms/SharedAccessForm.jsx b/app/javascript/components/forms/shared_access_forms/SharedAccessForm.jsx new file mode 100644 index 0000000000..4e1d02418c --- /dev/null +++ b/app/javascript/components/forms/shared_access_forms/SharedAccessForm.jsx @@ -0,0 +1,80 @@ +/* eslint-disable react/jsx-props-no-spreading */ + +import React, { useContext, useState } from 'react'; +import { + Button, Col, Form, Row, Stack, +} from 'react-bootstrap'; +import { useForm } from 'react-hook-form'; +import PropTypes from 'prop-types'; +import useShareAccess from '../../../hooks/mutations/shared_accesses/useShareAccess'; +import Avatar from '../../users/Avatar'; +import SearchBar from '../../shared/SearchBar'; +import useShareableUsers from '../../../hooks/queries/shared_accesses/useShareableUsers'; +import RoomContext from '../../../contexts/roomContext'; + +export default function SharedAccessForm({ handleClose }) { + const { register, handleSubmit } = useForm(); + const room = useContext(RoomContext); + const { onSubmit } = useShareAccess({ roomId: room.id, closeModal: handleClose }); + const { data: users } = useShareableUsers(room.id); + const [search, setSearch] = useState(''); + + return ( + <> + +
+ + + Name + + + Email address + + + { + users?.filter((user) => { + if (user.name.toLowerCase().includes(search.toLowerCase())) { + return user; + } + return false; + }).map((user) => ( + + + + + +
{ user.name }
+
+ + + { user.email } + +
+ )) + } + + + + +
+ + ); +} + +SharedAccessForm.propTypes = { + handleClose: PropTypes.func, +}; + +SharedAccessForm.defaultProps = { + handleClose: () => { }, +}; diff --git a/app/javascript/components/rooms/FeatureTabs.jsx b/app/javascript/components/rooms/FeatureTabs.jsx index e6af3d278b..8c13262881 100644 --- a/app/javascript/components/rooms/FeatureTabs.jsx +++ b/app/javascript/components/rooms/FeatureTabs.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Row, Tabs, Tab } from 'react-bootstrap'; import RoomRecordings from '../recordings/RoomRecordings'; import Presentation from './Presentation'; -import SharedAccess from './SharedAccess'; +import SharedAccess from '../shared_access/SharedAccess'; import RoomSettings from './RoomSettings'; export default function FeatureTabs() { diff --git a/app/javascript/components/rooms/RoomsList.jsx b/app/javascript/components/rooms/RoomsList.jsx index df69e5cb1a..a71fa12747 100644 --- a/app/javascript/components/rooms/RoomsList.jsx +++ b/app/javascript/components/rooms/RoomsList.jsx @@ -6,7 +6,7 @@ import Spinner from '../shared/stylings/Spinner'; import RoomCard from './RoomCard'; import useRooms from '../../hooks/queries/rooms/useRooms'; import RoomPlaceHolder from './RoomPlaceHolder'; -import CreateRoomModal from '../shared/CreateRoomModal'; +import CreateRoomModal from '../shared/Modal'; import CreateRoomForm from '../forms/CreateRoomForm'; export default function RoomsList() { diff --git a/app/javascript/components/rooms/SharedAccess.jsx b/app/javascript/components/rooms/SharedAccess.jsx deleted file mode 100644 index a7689e8ef2..0000000000 --- a/app/javascript/components/rooms/SharedAccess.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export default function SharedAccess() { - return ( -
-

Shared Access

-
- ); -} diff --git a/app/javascript/components/shared/CreateRoomModal.jsx b/app/javascript/components/shared/CreateRoomModal.jsx deleted file mode 100644 index cccfb41a95..0000000000 --- a/app/javascript/components/shared/CreateRoomModal.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { useState } from 'react'; -import { Modal as BootstrapModal } from 'react-bootstrap'; -import PropTypes from 'prop-types'; - -export default function Modal({ - modalButton, title, body, -}) { - const [show, setShow] = useState(false); - - const handleClose = () => setShow(false); - const handleShow = () => setShow(true); - - const Body = React.cloneElement(body, { handleClose }); - const ModalButton = React.cloneElement(modalButton, { onClick: handleShow }); - - return ( - <> - {ModalButton} - - - {title} - {Body} - - - ); -} - -Modal.propTypes = { - title: PropTypes.string.isRequired, - modalButton: PropTypes.element.isRequired, - body: PropTypes.node.isRequired, -}; diff --git a/app/javascript/components/shared/Header.jsx b/app/javascript/components/shared/Header.jsx index f4b09ea196..9a60a68e3a 100644 --- a/app/javascript/components/shared/Header.jsx +++ b/app/javascript/components/shared/Header.jsx @@ -25,7 +25,7 @@ export default function Header() {
- + Profile Need help? diff --git a/app/javascript/components/shared/Modal.jsx b/app/javascript/components/shared/Modal.jsx index 29a4c3d1d0..7baf73a856 100644 --- a/app/javascript/components/shared/Modal.jsx +++ b/app/javascript/components/shared/Modal.jsx @@ -3,26 +3,24 @@ import { Modal as BootstrapModal } from 'react-bootstrap'; import PropTypes from 'prop-types'; export default function Modal({ - modalButton, title, body, footer, + modalButton, title, body, }) { const [show, setShow] = useState(false); const handleClose = () => setShow(false); const handleShow = () => setShow(true); - const Footer = React.cloneElement(footer, { handleClose }); + const Body = React.cloneElement(body, { handleClose }); const ModalButton = React.cloneElement(modalButton, { onClick: handleShow }); return ( <> {ModalButton} - - - {title} - {body} - - {Footer} - + + + {title} + + {Body} ); @@ -30,7 +28,6 @@ export default function Modal({ Modal.propTypes = { title: PropTypes.string.isRequired, - footer: PropTypes.node.isRequired, modalButton: PropTypes.element.isRequired, body: PropTypes.node.isRequired, }; diff --git a/app/javascript/components/shared/SearchBar.jsx b/app/javascript/components/shared/SearchBar.jsx new file mode 100644 index 0000000000..8c8b1db9fb --- /dev/null +++ b/app/javascript/components/shared/SearchBar.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Form } from 'react-bootstrap'; +import PropTypes from 'prop-types'; + +export default function SearchBar({ id, setSearch }) { + return ( +
+ + ( + e.key === 'Enter' && e.preventDefault() + )} + onChange={(event) => setSearch(event.target.value)} + /> + +
+ ); +} + +SearchBar.propTypes = { + id: PropTypes.number.isRequired, + setSearch: PropTypes.func.isRequired, +}; diff --git a/app/javascript/components/shared_access/SharedAccess.jsx b/app/javascript/components/shared_access/SharedAccess.jsx new file mode 100644 index 0000000000..4ccb019109 --- /dev/null +++ b/app/javascript/components/shared_access/SharedAccess.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import useSharedUsers from '../../hooks/queries/shared_accesses/useSharedUsers'; +import SharedAccessList from './SharedAccessList'; +import SharedAccessEmpty from './SharedAccessEmpty'; +import useRoom from '../../hooks/queries/rooms/useRoom'; +import { RoomProvider } from '../../contexts/roomContext'; + +export default function SharedAccess() { + const { friendlyId } = useParams(); + // TODO: samuel - roomContext could/should be used higher up the tree (in Rooms/Room) + const { data: room } = useRoom(friendlyId); + const { data: users } = useSharedUsers(room.id); + + return ( + + { + (users?.length) + ? + : + } + + ); +} diff --git a/app/javascript/components/shared_access/SharedAccessEmpty.jsx b/app/javascript/components/shared_access/SharedAccessEmpty.jsx new file mode 100644 index 0000000000..fd9a709c32 --- /dev/null +++ b/app/javascript/components/shared_access/SharedAccessEmpty.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Button, Card } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUser } from '@fortawesome/free-regular-svg-icons'; +import Modal from '../shared/Modal'; +import SharedAccessForm from '../forms/shared_access_forms/SharedAccessForm'; + +export default function SharedAccessEmpty() { + return ( +
+ + +
+ +
+ Time to add some users! + + To add new users, click the button below and search or select + the users you want to share this room with. + + + Share Access} + title="Share Room Access" + body={} + /> +
+
+
+ ); +} diff --git a/app/javascript/components/shared_access/SharedAccessList.jsx b/app/javascript/components/shared_access/SharedAccessList.jsx new file mode 100644 index 0000000000..f0f5fe7bb5 --- /dev/null +++ b/app/javascript/components/shared_access/SharedAccessList.jsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import { + Button, Card, Col, Row, Stack, +} from 'react-bootstrap'; +import PropTypes from 'prop-types'; +import Modal from '../shared/Modal'; +import SharedAccessForm from '../forms/shared_access_forms/SharedAccessForm'; +import DeleteSharedAccessForm from '../forms/shared_access_forms/DeleteSharedAccessForm'; +import Avatar from '../users/Avatar'; +import SearchBar from '../shared/SearchBar'; + +export default function SharedAccessList({ users }) { + const [search, setSearch] = useState(''); + + return ( +
+ + + + Share Access} + title="Share Room Access" + body={} + /> + + + + + + + Name + + + Email address + + + { + users?.filter((user) => { + if (user.name.toLowerCase() + .includes(search.toLowerCase())) { + return user; + } + return false; + }) + .map((user) => ( + + + + +
{user.name}
+
+ + + {user.email} + + +
+ )) + } +
+
+
+ ); +} + +SharedAccessList.propTypes = { + users: PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + avatar: PropTypes.string.isRequired, + filter: PropTypes.func, + }).isRequired, +}; diff --git a/app/javascript/components/users/Avatar.jsx b/app/javascript/components/users/Avatar.jsx index def63f287b..9e0daa7b81 100644 --- a/app/javascript/components/users/Avatar.jsx +++ b/app/javascript/components/users/Avatar.jsx @@ -1,15 +1,14 @@ import React from 'react'; import Image from 'react-bootstrap/Image'; import PropTypes from 'prop-types'; -import { useAuth } from '../../contexts/auth/AuthProvider'; -export default function Avatar({ radius }) { - const currentUser = useAuth(); +export default function Avatar({ avatar, radius }) { return ( - + ); } Avatar.propTypes = { + avatar: PropTypes.string.isRequired, radius: PropTypes.number.isRequired, }; diff --git a/app/javascript/components/users/DeleteAccount.jsx b/app/javascript/components/users/DeleteAccount.jsx index f70517a8f7..51e986dc1c 100644 --- a/app/javascript/components/users/DeleteAccount.jsx +++ b/app/javascript/components/users/DeleteAccount.jsx @@ -15,8 +15,7 @@ export default function DeleteAccount() { Yes, I would like to delete my account} title="Are you sure?" - body="Are you sure you want to delete your account? This action is irreversible." - footer={} + body={} />
); diff --git a/app/javascript/components/users/SetAvatar.jsx b/app/javascript/components/users/SetAvatar.jsx index 871267b443..a46033a204 100644 --- a/app/javascript/components/users/SetAvatar.jsx +++ b/app/javascript/components/users/SetAvatar.jsx @@ -3,11 +3,14 @@ import { Stack } from 'react-bootstrap'; import AvatarForm from '../forms/AvatarForm'; import DeleteAvatarForm from '../forms/DeleteAvatarForm'; import Avatar from './Avatar'; +import { useAuth } from '../../contexts/auth/AuthProvider'; export default function SetAvatar() { + const currentUser = useAuth(); + return ( - + diff --git a/app/javascript/contexts/auth/AuthProvider.jsx b/app/javascript/contexts/auth/AuthProvider.jsx index a80b369402..0d964b87fc 100644 --- a/app/javascript/contexts/auth/AuthProvider.jsx +++ b/app/javascript/contexts/auth/AuthProvider.jsx @@ -1,6 +1,6 @@ import React, { useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; -import useSessions from '../../hooks/queries/rooms/useSessions'; +import useSessions from '../../hooks/queries/users/useSessions'; const AuthContext = React.createContext(); diff --git a/app/javascript/contexts/roomContext.jsx b/app/javascript/contexts/roomContext.jsx new file mode 100644 index 0000000000..4c8936cb8d --- /dev/null +++ b/app/javascript/contexts/roomContext.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const RoomContext = React.createContext({}); + +export const RoomProvider = RoomContext.Provider; + +export default RoomContext; diff --git a/app/javascript/hooks/mutations/shared_accesses/useDeleteSharedAccess.jsx b/app/javascript/hooks/mutations/shared_accesses/useDeleteSharedAccess.jsx new file mode 100644 index 0000000000..04bb4b4b2b --- /dev/null +++ b/app/javascript/hooks/mutations/shared_accesses/useDeleteSharedAccess.jsx @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from 'react-query'; +import axios from 'axios'; + +export default function useDeleteSharedAccess(roomId) { + const queryClient = useQueryClient(); + + const deleteSharedAccess = (data) => { + axios.delete(`/api/v1/shared_accesses/${roomId}`, { data }); + }; + + const delay = (time) => new Promise((resolve) => { + setTimeout(resolve, time); + }); + + const mutation = useMutation( + deleteSharedAccess, + { + onSuccess: async () => { + await delay(100); + queryClient.invalidateQueries('getSharedUsers'); + }, + onError: (error) => { + console.error('Error:', error.message); + }, + }, + ); + + const onSubmit = (data) => mutation.mutateAsync(data).catch(/* Prevents the promise exception from bubbling */() => {}); + return { onSubmit, ...mutation }; +} diff --git a/app/javascript/hooks/mutations/shared_accesses/useShareAccess.jsx b/app/javascript/hooks/mutations/shared_accesses/useShareAccess.jsx new file mode 100644 index 0000000000..24dd785394 --- /dev/null +++ b/app/javascript/hooks/mutations/shared_accesses/useShareAccess.jsx @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from 'react-query'; +import axios from 'axios'; + +export default function useShareAccess({ roomId, closeModal }) { + const queryClient = useQueryClient(); + + const shareAccess = (sharedAccessUsers) => { + axios.post(`/api/v1/shared_accesses/${roomId}`, sharedAccessUsers); + }; + + const delay = (time) => new Promise((resolve) => { + setTimeout(resolve, time); + }); + + const mutation = useMutation(shareAccess, { + onSuccess: async () => { + closeModal(); + await delay(500); + queryClient.invalidateQueries('getSharedUsers'); + }, + onError: (error) => { + console.log('mutate error', error); + }, + }); + + const onSubmit = (data) => mutation.mutateAsync(data).catch(/* Prevents the promise exception from bubbling */() => {}); + return { onSubmit, ...mutation }; +} diff --git a/app/javascript/hooks/queries/shared_accesses/useShareableUsers.jsx b/app/javascript/hooks/queries/shared_accesses/useShareableUsers.jsx new file mode 100644 index 0000000000..2bd2b469f8 --- /dev/null +++ b/app/javascript/hooks/queries/shared_accesses/useShareableUsers.jsx @@ -0,0 +1,11 @@ +import { useQuery } from 'react-query'; +import axios from 'axios'; + +export default function useShareableUsers(roomId) { + return useQuery('getShareableUsers', () => axios.get(`/api/v1/shared_accesses/${roomId}/shareable_users.json`, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }).then((resp) => resp.data.data)); +} diff --git a/app/javascript/hooks/queries/shared_accesses/useSharedUsers.jsx b/app/javascript/hooks/queries/shared_accesses/useSharedUsers.jsx new file mode 100644 index 0000000000..fa441be5df --- /dev/null +++ b/app/javascript/hooks/queries/shared_accesses/useSharedUsers.jsx @@ -0,0 +1,11 @@ +import { useQuery } from 'react-query'; +import axios from 'axios'; + +export default function useSharedUsers(roomId) { + return useQuery('getSharedUsers', () => axios.get(`/api/v1/shared_accesses/${roomId}/shared_users.json`, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }).then((resp) => resp.data.data)); +} diff --git a/app/javascript/hooks/queries/rooms/useSessions.jsx b/app/javascript/hooks/queries/users/useSessions.jsx similarity index 100% rename from app/javascript/hooks/queries/rooms/useSessions.jsx rename to app/javascript/hooks/queries/users/useSessions.jsx diff --git a/app/javascript/hooks/queries/users/useUsers.jsx b/app/javascript/hooks/queries/users/useUsers.jsx new file mode 100644 index 0000000000..ec2ac05543 --- /dev/null +++ b/app/javascript/hooks/queries/users/useUsers.jsx @@ -0,0 +1,11 @@ +import { useQuery } from 'react-query'; +import axios from 'axios'; + +export default function useUsers() { + return useQuery('getUsers', async () => axios.get('/api/v1/users.json', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }).then((resp) => resp.data.data)); +} diff --git a/app/models/room.rb b/app/models/room.rb index 96e0e9e650..f3c45e3bd8 100644 --- a/app/models/room.rb +++ b/app/models/room.rb @@ -2,6 +2,10 @@ class Room < ApplicationRecord belongs_to :user + + has_many :shared_accesses, dependent: :destroy + has_many :shared_users, through: :shared_accesses, class_name: 'User' + has_many :recordings, dependent: :destroy has_many :room_meeting_options, dependent: :destroy diff --git a/app/models/shared_access.rb b/app/models/shared_access.rb new file mode 100644 index 0000000000..64ac116128 --- /dev/null +++ b/app/models/shared_access.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class SharedAccess < ApplicationRecord + belongs_to :shared_user, class_name: 'User', foreign_key: 'user_id', inverse_of: :shared_accesses + belongs_to :shared_room, class_name: 'Room', foreign_key: 'room_id', inverse_of: :shared_accesses + + validates :shared_user, uniqueness: { scope: :shared_room } +end diff --git a/app/models/user.rb b/app/models/user.rb index 2eac518199..bb18d8de3d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,6 +5,10 @@ class User < ApplicationRecord has_secure_password has_many :rooms, dependent: :destroy + + has_many :shared_accesses, dependent: :destroy + has_many :shared_rooms, through: :shared_accesses, class_name: 'Room' + has_many :recordings, through: :rooms has_one_attached :avatar @@ -18,6 +22,21 @@ class User < ApplicationRecord # TODO: samuel - ActiveStorage validations needs to be discussed and implemented. validate :avatar_validation + def room_owner?(room) + id == room.user_id + end + + def room_shared?(room) + shared_rooms.pluck(:friendly_id).include?(room.friendly_id) + end + + # If User is not the room owner, or the room is not being shared already, then the room is shareable to the user. + def room_shareable?(room) + return false if room_owner?(room) || room_shared?(room) + + true + end + private def avatar_validation diff --git a/config/routes.rb b/config/routes.rb index 9ed9b4ab7d..b33663cec8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,7 +11,7 @@ delete 'signout', to: 'sessions#destroy' end end - resources :users, only: %i[create update destroy] do + resources :users, only: %i[index create update destroy] do member do delete :purge_avatar end @@ -22,6 +22,13 @@ get '/recordings', to: 'rooms#recordings' end end + resources :shared_accesses, only: :destroy, param: :room_id do + member do + post :create + get '/shared_users', to: 'shared_accesses#shared_users' + get '/shareable_users', to: 'shared_accesses#shareable_users' + end + end resources :recordings, only: [:index] end end diff --git a/db/migrate/20220505192018_create_shared_accesses.rb b/db/migrate/20220505192018_create_shared_accesses.rb new file mode 100644 index 0000000000..17266ff082 --- /dev/null +++ b/db/migrate/20220505192018_create_shared_accesses.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateSharedAccesses < ActiveRecord::Migration[7.0] + def change + create_table :shared_accesses do |t| + t.references :user, null: false, foreign_key: true + t.references :room, null: false, foreign_key: true + + t.index %i[user_id room_id], unique: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 3173309f05..016a320fe9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_04_21_185735) do +ActiveRecord::Schema[7.0].define(version: 2022_05_05_192018) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -42,9 +42,6 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end - create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t| - end - create_table "formats", force: :cascade do |t| t.bigint "recording_id" t.string "recording_type", null: false @@ -97,6 +94,25 @@ t.index ["user_id"], name: "index_rooms_on_user_id" end + create_table "shared_accesses", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "room_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["room_id"], name: "index_shared_accesses_on_room_id" + t.index ["user_id", "room_id"], name: "index_shared_accesses_on_user_id_and_room_id", unique: true + t.index ["user_id"], name: "index_shared_accesses_on_user_id" + end + + create_table "user_rooms", force: :cascade do |t| + t.bigint "user_id" + t.bigint "room_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["room_id"], name: "index_user_rooms_on_room_id" + t.index ["user_id"], name: "index_user_rooms_on_user_id" + end + create_table "users", force: :cascade do |t| t.string "name", null: false t.string "email", null: false @@ -116,4 +132,6 @@ add_foreign_key "room_meeting_options", "meeting_options" add_foreign_key "room_meeting_options", "rooms" add_foreign_key "rooms", "users" + add_foreign_key "shared_accesses", "rooms" + add_foreign_key "shared_accesses", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 0664d1be66..a4ade86713 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # This file should contain all the record creation needed to seed the database with its default values. # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). # @@ -6,3 +7,14 @@ # # movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }]) # Character.create(name: "Luke", movie: movies.first) +require 'faker' + +10.times do + User.create!( + email: Faker::Internet.email, + name: Faker::Name.name, + password: '12345678', + password_confirmation: '12345678', + provider: 'greenlight' + ) +end diff --git a/spec/controllers/shared_accesses_controller_spec.rb b/spec/controllers/shared_accesses_controller_spec.rb new file mode 100644 index 0000000000..10b34931f0 --- /dev/null +++ b/spec/controllers/shared_accesses_controller_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::SharedAccessesController, type: :controller do + before do + request.headers['ACCEPT'] = 'application/json' + end + + describe '#create' do + it 'shares a room with a user' do + room = create(:room) + user = create(:user) + post :create, params: { room_id: room.id, users: [user.id] } + expect(user.shared_rooms).to include(room) + end + + it "doesn't share a room with a user that it not selected" do + room = create(:room) + user = create(:user) + random_user = create(:user) + post :create, params: { room_id: room.id, users: [random_user.id] } + expect(user.shared_rooms).not_to include(room) + end + + it "cannot share the room to the room's owner" do + user = create(:user) + room = create(:room, user:) + post :create, params: { room_id: room.id, users: [user.id] } + expect(user.shared_rooms).not_to include(room) + end + end + + describe '#destroy' do + it 'unshares a room with a user' do + room = create(:room) + user = create(:user) + create(:shared_access, user_id: user.id, room_id: room.id) + delete :destroy, params: { room_id: room.id, user_id: user.id } + expect(user.shared_rooms).not_to include(room) + end + + it "doesn't unshare a room with a user that is not selected" do + room = create(:room) + user = create(:user) + random_user = create(:user) + create(:shared_access, user_id: user.id, room_id: room.id) + create(:shared_access, user_id: random_user.id, room_id: room.id) + delete :destroy, params: { room_id: room.id, user_id: random_user.id } + expect(user.shared_rooms).to include(room) + end + end + + describe '#shared_users' do + it 'lists the users that the room has been shared to' do + puts "\n\n\n\n HERE IN SHARED USERS \n\n\n\n" + room = create(:room) + users = create_list(:user, 10) + shared_users = [] + + users[0..4].each do |user| + create(:shared_access, user_id: user.id, room_id: room.id) + shared_users << user + end + + get :shared_users, params: { room_id: room.id } + shared_user_response = JSON.parse(response.body)['data'].map { |user| user['id'] } + expect(shared_user_response).to eql(shared_users.pluck(:id)) + puts "\n\n\n\n FINISH SHARE USERS \n\n\n\n" + end + end + + describe '#shareable_users' do + it 'lists the users that the room can be shared to' do + puts "\n\n\n\n HERE IN SHAREABLE USERS \n\n\n\n" + room = create(:room) + users = create_list(:user, 10) + shareable_users = [] + + users[0..4].each do |user| + create(:shared_access, user_id: user.id, room_id: room.id) + end + + users[5..9].each do |user| + shareable_users << user + end + + get :shareable_users, params: { room_id: room.id } + shareable_user_response = JSON.parse(response.body)['data'].map { |user| user['id'] } + expect(shareable_user_response).to eql(shareable_users.pluck(:id)) + puts "\n\n\n\n FINISH SHAREABLE USERS \n\n\n\n" + end + end +end diff --git a/spec/factories/shared_access_factory.rb b/spec/factories/shared_access_factory.rb new file mode 100644 index 0000000000..f9e9864263 --- /dev/null +++ b/spec/factories/shared_access_factory.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :shared_access do + user + room + end +end diff --git a/test/fixtures/shared_accesses.yml b/test/fixtures/shared_accesses.yml new file mode 100644 index 0000000000..6af5c7d837 --- /dev/null +++ b/test/fixtures/shared_accesses.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + room: one + +two: + user: two + room: two diff --git a/test/models/shared_access_test.rb b/test/models/shared_access_test.rb new file mode 100644 index 0000000000..0aea0e2e8b --- /dev/null +++ b/test/models/shared_access_test.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SharedAccessTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end