diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb new file mode 100644 index 0000000..19e30b7 --- /dev/null +++ b/app/controllers/api/projects_controller.rb @@ -0,0 +1,3 @@ +class Api::ProjectsController < Api::BaseController + search_attributes :name +end diff --git a/app/models/project.rb b/app/models/project.rb new file mode 100644 index 0000000..3ae22eb --- /dev/null +++ b/app/models/project.rb @@ -0,0 +1,7 @@ +class Project < ApplicationRecord + # Relationships + has_many :user_projects, dependent: :destroy + + # Resourceable parameters + allow_params :name, :description +end diff --git a/app/models/user.rb b/app/models/user.rb index b0ea615..77ad667 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,7 +1,10 @@ class User < ApplicationRecord - # JWT - has_secure_password + # Relationships + has_many :user_projects, dependent: :destroy # Resourceable parameters - allow_params :name, :email, :password, :password_confirmation + allow_params :name, :email, :password, :password_confirmation, :admin + + # JWT + has_secure_password end diff --git a/app/models/user_project.rb b/app/models/user_project.rb new file mode 100644 index 0000000..ddd3140 --- /dev/null +++ b/app/models/user_project.rb @@ -0,0 +1,11 @@ +class UserProject < ApplicationRecord + # Relationships + belongs_to :user + belongs_to :project + + # Resourceable parameters + allow_params :user_id, :project_id, :role + + # Validations + validates_presence_of :role +end diff --git a/app/serializers/projects_serializer.rb b/app/serializers/projects_serializer.rb new file mode 100644 index 0000000..6a90c61 --- /dev/null +++ b/app/serializers/projects_serializer.rb @@ -0,0 +1,4 @@ +class ProjectsSerializer < BaseSerializer + index_attributes :id, :name, :description + show_attributes :id, :name, :description +end diff --git a/client/package.json b/client/package.json index c307170..b72184c 100644 --- a/client/package.json +++ b/client/package.json @@ -11,13 +11,14 @@ "flow": "flow" }, "dependencies": { - "@performant-software/semantic-components": "^0.5.17", - "@performant-software/shared-components": "^0.5.17", + "@performant-software/semantic-components": "^1.0.19-beta.2", + "@performant-software/shared-components": "^1.0.19-beta.2", "classnames": "^2.3.1", "i18next": "^21.9.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^11.18.4", + "react-icons": "^4.10.1", "react-router-dom": "^6.3.0", "react-scripts": "5.0.1", "semantic-ui-react": "^2.1.2", @@ -34,7 +35,7 @@ "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.5.0", - "flow-bin": "^0.177.0" + "flow-bin": "^0.215.1" }, "browserslist": { "production": [ diff --git a/client/public/assets/background.jpg b/client/public/assets/background.jpg new file mode 100644 index 0000000..be3ce00 Binary files /dev/null and b/client/public/assets/background.jpg differ diff --git a/client/src/App.js b/client/src/App.js index 96c7797..a98e6c4 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,28 +1,66 @@ // @flow +import { useDragDrop } from '@performant-software/shared-components'; import React, { type ComponentType } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; -import { useDragDrop } from '@performant-software/shared-components'; -import Admin from './pages/Admin'; import AuthenticatedRoute from './components/AuthenticatedRoute'; -import Home from './pages/Home'; +import Layout from './components/Layout'; +import Login from './pages/Login'; +import Project from './pages/Project'; +import Projects from './pages/Projects'; +import User from './pages/User'; +import Users from './pages/Users'; const App: ComponentType = useDragDrop(() => ( } + element={} + exact index /> - + )} - /> + > + + } + /> + } + /> + } + /> + + + } + /> + } + /> + } + /> + + )); diff --git a/client/src/components/Layout.js b/client/src/components/Layout.js new file mode 100644 index 0000000..fde9afc --- /dev/null +++ b/client/src/components/Layout.js @@ -0,0 +1,69 @@ +// @flow + +import { Breadcrumbs } from '@performant-software/semantic-components'; +import React, { useEffect, useMemo, useRef, useState, type AbstractComponent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, Outlet, useLocation, useParams } from 'react-router-dom'; +import { Container } from 'semantic-ui-react'; +import _ from 'underscore'; +import BreadcrumbsService, { Services } from '../services/Breadcrumbs'; +import Sidebar from './Sidebar'; +import styles from './Layout.module.css'; + +const Layout: AbstractComponent = () => { + const [menuWidth, setMenuWidth] = useState(0); + const menuRef = useRef(); + + const { pathname } = useLocation(); + const params = useParams(); + + const { t } = useTranslation(); + const defaultLabel = { new: t('Layout.labels.new') }; + + /** + * Builds the map of URL key to label. + */ + const labels = useMemo(() => _.reduce( + _.keys(Services), + (memo, key) => _.extend(memo, { [key] : t(`Layout.labels.${key}`) }), + defaultLabel + ), [defaultLabel, t]); + + /** + * Sets the sidebar menu width when the component is mounted. + */ + useEffect(() => { + const { current: instance } = menuRef; + + if (instance) { + setMenuWidth(instance.offsetWidth); + } + }, [menuRef.current]); + + return ( + + +
+ BreadcrumbsService.onLoad(name, id, params)} + pathname={pathname} + /> + +
+
+ ); +}; + +export default Layout; diff --git a/client/src/components/Layout.module.css b/client/src/components/Layout.module.css new file mode 100644 index 0000000..5e692c3 --- /dev/null +++ b/client/src/components/Layout.module.css @@ -0,0 +1,4 @@ +.layout > .content { + height: 100vh; + padding: 1em 2em 1em 2em; +} diff --git a/client/src/components/Login.js b/client/src/components/Login.js deleted file mode 100644 index fb6397d..0000000 --- a/client/src/components/Login.js +++ /dev/null @@ -1,56 +0,0 @@ -// @flow - -import { LoginModal } from '@performant-software/semantic-components'; -import React, { type ComponentType, useCallback, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { Button } from 'semantic-ui-react'; -import AuthenticationService from '../services/Authentication'; - -const Login: ComponentType = () => { - const [disabled, setDisabled] = useState(false); - const [email, setEmail] = useState(); - const [error, setError] = useState(false); - const [password, setPassword] = useState(); - const [visible, setVisible] = useState(false); - - const navigate = useNavigate(); - const { t } = useTranslation(); - - /** - * Attempts to authenticate then navigates to the admin page. - * - * @type {(function(): void)|*} - */ - const onLogin = useCallback(() => { - setDisabled(true); - - AuthenticationService - .login({ email, password }) - .then(() => navigate('/admin')) - .catch(() => setError(true)) - .finally(() => setDisabled(false)); - }, [email, password]); - - return ( - setVisible(false)} - onLogin={onLogin} - onPasswordChange={(e, { value }) => setPassword(value)} - onUsernameChange={(e, { value }) => setEmail(value)} - open={visible} - trigger={( -