diff --git a/Makefile b/Makefile index fcecab71..c2550d31 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ db-reset: # 'database=' here is a variable used in schema.sql (-v). psql -v database=memcode -U postgres -f backend/db/schema.sql db-migrate: - psql -v database=memcode -U postgres -f backend/db/migrations/14.sql + psql -v database=memcode -U postgres -f backend/db/migrations/15.sql # dump and restore data db-dump: diff --git a/backend/api/StudentGroupApi/create.js b/backend/api/StudentGroupApi/create.js new file mode 100644 index 00000000..54397a9e --- /dev/null +++ b/backend/api/StudentGroupApi/create.js @@ -0,0 +1,13 @@ +import knex from '~/db/knex'; +import auth from '~/middlewares/auth'; + +const create = auth(async (request, response) => { + const userId = request.currentUser.id; + const title = request.body['title']; + + const createdGroup = (await knex('studentGroup').insert({ title, userId }).returning('*'))[0]; + + response.success(createdGroup); +}); + +export default create; diff --git a/backend/api/StudentGroupApi/getAll.js b/backend/api/StudentGroupApi/getAll.js new file mode 100644 index 00000000..f9b120c2 --- /dev/null +++ b/backend/api/StudentGroupApi/getAll.js @@ -0,0 +1,22 @@ +import knex from '~/db/knex'; +import auth from '~/middlewares/auth'; + +const getAll = auth(async (request, response) => { + const userId = request.currentUser.id; + + const studentGroups = await knex('studentGroup') + .select('*') + .where({ userId }); + + const students = await knex('studentInGroup') + .select('*') + .join('user', { 'studentInGroup.userId': 'user.id' }) + .whereIn('studentGroupId', studentGroups.map((group) => group.id)); + + response.success({ + studentGroups, + students + }); +}); + +export default getAll; diff --git a/backend/api/StudentGroupApi/index.js b/backend/api/StudentGroupApi/index.js new file mode 100644 index 00000000..6299baa3 --- /dev/null +++ b/backend/api/StudentGroupApi/index.js @@ -0,0 +1,7 @@ +import create from './create'; +import getAll from './getAll'; + +export default { + create, + getAll +}; diff --git a/backend/api/urls.js b/backend/api/urls.js index 06fafd5f..a32711af 100644 --- a/backend/api/urls.js +++ b/backend/api/urls.js @@ -8,6 +8,7 @@ import CourseUserIsLearningApi from '~/api/CourseUserIsLearningApi'; import ProblemUserIsLearningApi from '~/api/ProblemUserIsLearningApi'; import UserApi from '~/api/UserApi'; import ProblemApi from '~/api/ProblemApi'; +import StudentGroupApi from '~/api/StudentGroupApi'; const getApiClass = (controllerName) => { switch (controllerName) { @@ -19,6 +20,7 @@ const getApiClass = (controllerName) => { case 'ProblemUserIsLearningApi': return ProblemUserIsLearningApi; case 'UserApi': return UserApi; case 'PageApi': return PageApi; + case 'StudentGroupApi': return StudentGroupApi; } }; diff --git a/backend/db/migrations/15.sql b/backend/db/migrations/15.sql new file mode 100644 index 00000000..245ea581 --- /dev/null +++ b/backend/db/migrations/15.sql @@ -0,0 +1,19 @@ +\c :database; + +CREATE TABLE student_group ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES "user" (id) ON DELETE CASCADE NOT NULL, + title VARCHAR NOT NULL CHECK (char_length(title) >= 1), + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE TABLE student_in_group ( + id SERIAL PRIMARY KEY, + student_group_id INTEGER REFERENCES "student_group" (id) ON DELETE CASCADE NOT NULL, + user_id INTEGER REFERENCES "user" (id) ON DELETE CASCADE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + unique (student_group_id, user_id) +); + +-- When the teacher creates some group of students, +-- they will see these students grouped in every course they create. diff --git a/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/components/OrganizeStudentsTab/components/CreateGroupModal.js b/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/components/OrganizeStudentsTab/components/CreateGroupModal.js new file mode 100644 index 00000000..9907bd79 --- /dev/null +++ b/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/components/OrganizeStudentsTab/components/CreateGroupModal.js @@ -0,0 +1,71 @@ +import api from '~/api'; + +import TogglerAndModal from '~/components/TogglerAndModal'; +import { TextInput } from '~/components/_standardForm'; + +import disableOnSpeRequest from '~/services/disableOnSpeRequest'; + +class CreateGroupModal extends React.Component { + static propTypes = { + toggler: PropTypes.object.isRequired, + uiCreateStudentGroup: PropTypes.func.isRequired, + } + + state = { + formState: { + title: '' + }, + formValidation: {}, + speCreateGroup: {} + } + + apiCreateGroup = (e, closeModal) => { + e.preventDefault(); + if (this.validate()) { + api.StudentGroupApi.create( + (spe) => this.setState({ speCreateGroup: spe }), + { + title: this.state.formState.title + } + ).then((group) => { + this.props.uiCreateStudentGroup(group); + closeModal(); + }); + } + } + + validate = () => { + if (this.state.formState.title.length === 0) { + this.setState({ formValidation: { title: 'Please enter a title.' } }); + return false; + } else { + return true; + } + } + + render = () => + {(closeModal) => + + + Create a new group + + + + this.apiCreateGroup(e, closeModal)}> + + this.setState({ formState })} formValidation={this.state.formValidation} name="title" label="title" autoFocus/> + + + Create + {/* */} + + + + } +} + +export default CreateGroupModal; diff --git a/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/components/OrganizeStudentsTab/index.js b/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/components/OrganizeStudentsTab/index.js new file mode 100644 index 00000000..fe92b27b --- /dev/null +++ b/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/components/OrganizeStudentsTab/index.js @@ -0,0 +1,79 @@ +import UserSelect from '~/appComponents/UserSelect'; +import api from '~/api'; +import CreateGroupModal from './components/CreateGroupModal'; +import Loading from '~/components/Loading'; + +import css from './index.scss'; + +class OrganizeStudentsTab extends React.Component { + static propTypes = { + } + + state = { + speStudentGroups: {}, + selectedGroupId: null + } + + componentDidMount = () => { + api.StudentGroupApi.getAll( + (spe) => this.setState({ speStudentGroups: spe }) + ) + .then(({ studentGroups }) => { + if (studentGroups[0]) { + this.setState({ selectedGroupId: studentGroups[0].id }) + } + }) + } + + uiCreateStudentGroup = (studentGroup) => { + const spe = this.state.speStudentGroups; + + this.setState({ + speStudentGroups: { + ...spe, + payload: { + ...spe.payload, + studentGroups: [studentGroup, ...spe.payload.studentGroups] + } + } + }); + } + + render = () => + {({ studentGroups, students }) => + + + Create group} + uiCreateStudentGroup={this.uiCreateStudentGroup} + /> + + + {studentGroups.map((group) => + + this.setState({ selectedGroupId: group.id })}> + {group.title} + + + )} + + + + + + {}} placeholder="Add students..."/> + + + {students.map((student) => + {student.username} + )} + + + + } +} + +export default OrganizeStudentsTab; diff --git a/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/components/OrganizeStudentsTab/index.scss b/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/components/OrganizeStudentsTab/index.scss new file mode 100644 index 00000000..75f75785 --- /dev/null +++ b/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/components/OrganizeStudentsTab/index.scss @@ -0,0 +1,28 @@ +:local(.local){ + display: flex; + .left{ + width: 40%; + + ul.groups{ + margin-top: 20px; + + li.group{ + &.-active{ + button{ background: rgba(255, 255, 255, 0.08); } + } + &:hover{ + button{ background: rgba(255, 255, 255, 0.05); } + } + button{ + padding: 10px 7px; + border-radius: 2px; + max-width: 200px; + + } + } + } + } + .right{ + width: 60%; + } +} diff --git a/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/index.js b/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/index.js index 0ed89606..3d6718be 100644 --- a/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/index.js +++ b/frontend/appComponents/Header/components/CurrentUser/components/SettingsModal/index.js @@ -1,5 +1,7 @@ import TogglerAndModal from '~/components/TogglerAndModal'; import TabNavigation from '~/components/TabNavigation'; +import OrganizeStudentsTab from './components/OrganizeStudentsTab'; + import { AuthenticationActions } from '~/reducers/Authentication'; import MyDuck from '~/ducks/MyDuck'; @@ -7,7 +9,7 @@ import css from './index.scss'; @connect( (state) => ({ - currentUser: state.global.Authentication.currentUser || false, + currentUser: state.global.Authentication.currentUser, My: state.global.My }), (dispatch) => ({ @@ -21,10 +23,11 @@ class SettingsModal extends React.Component { My: PropTypes.object.isRequired, MyActions: PropTypes.object.isRequired, signOut: PropTypes.func.isRequired, + currentUser: PropTypes.object.isRequired } state = { - selectedTab: 'Design', + selectedTab: 'Organize students', hideSocialButtons: localStorage.getItem('hideSocialButtons') === 'true' ? true : false } @@ -52,13 +55,14 @@ class SettingsModal extends React.Component { this.setState({ selectedTab })} selectedTab={this.state.selectedTab} - tabs={['Design', 'Manage']} + tabs={['Design', 'Organize students', 'Manage']} /> renderSelectedTab = () => { return { 'Design': this.renderDesignTab, - 'Manage': this.renderManageTab + 'Manage': this.renderManageTab, + 'Organize students': this.renderOrganizeStudentsTab }[this.state.selectedTab](); } @@ -74,6 +78,9 @@ class SettingsModal extends React.Component { + renderOrganizeStudentsTab = () => + + renderDesignTab = () =>