Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: UI query:data permissions awareness for counts #229

Merged
merged 3 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 12 additions & 18 deletions src/js/components/Overview/Counts.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { type CSSProperties, Fragment, type ReactNode } from 'react';
import { type CSSProperties, type ReactNode } from 'react';
import { Card, Popover, Space, Statistic, Typography } from 'antd';
import { InfoCircleOutlined, TeamOutlined } from '@ant-design/icons';
import { ExperimentOutlined, InfoCircleOutlined, TeamOutlined } from '@ant-design/icons';
import { BiDna } from 'react-icons/bi';

import ExpSvg from '../Util/ExpSvg';
import { T_PLURAL_COUNT } from '@/constants/i18n';
import { BOX_SHADOW, COUNTS_FILL } from '@/constants/overviewConstants';
import { NO_RESULTS_DASHES } from '@/constants/searchConstants';
import { useAppSelector, useTranslationFn } from '@/hooks';
import { useCanSeeUncensoredCounts } from '@/hooks/censorship';

const styles: Record<string, CSSProperties> = {
countCard: {
Expand All @@ -23,24 +24,23 @@ const Counts = () => {

const { counts, isFetchingData } = useAppSelector((state) => state.data);

const uncensoredCounts = useCanSeeUncensoredCounts();

// Break down help into multiple sentences inside an array to make translation a bit easier.
const data = [
{
entity: 'individual',
help: ['individual_help_1'],
icon: <TeamOutlined />,
count: counts.individuals,
},
{
entity: 'biosample',
help: ['biosample_help_1'],
icon: <BiDna />,
count: counts.biosamples,
},
{
entity: 'experiment',
help: ['experiment_help_1', 'experiment_help_2'],
icon: <ExpSvg />,
icon: <ExperimentOutlined />,
count: counts.experiments,
},
];
Expand All @@ -49,31 +49,25 @@ const Counts = () => {
<>
<Typography.Title level={3}>{t('Counts')}</Typography.Title>
<Space wrap>
{data.map(({ entity, help, icon, count }, i) => {
{data.map(({ entity, icon, count }, i) => {
const title = t(`entities.${entity}`, T_PLURAL_COUNT);
return (
<Card key={i} style={{ ...styles.countCard, height: isFetchingData ? 138 : 114 }}>
<Statistic
title={
<Space>
{title}
{help && (
{
<Popover
title={title}
content={
<CountsHelp>
{help.map((h, i) => (
<Fragment key={i}>{t(`entities.${h}`)} </Fragment>
))}
</CountsHelp>
}
content={<CountsHelp>{t(`entities.${entity}_help`, { joinArrays: ' ' })}</CountsHelp>}
>
<InfoCircleOutlined />
</Popover>
)}
}
</Space>
}
value={count}
value={count || (uncensoredCounts ? count : NO_RESULTS_DASHES)}
valueStyle={{ color: COUNTS_FILL }}
prefix={icon}
loading={isFetchingData}
Expand Down
5 changes: 5 additions & 0 deletions src/js/components/Search/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useSearchQuery } from '@/features/search/hooks';
import { useCanSeeUncensoredCounts } from '@/hooks/censorship';

import SearchResultsPane from './SearchResultsPane';

const SearchResults = () => {
Expand All @@ -7,10 +9,13 @@ const SearchResults = () => {
// existing code treats non-empty message as sign of insufficient data
const hasInsufficientData = message !== '';

const uncensoredCounts = useCanSeeUncensoredCounts();

return (
<SearchResultsPane
isFetchingData={isFetchingData}
hasInsufficientData={hasInsufficientData}
uncensoredCounts={uncensoredCounts}
message={message}
results={results}
/>
Expand Down
16 changes: 9 additions & 7 deletions src/js/components/Search/SearchResultsCounts.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { CSSProperties } from 'react';
import { Skeleton, Space, Statistic } from 'antd';
import { TeamOutlined } from '@ant-design/icons';
import { ExperimentOutlined, TeamOutlined } from '@ant-design/icons';
import { BiDna } from 'react-icons/bi';

import { T_PLURAL_COUNT } from '@/constants/i18n';
import { COUNTS_FILL } from '@/constants/overviewConstants';
import { NO_RESULTS_DASHES } from '@/constants/searchConstants';
import ExpSvg from '@/components/Util/ExpSvg';
import { useTranslationFn } from '@/hooks';
import type { DiscoveryResults, OptionalDiscoveryResults } from '@/types/data';
import type { SearchResultsUIPane } from '@/types/search';
Expand All @@ -20,6 +19,7 @@ const SearchResultsCounts = ({
selectedPane,
setSelectedPane,
hasInsufficientData,
uncensoredCounts,
message,
}: SearchResultsCountsProps) => {
const t = useTranslationFn();
Expand Down Expand Up @@ -63,7 +63,7 @@ const SearchResultsCounts = ({
value={
hasInsufficientData
? t(message ?? '')
: isBeaconNetwork && !individualCount
: !uncensoredCounts && !individualCount
? NO_RESULTS_DASHES
: individualCount
}
Expand All @@ -73,15 +73,16 @@ const SearchResultsCounts = ({
</div>
<Statistic
title={t('entities.biosample', T_PLURAL_COUNT)}
value={hasInsufficientData || (isBeaconNetwork && !biosampleCount) ? NO_RESULTS_DASHES : biosampleCount}
value={hasInsufficientData || (!uncensoredCounts && !biosampleCount) ? NO_RESULTS_DASHES : biosampleCount}
valueStyle={STAT_STYLE}
prefix={<BiDna />}
// Slight fixup for alignment of non-Antd icon:
prefix={<BiDna style={{ marginTop: 6, verticalAlign: 'top' }} />}
/>
<Statistic
title={t('entities.experiment', T_PLURAL_COUNT)}
value={hasInsufficientData || (isBeaconNetwork && !experimentCount) ? NO_RESULTS_DASHES : experimentCount}
value={hasInsufficientData || (!uncensoredCounts && !experimentCount) ? NO_RESULTS_DASHES : experimentCount}
valueStyle={STAT_STYLE}
prefix={<ExpSvg />}
prefix={<ExperimentOutlined />}
/>
</>
)}
Expand All @@ -98,6 +99,7 @@ type SearchResultsCountsProps = {
selectedPane?: SearchResultsUIPane;
setSelectedPane?: (pane: SearchResultsUIPane) => void;
hasInsufficientData?: boolean;
uncensoredCounts?: boolean;
message?: string;
};

Expand Down
3 changes: 3 additions & 0 deletions src/js/components/Search/SearchResultsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type IndividualResultRow = { id: string };
const SearchResultsPane = ({
isFetchingData,
hasInsufficientData,
uncensoredCounts,
message,
results,
resultsTitle,
Expand Down Expand Up @@ -79,6 +80,7 @@ const SearchResultsPane = ({
setSelectedPane={(p) => setPanePage(p)}
results={results}
hasInsufficientData={hasInsufficientData}
uncensoredCounts={uncensoredCounts}
message={message}
/>
</Col>
Expand Down Expand Up @@ -133,6 +135,7 @@ const SearchResultsPane = ({
export interface SearchResultsPaneProps {
isFetchingData: boolean;
hasInsufficientData?: boolean;
uncensoredCounts?: boolean;
message?: string;
results: DiscoveryResults;
resultsTitle?: string;
Expand Down
15 changes: 0 additions & 15 deletions src/js/components/Util/ExpSvg.js

This file was deleted.

2 changes: 1 addition & 1 deletion src/js/constants/searchConstants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DiscoveryResults } from '@/types/data';

export const NO_RESULTS_DASHES = '----';
export const NO_RESULTS_DASHES = '———';

export const EMPTY_DISCOVERY_RESULTS: DiscoveryResults = {
// individuals
Expand Down
5 changes: 4 additions & 1 deletion src/js/features/config/config.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const makeGetConfigRequest = createAsyncThunk<DiscoveryRules, void, { rej
selectedScope: { scopeSet },
},
} = getState();
const cond = scopeSet && configStatus === RequestStatus.Idle;
const cond = scopeSet && configStatus !== RequestStatus.Pending;
if (!cond) {
console.debug(
`makeGetConfigRequest() was attempted, but will not dispatch (scopeSet=${scopeSet}, configStatus=${configStatus})`
Expand Down Expand Up @@ -64,6 +64,7 @@ export const makeGetServiceInfoRequest = createAsyncThunk<

export interface ConfigState {
configStatus: RequestStatus;
countThreshold: number;
maxQueryParameters: number;
maxQueryParametersRequired: boolean;
// ----------------------------------------------------
Expand All @@ -73,6 +74,7 @@ export interface ConfigState {

const initialState: ConfigState = {
configStatus: RequestStatus.Idle,
countThreshold: 0,
maxQueryParameters: 0,
maxQueryParametersRequired: true,
// ----------------------------------------------------
Expand All @@ -95,6 +97,7 @@ const configStore = createSlice({
state.configStatus = RequestStatus.Pending;
});
builder.addCase(makeGetConfigRequest.fulfilled, (state, { payload }: PayloadAction<DiscoveryRules>) => {
state.countThreshold = payload.count_threshold;
state.maxQueryParameters = payload.max_query_parameters;
state.configStatus = RequestStatus.Fulfilled;
});
Expand Down
3 changes: 3 additions & 0 deletions src/js/features/config/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { useAppSelector } from '@/hooks';

export const useConfig = () => useAppSelector((state) => state.config);
9 changes: 9 additions & 0 deletions src/js/features/metadata/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { useMemo } from 'react';
import { makeProjectDatasetResource, makeProjectResource, type Resource, RESOURCE_EVERYTHING } from 'bento-auth-js';
import { useAppSelector } from '@/hooks';
import type { Project } from '@/types/metadata';

export const useMetadata = () => useAppSelector((state) => state.metadata);

export const useSelectedScope = () => useMetadata().selectedScope;
export const useSelectedScopeAsResource = (): Resource => {
const { scope } = useSelectedScope();

if (!scope.project) return RESOURCE_EVERYTHING;
if (!scope.dataset) return makeProjectResource(scope.project);
return makeProjectDatasetResource(scope.project, scope.dataset);
};

export const useSelectedProject = (): Project | undefined => {
const {
Expand Down
15 changes: 15 additions & 0 deletions src/js/hooks/censorship.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { queryData } from 'bento-auth-js';
import { useConfig } from '@/features/config/hooks';
import { useSelectedScopeAsResource } from '@/features/metadata/hooks';
import { useHasResourcePermissionWrapper } from '@/hooks';

export const useCanSeeUncensoredCounts = () => {
const scopeResource = useSelectedScopeAsResource();
const { hasPermission: queryDataPerm } = useHasResourcePermissionWrapper(scopeResource, queryData);
const { countThreshold } = useConfig();

// Used mostly for UI - showing dashes vs "0".
// - If we have query:data permissions or the low cell count threshold is low enough that we get uncensored counts,
// then this becomes true.
return queryDataPerm || countThreshold <= 1;
};
14 changes: 10 additions & 4 deletions src/public/locales/en/default_translation_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@
"phenopacket_other": "Clinical Data",
"individual_one": "Individual",
"individual_other": "Individuals",
"individual_help_1": "Individuals represent a specific person / organism, and may have one or multiple associated biosamples, each with associated experiments.",
"individual_help": [
"Individuals represent a specific person / organism, and may have one or multiple associated biosamples, each with associated experiments."
],
"biosample_one": "Biosample",
"biosample_other": "Biosamples",
"biosample_help_1": "Biosamples are usually biological material extracted from a specific individual.",
"biosample_help": [
"Biosamples are usually biological material extracted from a specific individual."
],
"experiment_one": "Experiment",
"experiment_other": "Experiments",
"experiment_help_1": "Experiments are a process done to a specific sample, e.g., whole-genome sequencing.",
"experiment_help_2": "One lab experiment may result in multiple experiment records inside the portal, such as with multiplexed samples.",
"experiment_help": [
"Experiments are a process done to a specific sample, e.g., whole-genome sequencing.",
"One lab experiment may result in multiple experiment records inside the portal, such as with multiplexed samples."
],
"variant_one": "Variant",
"variant_other": "Variants"
},
Expand Down
14 changes: 10 additions & 4 deletions src/public/locales/fr/default_translation_fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@
"phenopacket_other": "Données cliniques",
"individual_one": "Participant",
"individual_other": "Participants",
"individual_help_1": "Les participants représentent une personne/un organisme spécifique et peuvent avoir un ou plusieurs échantillons biologiques associés, chacun avec des expériences associées.",
"individual_help": [
"Les participants représentent une personne/un organisme spécifique et peuvent avoir un ou plusieurs échantillons biologiques associés, chacun avec des expériences associées."
],
"biosample_one": "Échantillon biologique",
"biosample_other": "Échantillons biologiques",
"biosample_help_1": "Les échantillons biologiques sont généralement du matériel biologique extrait d’un participant spécifique.",
"biosample_help": [
"Les échantillons biologiques sont généralement du matériel biologique extrait d’un participant spécifique."
],
"experiment_one": "Expérience",
"experiment_other": "Expériences",
"experiment_help_1": "Les expériences sont un processus effectué sur un échantillon spécifique, par exemple le séquençage du génome entier.",
"experiment_help_2": "Une expérience par un laboratoire peut donner lieu à plusieurs enregistrements d'expériences dans le portail, par exemple avec des échantillons multiplexés.",
"experiment_help": [
"Les expériences sont un processus effectué sur un échantillon spécifique, par exemple le séquençage du génome entier.",
"Une expérience par un laboratoire peut donner lieu à plusieurs enregistrements d'expériences dans le portail, par exemple avec des échantillons multiplexés."
],
"variant_one": "Variant",
"variant_other": "Variants"
},
Expand Down