Skip to content

Commit

Permalink
Merge pull request #1291 from visualize-admin/feat/most-recent-date-f…
Browse files Browse the repository at this point in the history
…ilter

feat: Dynamic most recent date filter
  • Loading branch information
bprusinowski authored Dec 8, 2023
2 parents 78582b7 + 29b3d4f commit 610a170
Show file tree
Hide file tree
Showing 6 changed files with 270 additions and 182 deletions.
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}`,
};
}
}

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: {
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

1 comment on commit 610a170

@vercel
Copy link

@vercel vercel bot commented on 610a170 Dec 8, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

visualization-tool – ./

visualization-tool-git-main-ixt1.vercel.app
visualization-tool-alpha.vercel.app
visualization-tool-ixt1.vercel.app

Please sign in to comment.