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: Dynamic most recent date filter #1291

Merged
merged 4 commits into from
Dec 8, 2023
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ You can also check the [release page](https://github.com/visualize-admin/visuali

- Features
- Localized cube landing pages are now supported (dcat:landingPage) 🌎
- Temporal dimension filters can now be pinned to dynamically use the most recent value (so that published charts automatically switch to it when the cube is updated) 📅
- Temporal dimensions can now be used as segmentation fields (excluding area and line charts)
- Fixes
- Copying a link to a new visualization from a dataset preview now correctly includes a data source
Expand Down
10 changes: 7 additions & 3 deletions app/components/chart-filters-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
FilterValue,
getAnimationField,
} from "@/configurator";
import { isDynamicMaxValue } from "@/configurator/components/field";
import {
Dimension,
Measure,
Expand Down Expand Up @@ -74,12 +75,15 @@ export const ChartFiltersList = (props: ChartFiltersListProps) => {
return [];
}

const filterValue = isDynamicMaxValue(f.value)
? dimension.values[dimension.values.length - 1].value
: f.value;
const value = isTemporalDimension(dimension)
? {
value: f.value,
label: timeFormatUnit(`${f.value}`, dimension.timeUnit),
value: filterValue,
label: timeFormatUnit(`${filterValue}`, dimension.timeUnit),
}
: dimension.values.find((d) => d.value === f.value);
: dimension.values.find((d) => d.value === filterValue);

return [{ dimension, value }];
});
Expand Down
51 changes: 29 additions & 22 deletions app/configurator/components/chart-configurator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ import { ChartTypeSelector } from "@/configurator/components/chart-type-selector
import {
ControlTabField,
DataFilterSelect,
DataFilterSelectDay,
DataFilterSelectTime,
DataFilterTemporal,
isDynamicMaxValue,
OnOffControlTabField,
} from "@/configurator/components/field";
import { canRenderDatePickerField } from "@/configurator/components/field-date-picker";
import {
getFiltersByMappingStatus,
isConfiguring,
Expand Down Expand Up @@ -97,7 +98,6 @@ type DataFilterSelectGenericProps = {

const DataFilterSelectGeneric = (props: DataFilterSelectGenericProps) => {
const { dimension, index, disabled, onRemove } = props;
const values = dimension.values;
const controls = dimension.isKeyDimension ? null : (
<Box sx={{ display: "flex", flexGrow: 1 }}>
<IconButton
Expand All @@ -124,25 +124,17 @@ const DataFilterSelectGeneric = (props: DataFilterSelectGenericProps) => {
isOptional: !dimension.isKeyDimension,
};

if (isTemporalDimension(dimension)) {
if (dimension.timeUnit === "Day") {
return <DataFilterSelectDay {...sharedProps} dimension={dimension} />;
} else if (dimension.timeUnit === "Month") {
return <DataFilterSelect {...sharedProps} />;
} else {
const from = `${values[0].value}`;
const to = `${values[values.length - 1]?.value || from}`;

return (
<DataFilterSelectTime
{...sharedProps}
from={from}
to={to}
timeUnit={dimension.timeUnit}
timeFormat={dimension.timeFormat}
/>
);
}
if (
isTemporalDimension(dimension) &&
canRenderDatePickerField(dimension.timeUnit)
) {
return (
<DataFilterTemporal
{...sharedProps}
dimension={dimension}
timeUnit={dimension.timeUnit}
/>
);
} else {
return (
<DataFilterSelect {...sharedProps} hierarchy={dimension.hierarchy} />
Expand Down Expand Up @@ -231,6 +223,21 @@ const useEnsurePossibleFilters = ({

const oldFilters = getChartConfigFilters(chartConfig.cubes, cube.iri);

// Replace resolved values with potential dynamic max values to not
// override the dynamic max value with the resolved value
for (const [key, value] of Object.entries(oldFilters)) {
if (
value.type === "single" &&
isDynamicMaxValue(value.value) &&
filters[key]
) {
filters[key] = {
type: "single",
value: `${value.value}`,
};
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand this 😓 Can you give me an example, or add a test 😁 ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add a test, but just to explain: possible filters coming from the backend do not return VISUALIZE_MAX_VALUE, but rather an actual date (e.g. 2023), so if we didn't replace it back with VISUALIZE_MAX_VALUE, we would potentially trigger an update that shouldn't happen (as in fact, the pinned date didn't change, but is still equal to VISUALIZE_MAX_VALUE). Maybe it would make sense to already do this replacement on the backend side? 🤔


if (!isEqual(filters, oldFilters) && !isEmpty(filters)) {
dispatch({
type: "CHART_CONFIG_FILTERS_UPDATE",
Expand Down
2 changes: 1 addition & 1 deletion app/configurator/components/field-date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const DatePickerField = (props: DatePickerFieldProps) => {
);
};

type DatePickerTimeUnit =
export type DatePickerTimeUnit =
| TimeUnit.Day
| TimeUnit.Week
| TimeUnit.Month
Expand Down
192 changes: 118 additions & 74 deletions app/configurator/components/field.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { t } from "@lingui/macro";
import { Trans, t } from "@lingui/macro";
import {
CircularProgress,
FormControlLabel,
FormGroup,
Switch as MUISwitch,
Theme,
Typography,
} from "@mui/material";
import { makeStyles } from "@mui/styles";
import { TimeLocaleObject, extent, timeFormat, timeParse } from "d3";
import { TimeLocaleObject } from "d3";
import get from "lodash/get";
import orderBy from "lodash/orderBy";
import React, {
Expand All @@ -28,6 +30,7 @@ import {
Slider,
Switch,
} from "@/components/form";
import { OpenMetadataPanelWrapper } from "@/components/metadata-panel";
import SelectTree from "@/components/select-tree";
import useDisclosure from "@/components/use-disclosure";
import {
Expand All @@ -42,7 +45,10 @@ import {
ControlTab,
OnOffControlTab,
} from "@/configurator/components/chart-controls/control-tab";
import { DatePickerField } from "@/configurator/components/field-date-picker";
import {
DatePickerField,
DatePickerTimeUnit,
} from "@/configurator/components/field-date-picker";
import {
getTimeIntervalFormattedSelectOptions,
getTimeIntervalWithProps,
Expand Down Expand Up @@ -70,9 +76,9 @@ import {
Component,
Dimension,
HierarchyValue,
ObservationValue,
TemporalDimension,
} from "@/domain/data";
import { truthy } from "@/domain/types";
import { useTimeFormatLocale } from "@/formatters";
import { TimeUnit } from "@/graphql/query-hooks";
import { useLocale } from "@/locales/use-locale";
Expand Down Expand Up @@ -252,94 +258,132 @@ export const DataFilterSelect = ({
);
};

const formatDate = timeFormat("%Y-%m-%d");
const parseDate = timeParse("%Y-%m-%d");
/** We can pin some filters' values to max value dynamically, so that when a new
* value is added to the dataset, it will be automatically used as default filter
* value for published charts.
*/
const VISUALIZE_MAX_VALUE = "VISUALIZE_MAX_VALUE";

/** Checks if a given filter value is supposed to be dynamiaclly pinned to max
* value.
*/
export const isDynamicMaxValue = (
value: ObservationValue
): value is "VISUALIZE_MAX_VALUE" => {
return value === VISUALIZE_MAX_VALUE;
};

export const DataFilterSelectDay = ({
dimension,
label,
disabled,
isOptional,
controls,
}: {
type DataFilterTemporalProps = {
dimension: TemporalDimension;
label: React.ReactNode;
timeUnit: DatePickerTimeUnit;
disabled?: boolean;
isOptional?: boolean;
controls?: React.ReactNode;
}) => {
};

export const DataFilterTemporal = (props: DataFilterTemporalProps) => {
const { dimension, timeUnit, disabled, isOptional, controls } = props;
const { label: _label, values, timeFormat } = dimension;
const formatLocale = useTimeFormatLocale();
const formatDate = formatLocale.format(timeFormat);
const parseDate = formatLocale.parse(timeFormat);
const fieldProps = useSingleFilterSelect({
cubeIri: dimension.cubeIri,
dimensionIri: dimension.iri,
});
const noneLabel = t({
id: "controls.dimensionvalue.none",
message: `No Filter`,
});
const optionalLabel = t({
id: "controls.select.optional",
message: `optional`,
});
const allOptions = useMemo(() => {
return isOptional
? [
{
value: FIELD_VALUE_NONE,
label: noneLabel,
isNoneValue: true,
},
...dimension.values,
]
: dimension.values;
}, [isOptional, dimension.values, noneLabel]);

const allOptionsSet = useMemo(() => {
return new Set(
allOptions
.filter((x) => x.value !== FIELD_VALUE_NONE)
.map((x) => {
try {
return x.value as string;
} catch (e) {
console.warn(`Bad value ${x.value}`);
return;
}
})
.filter(truthy)
);
}, [allOptions]);

const isDisabled = useCallback(
const usesMostRecentDate = isDynamicMaxValue(fieldProps.value);
const label = isOptional ? (
<>
{_label}{" "}
<span style={{ marginLeft: "0.25rem" }}>
(<Trans id="controls.select.optional">optional</Trans>)
</span>
</>
) : (
_label
);
const { minDate, maxDate, optionValues } = React.useMemo(() => {
if (values.length) {
const options = values.map((d) => {
return {
label: `${d.value}`,
value: `${d.value}`,
};
});

return {
minDate: parseDate(`${values[0].value}`) as Date,
maxDate: parseDate(`${values[values.length - 1].value}`) as Date,
optionValues: options.map((d) => d.value),
};
} else {
const date = new Date();
return {
minDate: date,
maxDate: date,
optionValues: [],
};
}
}, [values, parseDate]);
const isDateDisabled = React.useCallback(
(date: Date) => {
return !allOptionsSet.has(formatDate(date));
return !optionValues.includes(formatDate(date));
},
[allOptionsSet]
[optionValues, formatDate]
);

const dateValue = useMemo(() => {
const parsed = fieldProps.value ? parseDate(fieldProps.value) : undefined;
return parsed || new Date();
}, [fieldProps.value]);

const [minDate, maxDate] = useMemo(() => {
const [min, max] = extent(Array.from(allOptionsSet));
if (!min || !max) {
return [];
}
return [new Date(min), new Date(max)] as const;
}, [allOptionsSet]);

return (
<DatePickerField
label={isOptional ? `${label} (${optionalLabel})` : label}
disabled={disabled}
controls={controls}
name={`date-picker-${dimension.iri}`}
label={
<Flex
sx={{
width: "100%",
justifyContent: "space-between",
alignItems: "center",
}}
>
<FieldLabel
label={
<OpenMetadataPanelWrapper dim={dimension}>
{label}
</OpenMetadataPanelWrapper>
}
/>
{/* FIXME: adapt to design */}
<FormGroup>
<FormControlLabel
control={
<MUISwitch
checked={usesMostRecentDate}
onChange={() =>
fieldProps.onChange({
target: {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Instead of relying on the state usesMOstRecentDate, I think it is preferrable to rely on the checked value coming in the 2nd argument of the callback. This way the function could be stable, and I think it'd be clearer since here you have to think "ok usesMostRecentDate is true, so when it is clicked it shuold be false, and the max date should be used". IN the callback, the checked parameter is already right, so I think it's clearer.

value: usesMostRecentDate
? formatDate(maxDate)
: VISUALIZE_MAX_VALUE,
},
})
}
/>
}
// FIXME: adapt to design, translate
label={<Typography variant="caption">Use most recent</Typography>}
/>
</FormGroup>
</Flex>
}
value={
usesMostRecentDate ? maxDate : (parseDate(fieldProps.value) as Date)
}
onChange={fieldProps.onChange}
name={dimension.iri}
value={dateValue}
isDateDisabled={isDisabled}
isDateDisabled={isDateDisabled}
timeUnit={timeUnit}
dateFormat={formatDate}
minDate={minDate}
maxDate={maxDate}
disabled={disabled || usesMostRecentDate}
controls={controls}
/>
);
};
Expand Down
Loading
Loading