Skip to content

Commit

Permalink
CDC #13 - Adding places list and edit pages
Browse files Browse the repository at this point in the history
  • Loading branch information
dleadbetter committed Sep 11, 2023
1 parent 16294e2 commit 20e87ff
Show file tree
Hide file tree
Showing 14 changed files with 522 additions and 0 deletions.
1 change: 1 addition & 0 deletions client/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
"react/jsx-no-bind": "off",
"react/no-did-update-set-state": "off",
"react/prefer-exact-props": "off",
"react/prefer-stateless-function": "off",
"react/require-default-props": "off",
"react/jsx-props-no-spreading": "off",
Expand Down
34 changes: 34 additions & 0 deletions client/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import AuthenticatedRoute from './components/AuthenticatedRoute';
import Layout from './components/Layout';
import Login from './pages/Login';
import Place from './pages/Place';
import Places from './pages/Places';
import Project from './pages/Project';
import Projects from './pages/Projects';
import User from './pages/User';
Expand Down Expand Up @@ -64,6 +66,22 @@ const App: ComponentType<any> = useDragDrop(() => (
element={<UserProject />}
/>
</Route>
<Route
path='places'
>
<Route
index
element={<Places />}
/>
<Route
path='new'
element={<Place />}
/>
<Route
path=':placeId'
element={<Place />}
/>
</Route>
</Route>
</Route>
<Route
Expand Down Expand Up @@ -102,6 +120,22 @@ const App: ComponentType<any> = useDragDrop(() => (
</Route>
</Route>
</Route>
<Route
path='places'
>
<Route
index
element={<Places />}
/>
<Route
path='new'
element={<Place />}
/>
<Route
path=':placeId'
element={<Place />}
/>
</Route>
</Route>
</Routes>
</Router>
Expand Down
87 changes: 87 additions & 0 deletions client/src/components/OwnableDropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// @flow

import { AssociatedDropdown } from '@performant-software/semantic-components';
import React, {
useCallback,
useEffect,
useState,
type AbstractComponent
} from 'react';
import { useParams } from 'react-router-dom';
import type { Project as ProjectType } from '../types/Project';
import type { Projectable as ProjectableType } from '../types/Projectable';
import ProjectsService from '../services/Projects';
import ProjectTransform from '../transforms/Project';

type Props = {
item: ProjectableType,
onSetState: (item: any) => void
};

const OwnableDropdown: AbstractComponent<any> = (props: Props) => {
const { projectId } = useParams();
const [currentProject, setCurrentProject] = useState();

/**
* If we're in the context of a single project, only return the current project.
* Otherwise call the `/api/projects` endpoint.
*
* @type {function(string): *}
*/
const onSearch = useCallback((search: string) => {
let promise;

if (currentProject) {
promise = Promise.resolve({ data: { projects: [currentProject] } });
} else {
promise = ProjectsService.fetchAll({ search });
}

return promise;
}, [currentProject]);

/**
* Sets the project on the state's `project_item`.
*
* @type {function(Project): *}
*/
const onSelection = useCallback((project: ProjectType) => props.onSetState({
project_item: {
project_id: project.id,
project
}
}), []);

/**
* If we're in the context of a single project, load the project and set it on the state.
*/
useEffect(() => {
if (projectId) {
ProjectsService
.fetchOne(projectId)
.then(({ data }) => setCurrentProject(data.project));
}
}, []);

/**
* If we're adding a new record, select the current project by default.
*/
useEffect(() => {
if (currentProject && !props.item.id) {
onSelection(currentProject);
}
}, [currentProject]);

return (
<AssociatedDropdown
collectionName='projects'
onSearch={onSearch}
onSelection={onSelection}
renderOption={(project) => ProjectTransform.toDropdown(project)}
searchQuery={props.item.project_item?.project?.name}
value={props.item.project_item?.project_id}
/>
);
};

export default OwnableDropdown;
47 changes: 47 additions & 0 deletions client/src/components/PlaceNameModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @flow

import type { EditContainerProps } from '@performant-software/shared-components/types';
import React, { type AbstractComponent } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Modal } from 'semantic-ui-react';
import type { PlaceName as PlaceNameType } from '../types/Place';

type Props = EditContainerProps & {
item: PlaceNameType
};

const PlaceNameModal: AbstractComponent<any> = (props: Props) => {
const { t } = useTranslation();

return (
<Modal
as={Form}
centered={false}
open
>
<Modal.Header
content={props.item.id
? t('PlaceNameModal.title.edit')
: t('PlaceNameModal.title.add')}
/>
<Modal.Content>
<Form.Input
autoFocus
error={props.isError('name')}
label={t('PlaceNameModal.labels.name')}
required={props.isRequired('name')}
onChange={props.onTextInputChange.bind(this, 'name')}
value={props.item.name}
/>
<Form.Checkbox
checked={props.item.primary}
label={t('PlaceNameModal.labels.primary')}
onChange={props.onCheckboxInputChange.bind(this, 'primary')}
/>
</Modal.Content>
{ props.children }
</Modal>
);
};

export default PlaceNameModal;
26 changes: 26 additions & 0 deletions client/src/components/Sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import cx from 'classnames';
import React, { useCallback, type ComponentType } from 'react';
import { useTranslation, withTranslation } from 'react-i18next';
import { BiWorld } from 'react-icons/bi';
import { FaFolderOpen, FaUsers } from 'react-icons/fa';
import { TbDatabaseShare } from 'react-icons/tb';
import { useNavigate } from 'react-router-dom';
Expand Down Expand Up @@ -122,6 +123,31 @@ const Sidebar: ComponentType<any> = withTranslation()((props: Props) => {
)}
/>
)}
<Popup
content={t('Sidebar.labels.places')}
mouseEnterDelay={1000}
position='right center'
trigger={(
<MenuLink
className={styles.item}
parent
to='/places'
>
<BiWorld
size='2em'
/>
{ params.placeId && (
<Menu.Menu>
<MenuLink
content={t('Sidebar.labels.details')}
parent
to={`/places/${params.placeId}`}
/>
</Menu.Menu>
)}
</MenuLink>
)}
/>
<Popup
content={t('Sidebar.labels.logout')}
mouseEnterDelay={1000}
Expand Down
24 changes: 24 additions & 0 deletions client/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@
"login": "Login"
}
},
"Place": {
"labels": {
"names": "Names",
"project": "Project"
},
"placeNames": {
"columns": {
"name": "Name",
"primary": "Primary"
}
}
},
"PlaceNameModal": {
"labels": {
"name": "Name",
"primary": "Primary"
}
},
"Places": {
"columns": {
"name": "Name",
"project": "Project"
}
},
"Project": {
"labels": {
"description": "Description",
Expand Down
86 changes: 86 additions & 0 deletions client/src/pages/Place.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @flow

import {
BooleanIcon,
EmbeddedList,
SimpleEditPage
} from '@performant-software/semantic-components';
import type { EditContainerProps } from '@performant-software/shared-components/types';
import React, { type AbstractComponent } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Header } from 'semantic-ui-react';
import OwnableDropdown from '../components/OwnableDropdown';
import type { Place as PlaceType } from '../types/Place';
import PlaceNameModal from '../components/PlaceNameModal';
import PlacesService from '../services/Places';
import Validation from '../utils/Validation';
import withReactRouterEditPage from '../hooks/ReactRouterEditPage';

type Props = EditContainerProps & {
item: PlaceType
};

const PlaceForm = (props: Props) => {
const { t } = useTranslation();

return (
<SimpleEditPage
{...props}
>
<SimpleEditPage.Tab
key='default'
>
<Form.Input
label={t('Place.labels.project')}
required
>
<OwnableDropdown
item={props.item}
onSetState={props.onSetState}
/>
</Form.Input>
<Header
content={t('Place.labels.names')}
/>
<EmbeddedList
actions={[{
name: 'edit'
}, {
name: 'delete'
}]}
columns={[{
name: 'name',
label: t('Place.placeNames.columns.name')
}, {
name: 'primary',
label: t('Place.placeNames.columns.primary'),
render: (placeName) => <BooleanIcon value={placeName.primary} />
}]}
items={props.item.place_names}
modal={{
component: PlaceNameModal
}}
onSave={props.onSaveChildAssociation.bind(this, 'place_names')}
onDelete={props.onDeleteChildAssociation.bind(this, 'place_names')}
/>
</SimpleEditPage.Tab>
</SimpleEditPage>
);
};

const Place: AbstractComponent<any> = withReactRouterEditPage(PlaceForm, {
id: 'placeId',
onInitialize: (id) => (
PlacesService
.fetchOne(id)
.then(({ data }) => data.place)
),
onSave: (place) => (
PlacesService
.save(place)
.then(({ data }) => data.place)
),
resolveValidationError: Validation.resolveUpdateError.bind(this)
});

export default Place;
Loading

0 comments on commit 20e87ff

Please sign in to comment.