Skip to content

Commit 570f958

Browse files
authored
RSDEV-260 Add subsamples details section to sample page (#54)
This change adds a new section to the sample page, showing some details of each subsample. This is to make it easier to browse those details without having to click around each subsample page, particularly useful when samples regularly only have one subsample. This new panel appears below the subsample listing table, with the subsample theme colours to distinguish it from the sample itself. The user can browse back and forward between the subsample with the arrow buttons, or just to a specific subsample by tapping a row in the table (viewing the subsample's own page is now reachable by tapping the Global ID link or the link at the end of the new panel). The location, quantity, and notes fields are shown; fields that are specific to the subsample and detail what is unique about this physical quantity of some substance. Moreover, the table listing all of the subsamples now defaults to being closed when there is only one subsample. We still must keep it available as there are some operations on the subsample that can only be performed from the search mechanism, but it is unlikely to be of interest most of the time for users who typically have samples with only one subsample. Interacting with the search mechanism in any way causes it to open. In terms of implementation details of note, the activeResult of the Search mechanism scoped to the SampleModel is being used for fetching the full details of the subsample, and it is that allows us to change the displayed subsample when the rows of the table are tapped. As such, when the user presses the next and previous buttons we have to fetch the previous or next page of results if the record in question is not yet known to the Search mechanism. Up until now, we've not used the activeResult of the searches scoped to each record type (container contents, sample subsamples, and template samples), but the activeResult is in use in the move dialog, the template picker, and of course the whole page-wide search. The abstraction afforded by the Search and CoreFetcher et al. classes continues to pay dividends.
1 parent 53b4782 commit 570f958

File tree

19 files changed

+333
-65
lines changed

19 files changed

+333
-65
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
//@flow
2+
3+
import React, { type Node, type ComponentType } from "react";
4+
import { observer } from "mobx-react-lite";
5+
import SubSampleModel from "../../../stores/models/SubSampleModel";
6+
import Grid from "@mui/material/Grid";
7+
import Collapse from "@mui/material/Collapse";
8+
import ExpandCollapseIcon from "../../../components/ExpandCollapseIcon";
9+
import IconButton from "@mui/material/IconButton";
10+
import Toolbar from "@mui/material/Toolbar";
11+
import AppBar from "@mui/material/AppBar";
12+
import Card from "@mui/material/Card";
13+
import CardContent from "@mui/material/CardContent";
14+
import CardActions from "@mui/material/CardActions";
15+
import Link from "@mui/material/Link";
16+
import { styled, useTheme, darken, alpha } from "@mui/material/styles";
17+
import LocationField from "../../components/Fields/Location";
18+
import Box from "@mui/material/Box";
19+
import GlobalId from "../../../components/GlobalId";
20+
import QuantityField from "../../Subsample/Fields/Quantity";
21+
import Stack from "@mui/material/Stack";
22+
import Notes from "../../Subsample/Fields/Notes/Notes";
23+
import MobileStepper from "@mui/material/MobileStepper";
24+
import Button, { buttonClasses } from "@mui/material/Button";
25+
import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
26+
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
27+
import { type Search } from "../../../stores/definitions/Search";
28+
import { doNotAwait, modulo } from "../../../util/Util";
29+
import { svgIconClasses } from "@mui/material/SvgIcon";
30+
import { Link as ReactRouterLink } from "react-router-dom";
31+
import Typography from "@mui/material/Typography";
32+
33+
const CustomStepper = styled(MobileStepper)(({ theme }) => ({
34+
backgroundColor: theme.palette.record.subSample.lighter,
35+
borderBottomLeftRadius: "4px",
36+
borderBottomRightRadius: "4px",
37+
border: `2px solid ${theme.palette.record.subSample.bg}`,
38+
borderTop: "none",
39+
color: alpha(darken(theme.palette.record.subSample.bg, 0.5), 0.7),
40+
fontWeight: "700",
41+
letterSpacing: "0.03em",
42+
[`& .${buttonClasses.root}`]: {
43+
[`& .${svgIconClasses.root}`]: {
44+
color: theme.palette.record.subSample.bg,
45+
},
46+
[`&.${buttonClasses.disabled}`]: {
47+
opacity: 0.3,
48+
},
49+
},
50+
}));
51+
52+
const Wrapper = ({ children }: {| children: Node |}) => {
53+
const [sectionOpen, setSectionOpen] = React.useState(true);
54+
return (
55+
<Grid container direction="row" flexWrap="nowrap" spacing={1}>
56+
<Grid item sx={{ pl: 0, ml: -2 }}>
57+
<IconButton
58+
onClick={() => setSectionOpen(!sectionOpen)}
59+
sx={{ p: 1.25 }}
60+
>
61+
<ExpandCollapseIcon open={sectionOpen} />
62+
</IconButton>
63+
</Grid>
64+
<Grid item flexGrow={1}>
65+
<Collapse in={sectionOpen} collapsedSize={50}>
66+
{children}
67+
</Collapse>
68+
</Grid>
69+
</Grid>
70+
);
71+
};
72+
73+
type SubsampleDetailsArgs = {|
74+
search: Search,
75+
|};
76+
77+
function SubsampleDetails({ search }: SubsampleDetailsArgs) {
78+
const theme = useTheme();
79+
80+
const subsample = search.activeResult;
81+
if (subsample === null || typeof subsample === "undefined")
82+
return <Wrapper>No subsamples</Wrapper>;
83+
const index = search.filteredResults.findIndex(
84+
(x) => x.globalId === subsample.globalId
85+
);
86+
87+
if (!(subsample instanceof SubSampleModel))
88+
throw new Error("All Subsamples must be instances of SubSampleModel");
89+
90+
return (
91+
<Wrapper>
92+
<>
93+
<Card
94+
variant="outlined"
95+
sx={{
96+
border: `2px solid ${theme.palette.record.subSample.bg}`,
97+
borderBottomLeftRadius: 0,
98+
borderBottomRightRadius: 0,
99+
}}
100+
>
101+
<AppBar
102+
position="relative"
103+
open={true}
104+
sx={{
105+
backgroundColor: theme.palette.record.subSample.bg,
106+
boxShadow: "none",
107+
}}
108+
>
109+
<Toolbar
110+
variant="dense"
111+
disableGutters
112+
sx={{
113+
px: 1.5,
114+
}}
115+
>
116+
{subsample.name}
117+
<Box flexGrow={1}></Box>
118+
<GlobalId record={subsample} />
119+
</Toolbar>
120+
</AppBar>
121+
<CardContent>
122+
<Stack spacing={2}>
123+
<LocationField fieldOwner={subsample} />
124+
<QuantityField
125+
fieldOwner={subsample}
126+
quantityCategory={subsample.quantityCategory}
127+
onErrorStateChange={() => {}}
128+
/>
129+
<Notes record={subsample} onErrorStateChange={() => {}} />
130+
</Stack>
131+
</CardContent>
132+
<CardActions>
133+
<Typography align="center" sx={{ width: "100%" }}>
134+
<Link component={ReactRouterLink} to={subsample.permalinkURL}>
135+
See full details of <strong>{subsample.name}</strong>
136+
</Link>
137+
</Typography>
138+
</CardActions>
139+
</Card>
140+
<CustomStepper
141+
variant="text"
142+
steps={search.count}
143+
activeStep={
144+
index + search.fetcher.pageSize * search.fetcher.pageNumber
145+
}
146+
position="static"
147+
nextButton={
148+
<Button
149+
size="small"
150+
onClick={doNotAwait(async () => {
151+
if (index + 1 > search.filteredResults.length - 1)
152+
await search.setPage(search.fetcher.pageNumber + 1);
153+
await search.setActiveResult(
154+
search.filteredResults[(index + 1) % search.fetcher.pageSize]
155+
);
156+
})}
157+
disabled={
158+
index +
159+
search.fetcher.pageSize * search.fetcher.pageNumber +
160+
1 >=
161+
search.count
162+
}
163+
>
164+
<KeyboardArrowRight />
165+
</Button>
166+
}
167+
backButton={
168+
<Button
169+
size="small"
170+
onClick={doNotAwait(async () => {
171+
if (index === 0)
172+
await search.setPage(search.fetcher.pageNumber - 1);
173+
await search.setActiveResult(
174+
search.filteredResults[
175+
modulo(index - 1, search.fetcher.pageSize)
176+
]
177+
);
178+
})}
179+
disabled={
180+
index + search.fetcher.pageSize * search.fetcher.pageNumber ===
181+
0
182+
}
183+
>
184+
<KeyboardArrowLeft />
185+
</Button>
186+
}
187+
/>
188+
</>
189+
</Wrapper>
190+
);
191+
}
192+
193+
export default (observer(
194+
SubsampleDetails
195+
): ComponentType<SubsampleDetailsArgs>);

src/main/webapp/ui/src/Inventory/Sample/Content/SubsampleListing.js

+55-19
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { menuIDs } from "../../../util/menuIDs";
99
import Grid from "@mui/material/Grid";
1010
import SampleModel from "../../../stores/models/SampleModel";
1111
import InnerSearchNavigationContext from "../../components/InnerSearchNavigationContext";
12+
import Collapse from "@mui/material/Collapse";
13+
import ExpandCollapseIcon from "../../../components/ExpandCollapseIcon";
14+
import IconButton from "@mui/material/IconButton";
1215

1316
const TABS = ["LIST", "TREE", "CARD"];
1417

@@ -25,6 +28,18 @@ type SubsampleListingArgs = {|
2528
*/
2629
function SubsampleListing({ sample }: SubsampleListingArgs): Node {
2730
const { search } = React.useContext(SearchContext);
31+
const [searchOpen, setSearchOpen] = React.useState(
32+
sample.subSamples.length > 1
33+
);
34+
35+
React.useEffect(() => {
36+
setSearchOpen(sample.subSamples.length > 1);
37+
}, [sample.subSamples]);
38+
39+
React.useEffect(() => {
40+
if (!sample.search.activeResult && sample.search.filteredResults.length > 0)
41+
void sample.search.setActiveResult();
42+
}, [sample.search.filteredResults]);
2843

2944
const handleSearch = (query: string) => {
3045
void sample.search.fetcher.performInitialSearch({
@@ -34,25 +49,46 @@ function SubsampleListing({ sample }: SubsampleListingArgs): Node {
3449
};
3550

3651
return (
37-
<SearchContext.Provider
38-
value={{
39-
search: sample.search,
40-
scopedResult: sample,
41-
isChild: true,
42-
differentSearchForSettingActiveResult: search,
43-
}}
44-
>
45-
<InnerSearchNavigationContext>
46-
<Grid container direction="column" spacing={1}>
47-
<Grid item>
48-
<Search handleSearch={handleSearch} TABS={TABS} size="small" />
49-
</Grid>
50-
<Grid item>
51-
<SearchView contextMenuId={menuIDs.RESULTS} />
52-
</Grid>
53-
</Grid>
54-
</InnerSearchNavigationContext>
55-
</SearchContext.Provider>
52+
<Grid container direction="row" flexWrap="nowrap" spacing={1}>
53+
<Grid item sx={{ pl: 0, ml: -2 }}>
54+
<IconButton onClick={() => setSearchOpen(!searchOpen)} sx={{ p: 1.25 }}>
55+
<ExpandCollapseIcon open={searchOpen} />
56+
</IconButton>
57+
</Grid>
58+
<Grid item>
59+
<Collapse
60+
in={searchOpen}
61+
collapsedSize={44}
62+
onClick={() => {
63+
setSearchOpen(true);
64+
}}
65+
>
66+
<SearchContext.Provider
67+
value={{
68+
search: sample.search,
69+
scopedResult: sample,
70+
isChild: false,
71+
differentSearchForSettingActiveResult: sample.search,
72+
}}
73+
>
74+
<InnerSearchNavigationContext>
75+
<Grid container direction="column" spacing={1}>
76+
<Grid item>
77+
<Search
78+
handleSearch={handleSearch}
79+
TABS={TABS}
80+
size="small"
81+
/>
82+
</Grid>
83+
<Grid item>
84+
<SearchView contextMenuId={menuIDs.RESULTS} />
85+
</Grid>
86+
</Grid>
87+
</InnerSearchNavigationContext>
88+
</SearchContext.Provider>
89+
</Collapse>
90+
</Grid>
91+
</Grid>
5692
);
5793
}
5894

src/main/webapp/ui/src/Inventory/Sample/Form.js

+12
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
} from "../components/Stepper/StepperPanelHeader";
3131
import LimitedAccessAlert from "../components/LimitedAccessAlert";
3232
import type { Person } from "../../stores/definitions/Person";
33+
import SubsampleDetails from "./Content/SubsampleDetails";
34+
import Typography from "@mui/material/Typography";
3335

3436
const OverviewSection = observer(
3537
({ activeResult }: { activeResult: SampleModel }) => {
@@ -224,7 +226,17 @@ function Form(): Node {
224226
sectionName="subsamples"
225227
recordType="sample"
226228
>
229+
{/*
230+
* We say "one of the {plural}" here instead of "a {alias}"
231+
* because adding the logic to get the grammar of "a" versus
232+
* "an" right would be too much of a pain.
233+
*/}
234+
<Typography variant="body1">
235+
Tap one of the {activeResult.subSampleAlias.plural} in the
236+
search section to preview it below.
237+
</Typography>
227238
<SubsampleListing sample={activeResult} />
239+
<SubsampleDetails search={activeResult.search} />
228240
</StepperPanel>
229241
) : null}
230242
</>

src/main/webapp/ui/src/Inventory/Search/ResultsTable.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function ResultsTable({ contextMenuId }: ResultsTableArgs): Node {
6565
};
6666

6767
const handleChangePage = (newPage: number) => {
68-
search.setPage(newPage);
68+
void search.setPage(newPage);
6969
};
7070

7171
const toggleAll = () => {

src/main/webapp/ui/src/Inventory/Search/components/ResultRow.js

+10-10
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,16 @@ function ResultRow({ result, adjustableColumns }: ResultRowArgs): Node {
5757
const { classes } = useStyles();
5858

5959
/*
60-
* Whilst card view uses `searchStore`, here we use
61-
* `differentSearchForSettingActiveResult` because there are various places
62-
* where list view is used for selecting records where slightly different
63-
* actions are taken. In the right panel, tapping a row will set that record
64-
* as the `activeResult` of `searchStore` (thereby changing the contents of
65-
* the right panel). In the move dialog, the right panel of the dialog is
66-
* updated to show the current contents of the container. In the template
67-
* picker, tapping the row will set the `activeResult` of the picker's search
68-
* which will ultimately change the `template` of the new sample that is the
69-
* `searchStore`'s `activeResult`. Ultimately, this means that replacing
60+
* Here we use `differentSearchForSettingActiveResult` because there are
61+
* various places where list view is used for selecting records where
62+
* slightly different actions are taken. In the right panel, tapping a row
63+
* will set that record as the `activeResult` of `searchStore` (thereby
64+
* changing the contents of the right panel). In the move dialog, the right
65+
* panel of the dialog is updated to show the current contents of the
66+
* container. In the template picker, tapping the row will set the
67+
* `activeResult` of the picker's search which will ultimately change the
68+
* `template` of the new sample that is the `searchStore`'s `activeResult`.
69+
* Ultimately, this means that replacing
7070
* `differentSearchForSettingActiveResult` with a solution that just looks at
7171
* the current search context and its parent is not sufficiently flexible.
7272
*/

0 commit comments

Comments
 (0)